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) })