Files
Webklar-Kundenbereich/test/funktionenRoutes.test.js
T
ClaudeandClaude Sonnet 5.5 019833d385 Öffnungszeiten: Tests und Belehrung nach dem Merge mit TP2
Zeiten nutzen die echte Widerrufsbelehrung wie der Terminplaner (der Platzhalter BELEHRUNG_PLATZHALTER ist
mit TP2 F7 entfallen). Gepinnte Listen als Vereinigung beider Seiten: K15-Blöcke, T16-Blöcke, legal-Schlüssel der
Projektliste, hindernis in der Team-Ansicht für alle Funktionen. Neue Tests: Abnahme (Freischalten) der Zeiten über den
Haken freischaltbar und mit Ersatz-Hash, abnahme gibt für Zeiten nie ab.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 04:43:20 +00:00

542 lines
29 KiB
JavaScript
Raw 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 { 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', 'zeiten'])
assert.equal(body.legal.news, null)
// die Oeffnungszeiten haben keine Rechtsdokumente zum Anhaengen (avv und widerruf null): kein Eintrag wie beim Terminplaner
assert.equal(body.legal.zeiten, 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 <k@example.invalid>')
// 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)
})