Files
Webklar-Kundenbereich/test/funktionenRoutes.test.js
T
ClaudeandClaude Opus 5.5 66793d06d5 Öffnungszeiten: Einschalten im Wartungs-Abo
POST /api/funktionen/zeiten/einschalten (Gratisweg, Spec 3.2): Abo frisch
prüfen, Zustimmung (consentFrei) und Nachweis ohne Stripe-Sitzung, Zustand
setup mit unlockedBy abo, dann Neubau, Einbau-Ticket und K24; ein zweiter
Klick holt Fehlendes nach (holeZeitenNach, eigene Sperre). GET /api/funktionen
liefert das Angebot (zeiten), features.zeiten mit unlockedBy, aboEndeAt, einbau,
loeschungAm sowie zeitenStatus und zeitenLeer je Projekt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 02:03:49 +00:00

513 lines
27 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)
})
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)
})