Files
ClaudeandClaude Opus 5.5 c7cd436ec2 Öffnungszeiten: Mails K24–K27 heißen jetzt K27–K30, Feld k24At heißt einschaltMailAt
K24–K26 sind seit dem Webshop-Livegang (main) vergeben; der Mail-Lader sucht die Vorlage über den
Dateianfang, doppelte Nummern hätten die falsche Mail geschickt. Das Feld ist noch nicht in der
Produktion angelegt (zeiten-setup.mjs lief noch nicht), daher jetzt ein sprechender Name.
Spec und Plan behalten die alten Nummern (Hinweis oben im Dokument).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 06:00:17 +00:00

455 lines
23 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { test, after, beforeEach, afterEach, mock } from 'node:test'
import assert from 'node:assert/strict'
import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.js'
import { mailSink } from './support/mailSink.js'
import { schemaKeys } from './support/schema.js'
import { useTestEnv } from './support/testEnv.js'
import { ZEITEN_DATETIMES } from '../server/services/zeiten/schema.js'
// Bezahlweg von Oeffnungszeiten live (Aufgabe 17, Spec 3.3 und 4.6): Checkout-Sperre "inklusive" (pruefeInklusive in featureOrder.js),
// Zahlung (markPaid in siteFeatures.js): setup, unlockedBy kauf, Neubau VOR dem Ticket, einbau am Dokument, Ticket-Text nennt das Ergebnis.
// News und Terminplaner bleiben unveraendert (kein inklusive-Check, kein Neubau, kein unlockedBy).
const aw = await startAppwriteMock({
schema: {
portalFeatures: schemaKeys('portalFeatures'),
subscriptions: schemaKeys('subscriptions'),
featureConsents: schemaKeys('featureConsents'),
termineConfig: schemaKeys('termineConfig'),
oeffnungszeiten: schemaKeys('oeffnungszeiten'),
zeitenErinnerungen: schemaKeys('zeitenErinnerungen'),
},
// Datumsfelder wie in Appwrite: Unlesbares oder '' waere dort ein 400 (leeren geht nur mit null)
datetimes: {
...ZEITEN_DATETIMES,
portalFeatures: ['orderedAt', 'activatedAt', 'disabledAt', 'consentAt', 'aboEndeMailAt', 'aboEndeAt', 'einschaltMailAt'],
featureConsents: ['acceptedAt'],
},
defaultLimit: 25,
})
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { config } = await import('../server/config.js')
const { startTestApp, post, postWebhook } = await import('./support/testApp.js')
const { customerCookie, hostingDoc, seedFixtures, subscriptionDoc, wartungDoc } = await import('./support/fixtures.js')
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
const { setMailTransportForTests } = await import('../server/services/mailer.js')
const { default: funktionenRoutes } = await import('../server/routes/funktionen.js')
const { default: aboRoutes } = await import('../server/routes/abo.js')
const { orderDeps, pruefeInklusive } = await import('../server/services/featureOrder.js')
const { aboMemo } = await import('../server/services/aboStatus.js')
const sf = await import('../server/services/siteFeatures.js')
const { zeitenDeps } = await import('../server/services/zeiten/neubau.js')
const { resetZeitenCacheForTests } = await import('../server/services/zeiten/cache.js')
const app = await startTestApp({ '/api/funktionen': funktionenRoutes, '/api/abo': aboRoutes })
after(() => Promise.all([app.close(), aw.close(), st.close()]))
after(() => setMailTransportForTests(null))
const Z = config.features.zeiten
const T = config.features.termine
const MODUS = { zeiten: { mode: config.zeiten.mode, pilotEmails: [...config.zeiten.pilotEmails] }, termine: { mode: config.termine.mode, pilotEmails: [...config.termine.pilotEmails] } }
const c1 = customerCookie('c1')
const c3 = customerCookie('c3')
const INKLUSIVE_TEXT = 'Im Wartungs-Abo bereits enthalten – einfach einschalten.'
const SUBJECT = {
K15: 'Deine Bestellung: Öffnungszeiten live',
T16: 'Neue Funktion bestellt: Öffnungszeiten live',
}
const BEREITS_DA = /Bezahlt, aber schon vorhanden/
const wartung = (patch = {}) => aw.seed('subscriptions', subscriptionDoc('sub_h', { ...wartungDoc, ...patch }))
const projekt = (id, over = {}) =>
aw.seed('websiteProjects', { $id: id, customerId: 'c1', projectName: `Projekt ${id}`, subdomain: id, repoFullName: `JUSN/${id}`, previewMode: 'auto', previewDetected: 'static', ...over })
const bezahlteHaken = () => Z.consent.items.filter((item) => item.haken).map((item) => item.id)
const koerper = (projectId = 'p1', extra = {}) => ({ projectId, zustimmung: { version: Z.consent.version, haken: bezahlteHaken() }, ...extra })
const checkout = (body, cookie = c1, key = 'zeiten') => post(`/api/funktionen/${key}/checkout`, body, { cookie })
const sessionAufrufe = () => st.calls.filter((c) => c.method === 'POST' && c.path === '/v1/checkout/sessions')
const preisDerSitzung = (aufruf) => st.prices.get(aufruf.body['line_items[0][price]'])
const zeitenDoc = (projectId = 'p1') => aw.docs('portalFeatures').get(`f_${projectId}_zeiten`)
const tickets = () => [...aw.docs('workorders').values()]
const mailsMit = (subject) => sink.sent.filter((m) => m.subject === subject)
const keineSpuren = () =>
assert.deepEqual(
{ features: aw.docs('portalFeatures').size, consents: aw.docs('featureConsents').size, tickets: aw.docs('workorders').size, sitzungen: sessionAufrufe().length, mails: sink.sent.length },
{ features: 0, consents: 0, tickets: 0, sitzungen: 0, mails: 0 }
)
const event = (type, session) => ({ id: `evt_${Math.random()}`, object: 'event', type, data: { object: session } })
const webhook = (type, session) => postWebhook(event(type, session))
/** Bezahlte Sitzung im Ereignis; die Rechnung liegt im Stripe-Nachbau. */
function bezahlt(sessionId, { projectId = 'p1', featureKey = 'zeiten', invoice = `in_${sessionId}` } = {}) {
st.invoices.set(invoice, { id: invoice, object: 'invoice', hosted_invoice_url: `https://invoice.stripe.test/${invoice}` })
return { id: sessionId, object: 'checkout.session', mode: 'payment', payment_status: 'paid', invoice, metadata: { customerId: 'c1', projectId, featureKey } }
}
/** Echter Bestellweg: Checkout-Route legt die Stripe-Sitzung an und das Dokument auf 'ordered' (kein Wartungs-Abo). */
async function bestelle(projectId = 'p1') {
const res = await checkout(koerper(projectId))
assert.equal(res.status, 200, JSON.stringify(res.body))
const sessionId = [...st.checkoutSessions.values()].at(-1).id
assert.equal(zeitenDoc(projectId).state, 'ordered')
return sessionId
}
let sink
let rebuilds
const originalRebuild = zeitenDeps.rebuild
const originalAboMemo = orderDeps.aboMemo
beforeEach(async () => {
for (const col of ['subscriptions', 'portalFeatures', 'featureConsents', 'oeffnungszeiten', 'workorders', 'websiteProjects']) {
for (const id of aw.docs(col).keys()) await deleteDocument(col, id)
}
resetZeitenCacheForTests()
st.checkoutSessions.clear()
st.invoices.clear()
st.calls.length = 0
seedFixtures(aw)
projekt('p1')
projekt('p2')
projekt('p3', { customerId: 'c3' })
aw.seed('customers', { $id: 'c1', appwriteUserId: 'u1', email: 'k@example.invalid', name: 'Kunde Eins', status: 'active', portalAccessEnabled: true })
config.zeiten.mode = 'pilot'
config.zeiten.pilotEmails = ['*']
config.termine.mode = 'pilot'
config.termine.pilotEmails = ['*']
T.bestellpruefung = async () => []
sink = mailSink()
setMailTransportForTests(sink)
rebuilds = []
zeitenDeps.rebuild = async (p) => {
rebuilds.push({ projektId: p.$id, tickets: aw.docs('workorders').size, state: zeitenDoc(p.$id)?.state, unlockedBy: zeitenDoc(p.$id)?.unlockedBy, einbau: zeitenDoc(p.$id)?.einbau })
return { queued: true }
}
mock.method(console, 'error', () => {})
mock.method(console, 'warn', () => {})
mock.method(console, 'log', () => {})
})
afterEach(() => {
mock.restoreAll()
zeitenDeps.rebuild = originalRebuild
orderDeps.aboMemo = originalAboMemo
})
after(() => {
config.zeiten.mode = MODUS.zeiten.mode
config.zeiten.pilotEmails = MODUS.zeiten.pilotEmails
config.termine.mode = MODUS.termine.mode
config.termine.pilotEmails = MODUS.termine.pilotEmails
delete T.bestellpruefung
})
// ------------------------------------------------------------------------------------- Checkout-Sperre inklusive
test('Checkout zeiten mit laufendem Abo ⇒ 409 inklusive, keine Stripe-Sitzung, keine Spur', async () => {
wartung()
const res = await checkout(koerper())
assert.equal(res.status, 409, JSON.stringify(res.body))
assert.equal(res.body.error, INKLUSIVE_TEXT)
assert.equal(res.body.code, 'inklusive')
assert.equal(res.body.grund, null)
keineSpuren()
})
test('Checkout zeiten bei Abo nicht aktiv ⇒ 409 inklusive mit grund nicht_aktiv', async () => {
for (const status of ['trialing', 'past_due']) {
wartung({ status })
const res = await checkout(koerper())
assert.equal(res.status, 409, status)
assert.equal(res.body.error, INKLUSIVE_TEXT, status)
assert.equal(res.body.code, 'inklusive', status)
assert.equal(res.body.grund, 'nicht_aktiv', status)
keineSpuren()
}
})
test('Checkout zeiten ohne Abo, mit Hosting-Abo oder gekündigt ⇒ Stripe-Sitzung über 3000 Cent', async () => {
const faelle = [
['Hosting-Abo', () => aw.seed('subscriptions', hostingDoc), c1, 'p1'],
['gekündigt', () => wartung({ cancelAtPeriodEnd: true }), c1, 'p2'],
['gar kein Abo', () => {}, c3, 'p3'],
]
for (const [name, vorbereiten, cookie, projectId] of faelle) {
for (const id of aw.docs('subscriptions').keys()) await deleteDocument('subscriptions', id)
vorbereiten()
const vorher = sessionAufrufe().length
const res = await checkout(koerper(projectId), cookie)
assert.equal(res.status, 200, `${name}: ${JSON.stringify(res.body)}`)
assert.match(res.body.url, /^https:\/\/checkout\.stripe\.test\/c\/pay\/cs_test_/, name)
assert.equal(sessionAufrufe().length, vorher + 1, name)
assert.equal(preisDerSitzung(sessionAufrufe().at(-1)).unit_amount, 3000, name)
assert.equal(sessionAufrufe().at(-1).body['metadata[featureKey]'], 'zeiten', name)
assert.equal(zeitenDoc(projectId).state, 'ordered', name)
}
})
test('Checkout zeiten: Abo nicht lesbar ⇒ 500 ohne Rohtext, keine Stripe-Sitzung, keine Spur (fail-closed)', async () => {
wartung()
aw.failNext({ collection: 'subscriptions', status: 500, message: 'Interner Appwrite-Text mit Kunde c1' })
const res = await checkout(koerper())
assert.equal(res.status, 500)
assert.equal(JSON.stringify(res.body).includes('Appwrite'), false)
keineSpuren()
})
test('Reihenfolge: die Sperre inklusive kommt nach Zustimmung, Projekt, Schalter und Unterlagen', async () => {
wartung()
const lesungen = []
orderDeps.aboMemo = () => {
const memo = aboMemo()
return { ...memo, bestellbar: async (id) => (lesungen.push(id), memo.bestellbar(id)) }
}
// 3 Zustimmung
const ohne = await checkout({ projectId: 'p1' })
assert.equal(ohne.status, 400)
assert.equal(ohne.body.code, 'zustimmung_fehlt')
// 4 Projekt (fremd)
assert.equal((await checkout(koerper('p3'))).status, 403)
// 5 Schalter
config.zeiten.mode = 'off'
const aus = await checkout(koerper())
assert.equal(aus.status, 409)
assert.equal(aus.body.code, 'nicht_verfuegbar')
config.zeiten.mode = 'pilot'
// 5b Unterlagen
Z.unterlagenBereit = async () => false
const unterlagen = await checkout(koerper())
assert.equal(unterlagen.status, 503)
assert.equal(unterlagen.body.code, 'unterlagen_fehlen')
delete Z.unterlagenBereit
assert.deepEqual(lesungen, [], 'bis hierhin wurde das Abo nicht gelesen')
// 7b Abo: jetzt kommt inklusive, mit genau einer Abo-Lesung
const inklusive = await checkout(koerper())
assert.equal(inklusive.status, 409)
assert.equal(inklusive.body.code, 'inklusive')
assert.deepEqual(lesungen, ['c1'])
keineSpuren()
})
test('News und Termine unverändert: kein inklusive-Check (Abo wird für News nicht, für Termine einmal gelesen)', async () => {
wartung()
const lesungen = []
orderDeps.aboMemo = () => {
const memo = aboMemo()
return { ...memo, bestellbar: async (id) => (lesungen.push(id), memo.bestellbar(id)) }
}
const news = await checkout({ projectId: 'p1', consent: true }, c1, 'news')
assert.equal(news.status, 200, JSON.stringify(news.body))
assert.deepEqual(lesungen, [], 'News liest das Abo nicht')
const haken = T.consent.items.filter((item) => item.haken).map((item) => item.id)
const termine = await checkout({ projectId: 'p2', zustimmung: { version: T.consent.version, haken } }, c1, 'termine')
assert.equal(termine.status, 200, JSON.stringify(termine.body))
assert.deepEqual(lesungen, ['c1'], 'Termine liest das Abo einmal (Wartungs-Abo-Pflicht), nicht ein zweites Mal für inklusive')
assert.equal(aw.docs('portalFeatures').get('f_p1_news')?.state, 'ordered')
assert.equal(aw.docs('portalFeatures').get('f_p2_termine')?.state, 'ordered')
})
test('pruefeInklusive: nur bei aboInklusive; ok und nicht_aktiv sperren, kein_abo und gekuendigt nicht; Lesefehler ⇒ 500', async () => {
wartung()
await pruefeInklusive({ ...Z, aboInklusive: false }, 'c1') // Funktion ohne aboInklusive: nichts
await pruefeInklusive(config.features.news, 'c1')
await assert.rejects(pruefeInklusive(Z, 'c1'), (err) => err.status === 409 && err.code === 'inklusive' && err.message === INKLUSIVE_TEXT && err.body.grund === null)
wartung({ status: 'past_due' })
await assert.rejects(pruefeInklusive(Z, 'c1'), (err) => err.status === 409 && err.code === 'inklusive' && err.body.grund === 'nicht_aktiv')
wartung({ cancelAtPeriodEnd: true })
await pruefeInklusive(Z, 'c1')
await pruefeInklusive(Z, 'c3')
aw.failNext({ collection: 'subscriptions', status: 500, message: 'Geheim' })
await assert.rejects(pruefeInklusive(Z, 'c1'), (err) => err.status === 500 && err.code === undefined) // wie pruefeAbo: die Route zeigt nur ihren allgemeinen Text
})
// -------------------------------------------------------------------------------------------------------- Zahlung
test('Zahlung ⇒ setup, unlockedBy kauf, Neubau vor dem Ticket, einbau am Dokument, Ticket nennt das Ergebnis, K15 und T16', async () => {
const sessionId = await bestelle()
const res = await webhook('checkout.session.completed', bezahlt(sessionId))
assert.equal(res.status, 200, JSON.stringify(res.body))
const doc = zeitenDoc()
assert.deepEqual(
[doc.state, doc.enabled, doc.purchaseStatus, doc.unlockedBy, doc.unlockedByPurchase, doc.einbau, doc.zahlungMeldung],
['setup', false, 'paid', 'kauf', true, 'angestossen', '']
)
assert.equal(sf.unlockedByVon(doc), 'kauf')
assert.equal(doc.invoiceUrl, `https://invoice.stripe.test/in_${sessionId}`)
// Reihenfolge: der Neubau lief, als der Zustand schon setup/kauf war, aber noch ohne Ticket und ohne einbau
assert.deepEqual(rebuilds, [{ projektId: 'p1', tickets: 0, state: 'setup', unlockedBy: 'kauf', einbau: '' }])
// Ticket: einmal, Text nennt das Ergebnis des Neubaus (automatisch eingesetzt) und den siteKey
assert.equal(tickets().length, 1)
const [ticket] = tickets()
assert.equal(doc.ticketId, ticket.$id)
assert.equal(ticket.topic, 'Öffnungszeiten live einbauen – Projekt p1')
assert.match(ticket.details, /Einbau: automatisch eingesetzt \(nach dem nächsten Veröffentlichen prüfen\)/)
assert.doesNotMatch(ticket.details, /bitte von Hand einsetzen/)
assert.ok(ticket.details.includes(`data-site="${doc.siteKey}"`))
// K15 an den Kunden, T16 ans Team, je einmal; keine K27 (die gehört zum Gratisweg)
assert.equal(mailsMit(SUBJECT.K15).length, 1)
assert.deepEqual(mailsMit(SUBJECT.K15)[0].to.map((a) => a.address), ['k@example.invalid'])
assert.equal(mailsMit(SUBJECT.T16).length, 1)
assert.doesNotMatch(mailsMit(SUBJECT.T16)[0].text, BEREITS_DA)
assert.equal(mailsMit('Öffnungszeiten live ist eingeschaltet').length, 0)
assert.equal(doc.einschaltMailAt ?? null, null)
})
test('Zahlung, Neubau nicht möglich (kein Repo): einbau manuell, Ticket sagt „bitte von Hand einsetzen“', async () => {
aw.seed('websiteProjects', { $id: 'p1', customerId: 'c1', projectName: 'Ohne Repo' })
const sessionId = await bestelle()
assert.equal((await webhook('checkout.session.completed', bezahlt(sessionId))).status, 200)
assert.deepEqual(rebuilds, [])
assert.equal(zeitenDoc().einbau, 'manuell')
assert.equal(zeitenDoc().unlockedBy, 'kauf')
assert.equal(tickets().length, 1)
assert.match(tickets()[0].details, /Einbau: bitte von Hand einsetzen/)
assert.doesNotMatch(tickets()[0].details, /automatisch eingesetzt/)
})
test('Zahlung, Neubau wirft: einbau manuell, Zustand und Ticket bleiben (bezahlt ist bezahlt)', async () => {
zeitenDeps.rebuild = async () => {
throw new Error('Runner nicht erreichbar')
}
const sessionId = await bestelle()
assert.equal((await webhook('checkout.session.completed', bezahlt(sessionId))).status, 200)
assert.deepEqual([zeitenDoc().state, zeitenDoc().einbau, zeitenDoc().unlockedBy], ['setup', 'manuell', 'kauf'])
assert.equal(tickets().length, 1)
assert.match(tickets()[0].details, /Einbau: bitte von Hand einsetzen/)
assert.equal(mailsMit(SUBJECT.K15).length, 1)
})
test('Stripe wiederholt den Webhook: kein zweiter Neubau, kein zweites Ticket, keine zweite Mail', async () => {
const sessionId = await bestelle()
assert.equal((await webhook('checkout.session.completed', bezahlt(sessionId))).status, 200)
assert.equal((await webhook('checkout.session.completed', bezahlt(sessionId))).status, 200)
assert.equal((await webhook('checkout.session.async_payment_succeeded', bezahlt(sessionId))).status, 200)
assert.equal(rebuilds.length, 1)
assert.equal(tickets().length, 1)
assert.equal(mailsMit(SUBJECT.K15).length, 1)
assert.equal(mailsMit(SUBJECT.T16).length, 1)
})
test('Ticket scheitert ⇒ Webhook 500, Zustand setup/kauf/einbau gespeichert; die Wiederholung legt das Ticket an, ohne zweiten Neubau', async () => {
const sessionId = await bestelle()
aw.failNext({ collection: 'workorders', method: 'POST', status: 500, message: 'Ticketsystem down' })
const erster = await webhook('checkout.session.completed', bezahlt(sessionId))
assert.equal(erster.status, 500)
assert.deepEqual([zeitenDoc().state, zeitenDoc().unlockedBy, zeitenDoc().einbau], ['setup', 'kauf', 'angestossen'])
assert.equal(zeitenDoc().ticketId ?? '', '')
assert.equal(tickets().length, 0)
assert.equal(mailsMit(SUBJECT.K15).length, 0, 'K15 erst nach dem Ticket')
const zweiter = await webhook('checkout.session.completed', bezahlt(sessionId))
assert.equal(zweiter.status, 200)
assert.equal(rebuilds.length, 1, 'einbau war schon gespeichert: kein zweiter Neubau')
assert.equal(tickets().length, 1)
assert.match(tickets()[0].details, /Einbau: automatisch eingesetzt/)
assert.equal(zeitenDoc().ticketId, tickets()[0].$id)
assert.equal(mailsMit(SUBJECT.K15).length, 1)
assert.equal(mailsMit(SUBJECT.T16).length, 1)
})
test('Neubau-Absturz vor dem Speichern von einbau: die Wiederholung baut neu und legt dann das Ticket an', async () => {
const sessionId = await bestelle()
// der Zustand ist gespeichert (setup, kauf, einbau leer), aber weder Neubau noch Ticket sind gelaufen
aw.seed('portalFeatures', { ...zeitenDoc(), state: 'setup', enabled: false, purchaseStatus: 'paid', unlockedBy: 'kauf', unlockedByPurchase: true, einbau: '' })
assert.equal((await webhook('checkout.session.completed', bezahlt(sessionId))).status, 200)
assert.equal(rebuilds.length, 1)
assert.equal(tickets().length, 1)
assert.equal(zeitenDoc().einbau, 'angestossen')
})
test('Neubauten sind serialisiert: zwei gleichzeitige Zahlungen starten nie zwei Neubauten zugleich', async () => {
let gleichzeitig = 0
let hoechstens = 0
zeitenDeps.rebuild = async (p) => {
gleichzeitig += 1
hoechstens = Math.max(hoechstens, gleichzeitig)
await new Promise((resolve) => setTimeout(resolve, 25))
gleichzeitig -= 1
rebuilds.push({ projektId: p.$id })
return { queued: true }
}
const s1 = await bestelle('p1')
const s2 = await bestelle('p2')
const [a, b] = await Promise.all([webhook('checkout.session.completed', bezahlt(s1, { projectId: 'p1' })), webhook('checkout.session.completed', bezahlt(s2, { projectId: 'p2' }))])
assert.deepEqual([a.status, b.status], [200, 200])
assert.equal(rebuilds.length, 2)
assert.equal(hoechstens, 1)
assert.deepEqual([zeitenDoc('p1').einbau, zeitenDoc('p2').einbau], ['angestossen', 'angestossen'])
})
// -------------------------------------------------------------------------------------- späte Zahlung, Altbestand
const seedeZeiten = (over) =>
aw.seed('portalFeatures', {
$id: 'f_p1_zeiten',
customerId: 'c1',
projectId: 'p1',
featureKey: 'zeiten',
state: 'active',
enabled: true,
unlockedByPurchase: false,
purchaseStatus: '',
siteKey: 'abcdefghijklmnop',
stripeSessionId: 'cs_alt',
...over,
})
test('späte Zahlung auf ein per Abo eingeschaltetes Dokument: gebucht und gemeldet, unlockedBy bleibt abo, nichts eingerichtet', async () => {
seedeZeiten({ state: 'active', unlockedBy: 'abo', ticketId: 't_alt' })
assert.equal((await webhook('checkout.session.completed', bezahlt('cs_alt'))).status, 200)
const doc = zeitenDoc()
assert.deepEqual([doc.state, doc.purchaseStatus, doc.unlockedBy, doc.zahlungMeldung], ['active', 'paid', 'abo', 'gemeldet'])
assert.equal(rebuilds.length, 0)
assert.equal(tickets().length, 0)
assert.equal(mailsMit(SUBJECT.K15).length, 0)
assert.equal(mailsMit(SUBJECT.T16).length, 1)
assert.match(mailsMit(SUBJECT.T16)[0].text, BEREITS_DA)
})
test('Zahlung auf ein Abo-Dokument in setup ohne Ticket: kein zweites Ticket neben dem Nachholen (holeZeitenNach), unlockedBy bleibt abo', async () => {
seedeZeiten({ state: 'setup', enabled: false, unlockedBy: 'abo' })
assert.equal((await webhook('checkout.session.completed', bezahlt('cs_alt'))).status, 200)
const doc = zeitenDoc()
assert.deepEqual([doc.state, doc.unlockedBy, doc.purchaseStatus], ['setup', 'abo', 'paid'])
assert.equal(doc.ticketId ?? '', '')
assert.equal(tickets().length, 0)
assert.equal(rebuilds.length, 0)
assert.equal(mailsMit(SUBJECT.K15).length, 0)
assert.match(mailsMit(SUBJECT.T16)[0].text, BEREITS_DA)
})
test('späte Zahlung ohne unlockedBy am Dokument (Altbestand): unlockedBy wird kauf, passend zu unlockedByPurchase true', async () => {
seedeZeiten({ state: 'active', ticketId: 't_alt' })
assert.equal((await webhook('checkout.session.completed', bezahlt('cs_alt'))).status, 200)
const doc = zeitenDoc()
assert.deepEqual([doc.unlockedBy, doc.unlockedByPurchase, doc.purchaseStatus], ['kauf', true, 'paid'])
assert.equal(rebuilds.length, 0)
})
// ------------------------------------------------------------------------------------ News und Termine unverändert
test('News und Termine bezahlt: wie bisher, kein unlockedBy, kein einbau, kein Neubau', async () => {
// News (alter Weg ohne Abo) und Terminplaner (mit Wartungs-Abo, TERMINE_ENABLED=pilot)
wartung()
const haken = T.consent.items.filter((item) => item.haken).map((item) => item.id)
assert.equal((await checkout({ projectId: 'p1', consent: true }, c1, 'news')).status, 200)
assert.equal((await checkout({ projectId: 'p2', zustimmung: { version: T.consent.version, haken } }, c1, 'termine')).status, 200)
const sNews = aw.docs('portalFeatures').get('f_p1_news').stripeSessionId
const sTermine = aw.docs('portalFeatures').get('f_p2_termine').stripeSessionId
assert.equal((await webhook('checkout.session.completed', bezahlt(sNews, { projectId: 'p1', featureKey: 'news' }))).status, 200)
assert.equal((await webhook('checkout.session.completed', bezahlt(sTermine, { projectId: 'p2', featureKey: 'termine' }))).status, 200)
for (const id of ['f_p1_news', 'f_p2_termine']) {
const doc = aw.docs('portalFeatures').get(id)
assert.deepEqual([doc.state, doc.purchaseStatus, doc.unlockedByPurchase], ['setup', 'paid', true], id)
assert.ok(doc.ticketId, id)
assert.equal(doc.unlockedBy ?? null, null, `${id}: unlockedBy nicht geschrieben`)
assert.equal(doc.einbau ?? null, null, `${id}: einbau nicht geschrieben`)
}
assert.equal(rebuilds.length, 0)
assert.equal(tickets().length, 2)
})