Zusammenführung von TP2-Abschluss und Webshop (origin/main cab9c33) mit Öffnungszeiten live. Beide
Funktionen bleiben vollständig: jeder Konflikt ist als Vereinigung gelöst, Reihenfolge der Funktionsschlüssel
überall news, termine, shop, zeiten.
Konfliktdateien (28): public/app.js, dashboard.html, funktionen.js, widerrufen.html; scripts/stripe-products.mjs;
server/config.js, index.js, middleware/kundenansichtGuard.js, routes/abo.js, routes/admin/funktionenAdmin.js,
routes/funktionen.js, services/customerAdmin.js, customerViewLog.js, featureRegistry.js, retention.js,
siteFeatures.js, stripeBilling.js; test/customerViewLog, featureFreischaltung, featureRegistry, funktionenRoutes,
funktionenTermine, kundenansichtGuard, mailTemplates, publicForms, siteFeatures, stripeProductsScript (je
.test.js) und test/support/schema.js.
Entscheidungen: Mailvorlagen G01–G15, K01–K30, T01–T21 lückenlos (K24–K26 Webshop, K27–K30 Öffnungszeiten);
Stripe-Katalog mit shopSetup und zeitenSetup; Widerruf kennt shop und zeiten; ABO_FUNKTIONEN bleibt analyse
und termine (kein zeiten); activate() schreibt die Zeiten-Felder nur über extra; kein Halter von
feature-activate:<id> ruft disable() auf; die Aufbewahrungs-Löschung löscht Zeiten-Daten vor dem Abschalten und
hält den Kunden bei einem Fehler (held); die Admin-Antwort trägt ersatzTicket und warnung; Hash-Routen webshop
und zeiten, Ansichten view-webshop und view-zeiten; assertOwnProject behält die Meldung von main.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
560 lines
30 KiB
JavaScript
560 lines
30 KiB
JavaScript
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 })
|
||
// Webshop: Schalter SHOP_ENABLED (Vorgabe off) ⇒ nur das Flag, keine weiteren Felder
|
||
assert.deepEqual(body.shop, { 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', 'shop', 'termine', 'zeiten'])
|
||
assert.equal(body.features.shop.priceLabel, '299 €')
|
||
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', 'shop']) {
|
||
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', 'shop', '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)
|
||
|
||
// Webshop: eigene Fassung (config.shop.fassung), eigene Slugs, dieselbe Belehrung der Funktionen
|
||
const S = config.shop.fassung
|
||
const shop = body.legal.shop
|
||
assert.deepEqual(Object.keys(shop).sort(), ['belehrung', 'links', 'version'])
|
||
assert.equal(shop.version, S)
|
||
assert.deepEqual(Object.keys(shop.links), ['lb', 'avv', 'widerruf'])
|
||
const shopAdressen = legalPaths('shop', S)
|
||
for (const [doc, link] of Object.entries(shop.links)) assert.equal(link, `${config.publicBaseUrl}${shopAdressen[doc]}`)
|
||
assert.ok(shop.links.lb.includes('leistungsbeschreibung-webshop-'))
|
||
assert.ok(shop.links.avv.includes('avv-webshop-'))
|
||
assert.ok(shop.links.widerruf.includes('widerrufsbelehrung-funktionen-'))
|
||
assert.equal(shop.belehrung, widerrufsbelehrungFunktion())
|
||
assert.deepEqual(body.features.shop.avv, { version: S, url: shopAdressen.avv })
|
||
})
|
||
|
||
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.equal(doc.consentUserAgent, '', 'C1: der Browser steht nur im Nachweis, nicht am Funktionsdokument')
|
||
assert.equal(doc.consentIp, '')
|
||
// 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)
|
||
})
|