import { readFileSync } from 'node:fs' import { test, after } from 'node:test' import assert from 'node:assert/strict' import { startAppwriteMock } from './support/appwriteMock.js' import { startFakeStripe } from './support/fakeStripe.js' import { useTestEnv } from './support/testEnv.js' import { schemaKeys } from './support/schema.js' // websiteProjects, workorders und customers legt das Setup-Skript nicht an: ohne Schema im Nachbau const aw = await startAppwriteMock({ schema: { portalFeatures: schemaKeys('portalFeatures'), subscriptions: schemaKeys('subscriptions'), featureConsents: schemaKeys('featureConsents') }, }) const st = await startFakeStripe() useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) const { config } = await import('../server/config.js') const { startTestApp, get, rawGet, post, postWebhook } = await import('./support/testApp.js') const { customerCookie, seedFixtures } = await import('./support/fixtures.js') const { default: funktionenRoutes } = await import('../server/routes/funktionen.js') const { default: aboRoutes } = await import('../server/routes/abo.js') const { default: teamRoutes } = await import('../server/routes/team/index.js') const { default: publicNewsRoutes } = await import('../server/routes/publicNews.js') const sf = await import('../server/services/siteFeatures.js') const { deleteDocument } = await import('../server/services/appwriteAdmin.js') const { setMailTransportForTests } = await import('../server/services/mailer.js') const { mailSink } = await import('./support/mailSink.js') const app = await startTestApp({ '/api/funktionen': funktionenRoutes, '/api/abo': aboRoutes, '/api/team': teamRoutes, '/api/public/news': publicNewsRoutes, }) const mails = mailSink() setMailTransportForTests(mails) after(() => { setMailTransportForTests(null) return Promise.all([app.close(), aw.close(), st.close()]) }) seedFixtures(aw) aw.seed('websiteProjects', { $id: 'p1', customerId: 'c1', projectName: 'Bäckerei', liveDomain: 'baeckerei.de' }) aw.seed('websiteProjects', { $id: 'p2', customerId: 'c1', projectName: 'Café' }) aw.seed('websiteProjects', { $id: 'p3', customerId: 'c1', projectName: 'Kiosk' }) aw.seed('websiteProjects', { $id: 'p4', customerId: 'c1', projectName: 'Bistro' }) aw.seed('websiteProjects', { $id: 'p5', customerId: 'c2', projectName: 'Fremd' }) const cookie = customerCookie('c1') const checkout = (projectId, consent = true) => post('/api/funktionen/news/checkout', { projectId, consent }, { cookie }) const sessions = () => [...st.checkoutSessions.values()] const workorders = () => [...aw.docs('workorders').values()].filter((t) => String(t.topic).startsWith('News-Pinnwand einbauen')) const subjects = () => mails.sent.map((m) => m.subject) const event = (type, session) => ({ id: `evt_${Math.random()}`, object: 'event', type, data: { object: session } }) const paidSession = (id, projectId, extra = {}) => ({ id, object: 'checkout.session', mode: 'payment', payment_status: 'paid', metadata: { customerId: 'c1', projectId, featureKey: 'news' }, ...extra, }) test('liste_zustaende', async () => { const body = await get('/api/funktionen', { cookie }) assert.equal(body.setupEstimate, 'meist innerhalb von 1–2 Wochen') assert.equal(body.consentText, config.funktionen.consentText) assert.equal(body.projects.length, 4) for (const p of body.projects) assert.equal(p.features.news.state, 'off') // Terminplaner: zweite Funktion je Projekt, Schalter TERMINE_ENABLED (Vorgabe off) - Einzelheiten in funktionenTermine.test.js for (const p of body.projects) assert.equal(p.features.termine.state, 'off') assert.deepEqual(body.termine, { verfuegbar: false }) // Oeffnungszeiten live: Schalter ZEITEN_ENABLED (Vorgabe off): genau { verfuegbar: false } und je Projekt ohne Status, leer // (Angebot, Ansicht und Status: test/zeitenEinschalten.test.js) assert.deepEqual(body.zeiten, { verfuegbar: false }) for (const p of body.projects) assert.deepEqual([p.features.zeiten.state, p.zeitenStatus, p.zeitenLeer], ['off', null, true]) // Register-Ansicht (B12): Preise und Bestelltexte beider Funktionen aus config.features, auch bei termine.verfuegbar: false assert.deepEqual(Object.keys(body.features).sort(), ['news', 'termine', 'zeiten']) assert.equal(body.features.news.priceLabel, '30 €') assert.equal(body.features.termine.priceLabel, '149 €') assert.equal(body.ustHinweis, config.ustHinweis) assert.ok(typeof body.ustHinweis === 'string' && body.ustHinweis.length > 0) // Items vollstaendig: Kennung, Wortlaut und Haken jedes Registerpunkts, dazu Fassung und Knopf for (const key of ['news', 'termine']) { const eintrag = config.features[key] const ansicht = body.features[key] assert.equal(ansicht.key, key) assert.equal(ansicht.name, eintrag.label.name) assert.equal(ansicht.setupEstimate, eintrag.setupEstimate) assert.equal(ansicht.consent.version, eintrag.consent.version) assert.equal(ansicht.consent.knopf, eintrag.consent.knopf) assert.ok(eintrag.consent.items.length > 0) assert.deepEqual( ansicht.consent.items, eintrag.consent.items.map((item) => ({ id: item.id, text: item.text, haken: item.haken === true })), ) assert.equal(ansicht.requiresWartungsAbo, eintrag.requiresWartungsAbo) assert.equal(ansicht.returnHash, eintrag.returnHash) // nichts Internes: nur die oeffentlichen Felder (kein ticket, keine mails, keine Haken oder Meldungen) assert.deepEqual(Object.keys(ansicht).sort(), [ 'avv', 'consent', 'key', 'kurz', 'leistungsbeschreibung', 'name', 'priceLabel', 'requiresWartungsAbo', 'returnHash', 'setupEstimate', 'widerruf', ]) assert.deepEqual(Object.keys(ansicht.consent).sort(), ['items', 'knopf', 'version']) } assert.deepEqual(body.features.termine.consent.items.map((item) => item.id), ['unterlagen', 'gesundheit', 'beginn', 'information']) assert.deepEqual(body.features.news.consent.items.map((item) => item.id), ['beginn']) const flach = JSON.stringify(body.features) assert.equal(flach.includes('"ticket"') || flach.includes('"mails"'), false) // Alias: setupEstimate und consentText bleiben die News-Werte (Rueckfall aelterer Clients) assert.equal(body.setupEstimate, body.features.news.setupEstimate) assert.equal(body.consentText, body.features.news.consent.items[0].text) }) // Vertragsunterlagen in der Liste (Task F8): Fassung, absolute versionierte Links und die Widerrufsbelehrung des Terminplaners test('liste_legal: legal.termine mit Fassung, https-Links mit Fassung und der Belehrung der Funktionen; News null', async () => { const { widerrufsbelehrungFunktion } = await import('../server/services/termine/rechtstexte.js') const { legalPaths } = await import('../server/services/legalDocs.js') const V = config.termine.avvVersion const body = await get('/api/funktionen', { cookie }) assert.deepEqual(Object.keys(body.legal).sort(), ['news', 'termine']) assert.equal(body.legal.news, null) const termine = body.legal.termine assert.deepEqual(Object.keys(termine).sort(), ['belehrung', 'links', 'version']) assert.equal(termine.version, V) assert.deepEqual(Object.keys(termine.links), ['lb', 'avv', 'widerruf']) const adressen = legalPaths('termine', V) for (const [doc, link] of Object.entries(termine.links)) { assert.match(link, /^https:\/\//, `${doc}: https`) assert.ok(link.endsWith(`-${V}.html`), `${doc}: versioniert`) assert.equal(link, `${config.publicBaseUrl}${adressen[doc]}`) } assert.equal(termine.belehrung, widerrufsbelehrungFunktion()) assert.match(termine.belehrung, /Muster-Widerrufsformular/) // dieselben Werte wie die Register-Ansicht der Bestellseite: Fassung der Widerrufsbelehrung und Adresse assert.deepEqual(body.features.termine.widerruf, { version: V, url: adressen.widerruf }) assert.deepEqual(body.features.termine.avv, { version: V, url: adressen.avv }) assert.deepEqual(body.features.termine.leistungsbeschreibung, { version: V, url: adressen.lb }) assert.deepEqual(body.features.news.widerruf, null) }) test('liste_register_ohne_freigabe: features.termine steht auch bei Schalter off und Pilot ohne Teilnahme', async () => { const vorher = { mode: config.termine.mode, pilotEmails: config.termine.pilotEmails } try { for (const [mode, pilotEmails] of [['off', []], ['pilot', ['andere@example.test']]]) { config.termine.mode = mode config.termine.pilotEmails = pilotEmails const body = await get('/api/funktionen', { cookie }) assert.equal(body.termine.verfuegbar, false, mode) assert.equal(body.features.termine.priceLabel, '149 €', mode) assert.equal(body.features.termine.consent.items.length, config.features.termine.consent.items.length, mode) assert.equal(body.features.news.priceLabel, '30 €', mode) } // mit Freigabe dieselbe Ansicht config.termine.mode = 'pilot' config.termine.pilotEmails = ['*'] const frei = await get('/api/funktionen', { cookie }) assert.equal(frei.termine.verfuegbar, true) assert.equal(frei.features.termine.priceLabel, '149 €') } finally { config.termine.mode = vorher.mode config.termine.pilotEmails = vorher.pilotEmails } }) test('checkout_ohne_zustimmung_400', async () => { assert.equal((await checkout('p1', false)).status, 400) assert.equal((await post('/api/funktionen/news/checkout', { projectId: 'p1' }, { cookie })).status, 400) assert.equal(sessions().length, 0) }) test('checkout_fremdes_projekt_403', async () => { assert.equal((await checkout('p5')).status, 403) assert.equal(sessions().length, 0) }) test('checkout_legt_session_an', async () => { const res = await checkout('p1') assert.equal(res.status, 200) assert.match(res.body.url, /^https:\/\/checkout\.stripe\.test\//) const [session] = sessions() assert.equal(session.mode, 'payment') assert.equal(session.metadata.featureKey, 'news') assert.equal(session.metadata.projectId, 'p1') assert.equal(session.metadata.customerId, 'c1') const call = st.calls.find((c) => c.method === 'POST' && c.path === '/v1/checkout/sessions') assert.equal(call.body['invoice_creation[enabled]'], 'true') assert.equal(call.body.success_url, `${config.publicBaseUrl}/dashboard.html#news?bestellt=1`) assert.equal(call.body.cancel_url, `${config.publicBaseUrl}/dashboard.html#news`) assert.ok(Number(call.body.expires_at) > Date.now() / 1000) const price = [...st.prices.values()].find((p) => p.id === call.body['line_items[0][price]']) assert.equal(price.unit_amount, 3000) const doc = await sf.getFeatureDoc('c1', 'p1', 'news') assert.equal(doc.state, 'ordered') assert.equal(doc.stripeSessionId, session.id) assert.equal(doc.consentText, config.funktionen.consentText) assert.ok(doc.consentAt) assert.ok(doc.consentUserAgent) // zweite Bestellung waehrend die erste offen ist: dieselbe Zahlungsseite, keine neue Sitzung const again = await checkout('p1') assert.equal(again.status, 200) assert.equal(again.body.url, res.body.url) assert.equal(sessions().length, 1) }) test('abgelaufene_sitzung_darf_neu_gestartet_werden', async () => { sessions().at(-1).status = 'expired' const res = await checkout('p1') assert.equal(res.status, 200) assert.equal(sessions().length, 2) assert.equal((await sf.getFeatureDoc('c1', 'p1', 'news')).stripeSessionId, sessions().at(-1).id) }) test('abgeschlossene_sitzung_409', async () => { sessions().at(-1).status = 'complete' const before = sessions().length assert.equal((await checkout('p1')).status, 409) assert.equal(sessions().length, before) }) test('checkout_bei_setup_409', async () => { const sid = 'cs_setup' await sf.markOrdered({ customerId: 'c1', projectId: 'p2', key: 'news', sessionId: sid, consent: {} }) await sf.markPaid({ sessionId: sid }) const before = sessions().length assert.equal((await checkout('p2')).status, 409) assert.equal(sessions().length, before) }) test('sepa_wartet_auf_zahlung', async () => { const res = await checkout('p3') assert.equal(res.status, 200) const id = sessions().at(-1).id const mailsBefore = mails.sent.length const wosBefore = workorders().length const unpaid = paidSession(id, 'p3', { payment_status: 'unpaid' }) assert.equal((await postWebhook(event('checkout.session.completed', unpaid))).status, 200) assert.equal((await sf.getFeatureDoc('c1', 'p3', 'news')).state, 'ordered') assert.equal(workorders().length, wosBefore) assert.equal(mails.sent.length, mailsBefore) assert.equal((await postWebhook(event('checkout.session.async_payment_succeeded', paidSession(id, 'p3')))).status, 200) assert.equal((await sf.getFeatureDoc('c1', 'p3', 'news')).state, 'setup') assert.equal(workorders().length, wosBefore + 1) assert.deepEqual(subjects().slice(mailsBefore).sort(), ['Deine Bestellung: News-Pinnwand', 'Neue Funktion bestellt: News-Pinnwand']) const k15 = mails.sent.at(-2).subject.startsWith('Deine') ? mails.sent.at(-2) : mails.sent.at(-1) assert.equal(k15.to[0].address, 'k@example.invalid') assert.match(k15.text, /30,00 €/) }) test('webhook_idempotent', async () => { await checkout('p4') const id = sessions().at(-1).id const mailsBefore = mails.sent.length const wosBefore = workorders().length for (let i = 0; i < 2; i += 1) { assert.equal((await postWebhook(event('checkout.session.completed', paidSession(id, 'p4')))).status, 200) } assert.equal(workorders().length, wosBefore + 1) assert.equal(mails.sent.length - mailsBefore, 2) assert.equal(subjects().slice(mailsBefore).filter((s) => s.startsWith('Deine Bestellung')).length, 1) }) test('abgelaufen_zurueck_auf_off', async () => { aw.seed('websiteProjects', { $id: 'p6', customerId: 'c1', projectName: 'Werkstatt' }) await checkout('p6') const id = sessions().at(-1).id const expired = { id, object: 'checkout.session', mode: 'payment', metadata: { customerId: 'c1', projectId: 'p6', featureKey: 'news' } } assert.equal((await postWebhook(event('checkout.session.expired', expired))).status, 200) assert.equal((await sf.getFeatureDoc('c1', 'p6', 'news')).state, 'off') assert.equal((await checkout('p6')).status, 200) }) test('zahlung_fehlgeschlagen_mailt_team', async () => { aw.seed('websiteProjects', { $id: 'p7', customerId: 'c1', projectName: 'Garage' }) await checkout('p7') const id = sessions().at(-1).id const mailsBefore = mails.sent.length await postWebhook(event('checkout.session.async_payment_failed', paidSession(id, 'p7', { payment_status: 'unpaid' }))) assert.equal((await sf.getFeatureDoc('c1', 'p7', 'news')).state, 'off') assert.equal(mails.sent.length - mailsBefore, 1) assert.match(mails.sent.at(-1).text, /Zahlung fehlgeschlagen/) }) test('aktivierung_mailt', async () => { const doc = await sf.getFeatureDoc('c1', 'p3', 'news') assert.equal(doc.state, 'setup') aw.seed('workorders', { ...aw.docs('workorders').get(doc.ticketId), status: 'Closed' }) const mailsBefore = mails.sent.length const done = await sf.runActivation() assert.equal(done.length, 1) assert.equal((await sf.getFeatureDoc('c1', 'p3', 'news')).state, 'active') assert.equal(mails.sent.length - mailsBefore, 1) assert.equal(mails.sent.at(-1).subject, 'Deine News-Pinnwand ist bereit') assert.equal(mails.sent.at(-1).to[0].address, 'k@example.invalid') assert.deepEqual(await sf.runActivation(), []) assert.equal(mails.sent.length - mailsBefore, 1) }) test('parallele_checkouts_eine_sitzung', async () => { aw.seed('websiteProjects', { $id: 'p8', customerId: 'c1', projectName: 'Gärtnerei' }) const before = sessions().length const results = await Promise.all([checkout('p8'), checkout('p8')]) assert.deepEqual(results.map((r) => r.status), [200, 200]) assert.equal(results[0].body.url, results[1].body.url) assert.equal(sessions().length, before + 1) }) test('bezahlte_sitzung_mit_ueberschriebener_id_wird_zugeordnet', async () => { aw.seed('websiteProjects', { $id: 'p9', customerId: 'c1', projectName: 'Tischlerei' }) await checkout('p9') const paidId = sessions().at(-1).id const doc = await sf.getFeatureDoc('c1', 'p9', 'news') aw.seed('portalFeatures', { ...aw.docs('portalFeatures').get(doc.$id), stripeSessionId: 'cs_ueberschrieben' }) const wosBefore = workorders().length assert.equal((await postWebhook(event('checkout.session.completed', paidSession(paidId, 'p9')))).status, 200) const after = await sf.getFeatureDoc('c1', 'p9', 'news') assert.equal(after.state, 'setup') assert.equal(after.stripeSessionId, paidId) assert.equal(workorders().length, wosBefore + 1) }) test('parallele_aktivierung_mailt_einmal', async () => { aw.seed('websiteProjects', { $id: 'p10', customerId: 'c1', projectName: 'Schreinerei' }) await checkout('p10') const id = sessions().at(-1).id await postWebhook(event('checkout.session.completed', paidSession(id, 'p10'))) const doc = await sf.getFeatureDoc('c1', 'p10', 'news') aw.seed('workorders', { ...aw.docs('workorders').get(doc.ticketId), status: 'Closed' }) const mailsBefore = mails.sent.length const [a, b] = await Promise.all([sf.runActivation(), sf.runActivation({ customerId: 'c1' })]) assert.equal(a.length + b.length, 1) assert.equal(mails.sent.slice(mailsBefore).filter((m) => m.subject === 'Deine News-Pinnwand ist bereit').length, 1) }) // ------------------------------------------------- Ticketsystem (Team) --- // Mitarbeiter ohne Admin-Label ueber den Team-Eingang (JWT) aw.seed('employees', { $id: 'e_staff2', userId: 'u_staff2', displayName: 'Andrej', email: 'andrej@webklar.com' }) const STAFF = { headers: { Authorization: 'Bearer jwt-staff' } } const adminPost = (customerId, body, opts = STAFF) => post(`/api/team/funktionen/${customerId}/news`, body, opts) test('admin_aktivieren_ohne_zahlung', async () => { aw.seed('websiteProjects', { $id: 'pa1', customerId: 'c1', projectName: 'Admin-Test' }) const wosBefore = workorders().length const res = await adminPost('c1', { projectId: 'pa1', action: 'activate', note: 'Kulanz' }) assert.equal(res.status, 200) const view = res.body.projects.find((p) => p.projectId === 'pa1').features.news assert.equal(view.state, 'active') assert.equal(view.ticketWoid, null) assert.match(view.adminNote, /^\d{4}-\d\d-\d\dT[\d:.]+Z Andrej: activate – Kulanz$/) const doc = await sf.getFeatureDoc('c1', 'pa1', 'news') assert.equal(doc.enabled, true) assert.equal(doc.unlockedByPurchase, false) assert.equal(workorders().length, wosBefore) const list = await get('/api/team/funktionen/c1', STAFF) assert.equal(list.projects.find((p) => p.projectId === 'pa1').features.news.state, 'active') }) test('admin_abschalten', async () => { const doc = await sf.getFeatureDoc('c1', 'pa1', 'news') const publicUrl = `/api/public/news/${doc.siteKey}` assert.equal((await rawGet(publicUrl)).status, 200) const res = await adminPost('c1', { projectId: 'pa1', action: 'disable' }) assert.equal(res.status, 200) const view = res.body.projects[0].features.news assert.equal(view.state, 'disabled') assert.match(view.adminNote, /Andrej: disable \| .*Andrej: activate – Kulanz$/) assert.ok(view.adminNote.length <= 300) assert.equal((await sf.getFeatureDoc('c1', 'pa1', 'news')).enabled, false) const { status, body } = await rawGet(publicUrl) assert.ok(status === 404 || (status === 200 && (body.posts || []).length === 0), `öffentlich: ${status}`) }) test('admin_ungueltig_und_fremd', async () => { aw.seed('websiteProjects', { $id: 'pa4', customerId: 'c1', projectName: 'Ohne Dokument' }) assert.equal((await adminPost('c1', { projectId: 'pa1', action: 'loeschen' })).status, 400) assert.equal((await adminPost('c1', { projectId: 'p5', action: 'activate' })).status, 403) assert.equal((await adminPost('c1', { projectId: 'gibtsnicht', action: 'activate' })).status, 403) assert.equal((await adminPost('c1', { projectId: 'pa4', action: 'disable' })).status, 409) }) test('admin_notiz_bleibt_im_limit', async () => { aw.seed('websiteProjects', { $id: 'pa2', customerId: 'c1', projectName: 'Notiz' }) for (let i = 0; i < 8; i += 1) { await adminPost('c1', { projectId: 'pa2', action: i % 2 ? 'disable' : 'activate', note: 'x'.repeat(100) }) } const note = (await sf.getFeatureDoc('c1', 'pa2', 'news')).adminNote assert.ok(note.length <= 300) assert.match(note, /^\S+ Andrej: disable – x+/) }) test('kunde_darf_admin_route_nicht', async () => { aw.seed('websiteProjects', { $id: 'pa5', customerId: 'c1', projectName: 'Kundenversuch' }) const res = await adminPost('c1', { projectId: 'pa5', action: 'activate' }, { cookie: customerCookie('c1') }) assert.ok(res.status === 401 || res.status === 403) assert.equal(await sf.getFeatureDoc('c1', 'pa5', 'news'), null) const list = await rawGet('/api/team/funktionen/c1', { cookie: customerCookie('c1') }) assert.ok(list.status === 401 || list.status === 403) }) test('ticket_fehlt_markiert', async () => { aw.seed('websiteProjects', { $id: 'pa3', customerId: 'c1', projectName: 'Ticketlos' }) await checkout('pa3') await postWebhook(event('checkout.session.completed', paidSession(sessions().at(-1).id, 'pa3'))) const doc = await sf.getFeatureDoc('c1', 'pa3', 'news') assert.equal(doc.state, 'setup') const newsOf = async () => (await get('/api/team/funktionen/c1', STAFF)).projects.find((p) => p.projectId === 'pa3').features.news assert.equal((await newsOf()).ticketMissing, false) await deleteDocument(config.collections.workorders, doc.ticketId) const missing = await newsOf() assert.equal(missing.ticketMissing, true) assert.equal(missing.ticketWoid, null) }) // ------------------------------------------------ Schlussdurchgang --- test('webhook_retry_setzt_ticketlose_einrichtung_fort', async () => { aw.seed('websiteProjects', { $id: 'pr1', customerId: 'c1', projectName: 'Retry' }) await checkout('pr1') const id = sessions().at(-1).id const wosBefore = workorders().length const mailsBefore = mails.sent.length aw.failNext({ collection: 'workorders', method: 'POST', status: 400 }) const first = await postWebhook(event('checkout.session.completed', paidSession(id, 'pr1'))) assert.equal(first.status, 500) const stuck = await sf.getFeatureDoc('c1', 'pr1', 'news') assert.equal(stuck.state, 'setup') assert.ok(!stuck.ticketId) assert.equal(mails.sent.length, mailsBefore) assert.equal((await postWebhook(event('checkout.session.completed', paidSession(id, 'pr1')))).status, 200) const fixed = await sf.getFeatureDoc('c1', 'pr1', 'news') assert.ok(fixed.ticketId) assert.equal(workorders().length, wosBefore + 1) assert.deepEqual(subjects().slice(mailsBefore).sort(), ['Deine Bestellung: News-Pinnwand', 'Neue Funktion bestellt: News-Pinnwand']) // weiterer Retry: nichts mehr await postWebhook(event('checkout.session.completed', paidSession(id, 'pr1'))) assert.equal(workorders().length, wosBefore + 1) assert.equal(mails.sent.length - mailsBefore, 2) }) test('rechnungslink_wird_nachgetragen', async () => { aw.seed('websiteProjects', { $id: 'pi1', customerId: 'c1', projectName: 'Rechnung' }) await checkout('pi1') const session = sessions().at(-1) await postWebhook(event('checkout.session.completed', paidSession(session.id, 'pi1'))) const listed = async () => (await get('/api/funktionen', { cookie })).projects.find((p) => p.projectId === 'pi1').features.news assert.equal((await listed()).invoiceUrl, null) st.invoices.set('in_late', { id: 'in_late', object: 'invoice', hosted_invoice_url: 'https://invoice.stripe.test/late' }) session.invoice = 'in_late' assert.equal((await listed()).invoiceUrl, 'https://invoice.stripe.test/late') const doc = await sf.getFeatureDoc('c1', 'pi1', 'news') assert.equal(doc.stripeInvoiceId, 'in_late') // Stripe-Fehler lassen die Liste nicht scheitern aw.seed('portalFeatures', { ...aw.docs('portalFeatures').get(doc.$id), invoiceUrl: '' }) st.failNext({ method: 'GET', path: /checkout\/sessions/, status: 400 }) assert.equal((await get('/api/funktionen', { cookie })).projects.length > 0, true) }) test('bezahlt_ohne_passende_bestellung_meldet_dem_team', async () => { aw.seed('websiteProjects', { $id: 'pu1', customerId: 'c1', projectName: 'Unklar' }) const mailsBefore = mails.sent.length const res = await postWebhook(event('checkout.session.completed', paidSession('cs_verwaist', 'pu1'))) assert.equal(res.status, 200) const notice = mails.sent.slice(mailsBefore) assert.equal(notice.length, 1) assert.equal(notice[0].subject, 'Neue Funktion bestellt: News-Pinnwand') assert.match(notice[0].text, /cs_verwaist/) assert.match(notice[0].text, /nicht zuzuordnen/) assert.doesNotMatch(notice[0].text, /Zahlung fehlgeschlagen/) assert.equal(workorders().filter((t) => t.topic.includes('Unklar')).length, 0) }) test('abgeschaltete_funktion_nicht_buchbar', async () => { aw.seed('websiteProjects', { $id: 'pd1', customerId: 'c1', projectName: 'Abgeschaltet' }) const doc = await sf.adminActivate('c1', 'pd1', 'news', { by: 'test' }) await sf.disable(doc.$id, { by: 'test' }) const before = sessions().length const res = await checkout('pd1') assert.equal(res.status, 409) assert.equal(res.body.error, 'Die News-Pinnwand wurde für diese Website abgeschaltet. Schreib uns im Chat, wenn du sie wieder möchtest.') assert.equal(sessions().length, before) }) test('statische_quellen: news_label, minmax_grid, ticket_anleitung', () => { const read = (p) => readFileSync(new URL(`../${p}`, import.meta.url), 'utf8') assert.match(read('public/app.js'), /FEATURE_LABELS = \{[^}]*news: 'News-Pinnwand'/) assert.doesNotMatch(read('public/app.js'), /FEATURE_LABELS\[f\.featureKey\] \|\| f\.featureKey/) const embed = read('public/embed/news.js') assert.ok(embed.includes('grid-template-columns:minmax(0,1.25fr) minmax(0,1fr)')) assert.ok(embed.includes('.wkn-combo-in{grid-template-columns:minmax(0,1fr)}')) assert.ok(!read('public/dashboard.html').includes('id="view-news" aria-live')) }) test('ticket_details_nennen_die_anleitung', () => { const doc = [...aw.docs('workorders').values()].find((t) => String(t.topic).startsWith('News-Pinnwand einbauen')) assert.ok(doc.details.includes('Anleitung: docs/funktionen/news-einbau.md (Repo Webklar-Kundenbereich)')) }) // Live-Fehler 30.09.2026: Appwrite lehnte die (zu lange) Sitzungs-ID ab, der Kunde sah die rohe Meldung test('checkout_datenbankfehler_ohne_rohe_meldung', async () => { aw.seed('websiteProjects', { $id: 'p-db', customerId: 'c1', projectName: 'Eisdiele' }) aw.failNext({ collection: 'portalFeatures', method: 'PATCH', status: 400, message: 'Invalid document structure: Attribute "stripeSessionId" has invalid type.' }) const res = await checkout('p-db') assert.equal(res.status, 500, JSON.stringify(res.body)) assert.doesNotMatch(res.body.error, /Invalid document structure|stripeSessionId/) assert.match(res.body.error, /Bestellung konnte gerade nicht gestartet werden/) }) // Task B7: auch die News-Bestellung schreibt vor der Weiterleitung einen Zustimmungsnachweis (Texte und Fassungen aus dem // Register config.features.news statt Literale: Block F2 hebt die Fassung ohne Testbruch an) test('news_schreibt_auch_einen_nachweis', async () => { aw.seed('websiteProjects', { $id: 'pn1', customerId: 'c1', projectName: 'Nachweis-Laden' }) const news = config.features.news const vor = new Date().toISOString() const res = await checkout('pn1') const nach = new Date().toISOString() assert.equal(res.status, 200) const session = sessions().at(-1) const nachweise = [...aw.docs('featureConsents').values()].filter((d) => d.projectId === 'pn1') assert.equal(nachweise.length, 1) const [n] = nachweise assert.equal(n.stripeSessionId, session.id) assert.equal(n.featureKey, 'news') assert.deepEqual(JSON.parse(n.texte), [ ...news.consent.items.map((item) => ({ id: item.id, text: item.text, haken: Boolean(item.haken) })), { id: 'knopf', text: news.consent.knopf, haken: false }, ]) assert.deepEqual(JSON.parse(n.fassungen), { zustimmung: news.consent.version, leistungsbeschreibung: news.leistungsbeschreibung.version }) assert.ok(n.ip && n.userAgent) assert.equal(n.konto, 'Kunde Eins ') // ein Zeitpunkt: Nachweis, Stripe-Metadaten und Dokument; das Dokument behaelt den Wortlaut wie bisher const doc = await sf.getFeatureDoc('c1', 'pn1', 'news') assert.equal(doc.consentText, config.funktionen.consentText) assert.equal(n.acceptedAt, doc.consentAt) assert.equal(n.acceptedAt, session.metadata.consentAt) assert.ok(vor <= n.acceptedAt && n.acceptedAt <= nach) // Widerrufs-Anker: nach der Zahlung zaehlt die Zeit des Klicks aus dem Nachweis, nicht die Zeit des Dokuments aw.seed('portalFeatures', { ...aw.docs('portalFeatures').get(doc.$id), orderedAt: '2026-01-01T00:00:00.000Z', consentAt: '2026-01-02T00:00:00.000Z' }) assert.equal((await postWebhook(event('checkout.session.completed', paidSession(session.id, 'pn1')))).status, 200) const order = (await sf.paidFeatureOrders('c1')).find((o) => o.projekt === 'Nachweis-Laden') assert.equal(order.featureKey, 'news') assert.equal(order.vertragAt, n.acceptedAt) })