Files
Webklar-Kundenbereich/test/funktionenTermine.test.js

1692 lines
99 KiB
JavaScript
Raw Permalink 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 { 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'
// Terminplaner als zweite Funktion (Aufgabe 17): Team-Route /:customerId/:key, GET /api/funktionen mit Schalter,
// Kunde loeschen schaltet den Terminplaner ab und entfernt seine Entwuerfe (C6). News verhaelt sich wie bisher (test/funktionenRoutes.test.js).
const aw = await startAppwriteMock({
schema: { portalFeatures: schemaKeys('portalFeatures'), subscriptions: schemaKeys('subscriptions'), termineConfig: schemaKeys('termineConfig'), termineBookings: schemaKeys('termineBookings'), 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 } = await import('./support/testApp.js')
const { customerCookie, seedFixtures, subscriptionDoc, wartungDoc, hostingDoc } = await import('./support/fixtures.js')
const { default: funktionenRoutes } = await import('../server/routes/funktionen.js')
const { default: teamRoutes } = await import('../server/routes/team/index.js')
const sf = await import('../server/services/siteFeatures.js')
const { loeschTag } = await import('../server/services/termine/frist.js')
const { deleteCustomerWithPortalAccess } = await import('../server/services/customerAdmin.js')
const { aboMeldung } = await import('../server/services/featureOrder.js')
const { setMailTransportForTests } = await import('../server/services/mailer.js')
const { mailSink } = await import('./support/mailSink.js')
const { ausVorlage } = await import('../server/services/termine/vorlagen.js')
const { neuerFeedToken } = await import('../server/services/termine/einstellungen.js')
const { updateDocument } = await import('../server/services/appwriteAdmin.js')
const app = await startTestApp({ '/api/funktionen': funktionenRoutes, '/api/team': teamRoutes })
// Mails (K16 der Abnahme, K15/T16 des nachgelegten Tickets) landen in einer Senke
const mails = mailSink()
setMailTransportForTests(mails)
after(() => {
setMailTransportForTests(null)
return Promise.all([app.close(), aw.close(), st.close()])
})
seedFixtures(aw)
aw.seed('employees', { $id: 'e_staff2', userId: 'u_staff2', displayName: 'Andrej', email: 'andrej@webklar.com' })
aw.seed('websiteProjects', { $id: 'pt1', customerId: 'c1', projectName: 'Salon' })
aw.seed('websiteProjects', { $id: 'pt2', customerId: 'c1', projectName: 'Studio' })
aw.seed('websiteProjects', { $id: 'pt3', customerId: 'c2', projectName: 'Fremder Salon' })
const STAFF = { headers: { Authorization: 'Bearer jwt-staff' } }
const cookie = customerCookie('c1')
const team = (customerId, key, body, opts = STAFF) => post(`/api/team/funktionen/${customerId}/${key}`, body, opts)
const featuresOf = (res, projectId) => res.body.projects.find((p) => p.projectId === projectId).features
const workorders = () => aw.docs('workorders').size
const docOf = (projectId, key) => sf.getFeatureDoc('c1', projectId, key)
test('Team aktiviert termine fuer ein Projekt, News bleibt unveraendert', async () => {
const tickets = workorders()
const res = await team('c1', 'termine', { projectId: 'pt1', action: 'activate', note: 'Pilot' })
assert.equal(res.status, 200)
assert.deepEqual(res.body.projects.map((p) => p.projectId), ['pt1'])
const view = featuresOf(res, 'pt1')
assert.equal(view.termine.state, 'active')
assert.equal(view.termine.ticketWoid, null)
assert.match(view.termine.siteKey, /^[a-z2-7]{16}$/)
assert.match(view.termine.adminNote, /^\d{4}-\d\d-\d\dT[\d:.]+Z Andrej: activate – Pilot$/)
assert.equal(view.termine.ticketMissing, false)
assert.equal(view.news.state, 'off')
assert.equal(view.news.adminNote, '')
const doc = await docOf('pt1', 'termine')
assert.deepEqual([doc.featureKey, doc.state, doc.enabled, doc.unlockedByPurchase], ['termine', 'active', true, false])
assert.equal(await docOf('pt1', 'news'), null, 'kein News-Dokument entstanden')
assert.equal(workorders(), tickets, 'kein Ticket')
// das Projekt ohne Aktion bleibt aus
const list = await get('/api/team/funktionen/c1', STAFF)
assert.equal(list.projects.find((p) => p.projectId === 'pt2').features.termine.state, 'off')
})
test('/news bleibt gleichwertig und aendert den Terminplaner nicht', async () => {
const res = await team('c1', 'news', { projectId: 'pt1', action: 'activate', note: 'Kulanz' })
assert.equal(res.status, 200)
const view = featuresOf(res, 'pt1')
assert.equal(view.news.state, 'active')
assert.match(view.news.adminNote, /Andrej: activate – Kulanz$/)
assert.equal(view.termine.state, 'active')
assert.doesNotMatch(view.termine.adminNote, /Kulanz/)
assert.notEqual((await docOf('pt1', 'news')).siteKey, (await docOf('pt1', 'termine')).siteKey)
assert.equal((await team('c1', 'news', { projectId: 'pt1', action: 'disable' })).status, 200)
const after = await docOf('pt1', 'termine')
assert.equal(after.state, 'active')
})
test('Abschalten setzt disabledAt und den Grund team, erneutes Aktivieren leert beides (R36)', async () => {
const off = await team('c1', 'termine', { projectId: 'pt1', action: 'disable' })
assert.equal(off.status, 200)
assert.equal(featuresOf(off, 'pt1').termine.state, 'disabled')
const doc = await docOf('pt1', 'termine')
assert.equal(doc.enabled, false)
assert.ok(Math.abs(Date.parse(doc.disabledAt) - Date.now()) < 5000)
assert.equal(doc.disabledReason, 'team', 'das WOMS schaltet mit dem Vorgabegrund ab')
// zweiter Klick (Doppelklick im WOMS): Beginn der 30 Tage, Grund und Notiz bleiben
const nochmal = await team('c1', 'termine', { projectId: 'pt1', action: 'disable', note: 'zweiter Klick' })
assert.equal(nochmal.status, 200)
const gleich = await docOf('pt1', 'termine')
assert.deepEqual([gleich.disabledAt, gleich.disabledReason, gleich.adminNote], [doc.disabledAt, 'team', doc.adminNote])
const on = await team('c1', 'termine', { projectId: 'pt1', action: 'activate' })
assert.equal(featuresOf(on, 'pt1').termine.state, 'active')
const aktiv = await docOf('pt1', 'termine')
assert.equal(aktiv.disabledAt, null)
assert.equal(aktiv.disabledReason, null)
})
test('unbekannter Schluessel -> 404, auch /kalender; nichts wird angelegt', async () => {
const docs = aw.docs('portalFeatures').size
for (const key of ['kalender', 'analyse', 'Termine', 'termine2']) {
const res = await team('c1', key, { projectId: 'pt2', action: 'activate' })
assert.equal(res.status, 404, key)
assert.equal(typeof res.body.error, 'string', key)
}
// der Schluessel wird vor dem Rumpf geprueft
assert.equal((await team('c1', 'kalender', {})).status, 404)
assert.equal(aw.docs('portalFeatures').size, docs)
assert.equal(await docOf('pt2', 'termine'), null)
})
test('Meldungen sind je Funktion grammatisch', async () => {
aw.seed('websiteProjects', { $id: 'pt4', customerId: 'c1', projectName: 'Ohne Dokument' })
const termine = await team('c1', 'termine', { projectId: 'pt4', action: 'disable' })
assert.equal(termine.status, 409)
assert.equal(termine.body.error, 'Für dieses Projekt gibt es noch keinen Terminplaner')
const news = await team('c1', 'news', { projectId: 'pt4', action: 'disable' })
assert.equal(news.status, 409)
assert.equal(news.body.error, 'Für dieses Projekt gibt es noch keine News-Pinnwand')
assert.equal(await docOf('pt4', 'termine'), null)
})
test('Pruefungen wie bei News: Aktion, Projekt, fremdes Projekt, Kunde', async () => {
assert.equal((await team('c1', 'termine', { projectId: 'pt1', action: 'loeschen' })).status, 400)
assert.equal((await team('c1', 'termine', { action: 'activate' })).status, 400)
assert.equal((await team('c1', 'termine', { projectId: 'pt3', action: 'activate' })).status, 403)
assert.equal((await team('c1', 'termine', { projectId: 'gibtsnicht', action: 'activate' })).status, 403)
assert.equal(await sf.getFeatureDoc('c2', 'pt3', 'termine'), null)
const asCustomer = await team('c1', 'termine', { projectId: 'pt2', action: 'activate' }, { cookie })
assert.ok(asCustomer.status === 401 || asCustomer.status === 403)
assert.equal(await docOf('pt2', 'termine'), null)
})
test('Die Ansicht des Teams liest die Funktionsdokumente des Kunden einmal', async (t) => {
const echt = globalThis.fetch
const abfragen = []
globalThis.fetch = async (url, optionen) => {
if (/\/collections\/portalFeatures\/documents/.test(String(url))) abfragen.push(`${optionen?.method ?? 'GET'} ${String(url).replace(/\?.*/, '')}`)
return echt(url, optionen)
}
t.after(() => (globalThis.fetch = echt))
const list = await get('/api/team/funktionen/c1', STAFF)
assert.ok(list.projects.length >= 3)
assert.equal(abfragen.length, 1, abfragen.join(' | '))
assert.match(abfragen[0], /^GET .*\/collections\/portalFeatures\/documents$/)
})
// ------------------------------------------------------------- Kundenliste ---
const MODUS = { mode: config.termine.mode, pilotEmails: [...config.termine.pilotEmails] }
const termineMit = (mode, pilotEmails = []) => {
config.termine.mode = mode
config.termine.pilotEmails = pilotEmails
}
after(() => termineMit(MODUS.mode, MODUS.pilotEmails))
const termineAntwort = async (c = cookie) => (await get('/api/funktionen', { cookie: c })).termine
const verfuegbar = async (c = cookie) => (await termineAntwort(c)).verfuegbar
test('GET /api/funktionen: termine.verfuegbar je Modus und Pilot-E-Mail; false bleibt exakt { verfuegbar: false }', async () => {
termineMit('off')
assert.deepEqual(await termineAntwort(), { verfuegbar: false })
termineMit('pilot', ['k@example.invalid'])
assert.equal(await verfuegbar(), true)
assert.deepEqual(await termineAntwort(customerCookie('c2')), { verfuegbar: false }, 'Kunde nicht in der Pilotliste')
termineMit('pilot', [])
assert.deepEqual(await termineAntwort(), { verfuegbar: false })
termineMit('pilot', ['*'])
assert.equal(await verfuegbar(customerCookie('c2')), true)
termineMit('on')
assert.equal(await verfuegbar(customerCookie('c2')), true)
})
// ---- Abo-Felder der Kachel (C10a): bestellbar, grund, preis, einrichtung, hinweis nur bei verfuegbar: true
// hinweis (R111) = aboMeldung des Registers: der Browser zeigt den Text nur an und hat keine Kopie; bei bestellbar true null
const wartung = (patch = {}) => aw.seed('subscriptions', subscriptionDoc('sub_h', { ...wartungDoc, ...patch }))
const angebot = (bestellbar, grund) => ({
verfuegbar: true,
bestellbar,
grund,
preis: config.features.termine.priceLabel,
einrichtung: config.features.termine.setupEstimate,
hinweis: bestellbar === true ? null : aboMeldung(config.features.termine, grund ?? 'unbekannt'),
})
test('GET /api/funktionen: Kunde mit Wartungs-Abo => bestellbar true, grund null, Preis und Einrichtung aus dem Register', async (t) => {
t.after(() => aw.seed('subscriptions', hostingDoc))
termineMit('pilot', ['*'])
wartung()
const termine = await termineAntwort()
assert.deepEqual(termine, angebot(true, null))
assert.ok(termine.preis && termine.einrichtung, 'das Register liefert beide Werte')
assert.deepEqual(Object.keys(termine), ['verfuegbar', 'bestellbar', 'grund', 'preis', 'einrichtung', 'hinweis'])
assert.equal(termine.hinweis, null, 'bestellbar: kein Hinweis (das Feld steht da, ist aber null)')
})
test('GET /api/funktionen: hinweis ist der Text von aboMeldung mit dem Register des Terminplaners, je Grund', async (t) => {
t.after(() => aw.seed('subscriptions', hostingDoc))
termineMit('pilot', ['*'])
const faelle = [
['kein_abo', () => {}, 'Für den Terminplaner brauchst du ein laufendes Wartungs-Abo.'],
['gekuendigt', () => wartung({ cancelAtPeriodEnd: true }), 'Dein Wartungs-Abo ist gekündigt. Für den Terminplaner brauchst du ein laufendes Abo.'],
[
'nicht_aktiv',
() => wartung({ status: 'past_due' }),
'Dein Wartungs-Abo ist gerade nicht aktiv (zum Beispiel wegen einer offenen Zahlung). Sobald es wieder aktiv ist, kannst du den Terminplaner bestellen.',
],
]
for (const [grund, richteEin, text] of faelle) {
aw.seed('subscriptions', hostingDoc)
richteEin()
const termine = await termineAntwort()
assert.equal(termine.bestellbar, false, grund)
assert.equal(termine.grund, grund)
assert.equal(termine.hinweis, text, grund)
assert.equal(termine.hinweis, aboMeldung(config.features.termine, grund), grund)
}
// ein Lesefehler: bestellbar null, grund null => Text zu 'unbekannt'
aw.seed('subscriptions', hostingDoc)
wartung()
t.mock.method(console, 'error', () => {})
aw.failNext({ collection: 'subscriptions', status: 500, message: 'x' })
const unbekannt = await termineAntwort()
assert.deepEqual([unbekannt.bestellbar, unbekannt.grund, unbekannt.hinweis], [null, null, 'Das Abo konnte gerade nicht geprüft werden.'])
})
test('GET /api/funktionen: ohne Wartungs-Abo (nur Hosting, gar kein Abo, beendet) => bestellbar false, grund kein_abo', async (t) => {
t.after(() => aw.seed('subscriptions', hostingDoc))
termineMit('pilot', ['*'])
assert.deepEqual(await termineAntwort(), angebot(false, 'kein_abo'), 'c1 hat seit seedFixtures nur Hosting')
assert.deepEqual(await termineAntwort(customerCookie('c3')), angebot(false, 'kein_abo'), 'c3 hat gar kein Abo')
wartung({ status: 'canceled', endedAt: '2026-09-01T00:00:00.000Z' })
assert.deepEqual(await termineAntwort(), angebot(false, 'kein_abo'), 'beendetes Wartungs-Abo')
})
test('GET /api/funktionen: Kuendigung vorgemerkt => grund gekuendigt; past_due und trialing => nicht_aktiv', async (t) => {
t.after(() => aw.seed('subscriptions', hostingDoc))
termineMit('pilot', ['*'])
wartung({ cancelAtPeriodEnd: true })
assert.deepEqual(await termineAntwort(), angebot(false, 'gekuendigt'))
wartung({ cancelAt: '2026-11-30T00:00:00.000Z' })
assert.deepEqual(await termineAntwort(), angebot(false, 'gekuendigt'))
wartung({ status: 'past_due' })
assert.deepEqual(await termineAntwort(), angebot(false, 'nicht_aktiv'))
wartung({ status: 'trialing' })
assert.deepEqual(await termineAntwort(), angebot(false, 'nicht_aktiv'))
})
test('GET /api/funktionen: Lesefehler im Abo => bestellbar null, grund null, die Projekte kommen trotzdem; nichts Technisches nach aussen', async (t) => {
t.after(() => aw.seed('subscriptions', hostingDoc))
const fehler = t.mock.method(console, 'error', () => {})
termineMit('pilot', ['*'])
wartung()
// eigenes Projekt mit aktivem Terminplaner: der Test haengt nicht von den Zustaenden aus frueheren Tests ab
aw.seed('websiteProjects', { $id: 'pt_lesefehler', customerId: 'c1', projectName: 'Lesefehler Salon' })
aw.seed('portalFeatures', { $id: 'f_pt_lesefehler_termine', customerId: 'c1', projectId: 'pt_lesefehler', featureKey: 'termine', siteKey: 'abcdefghijklmnop', state: 'active', enabled: true })
aw.failNext({ collection: 'subscriptions', status: 500, message: 'geheimer Appwrite-Text' })
const res = await get('/api/funktionen', { cookie })
assert.deepEqual(res.termine, angebot(null, null))
assert.equal(res.termine.hinweis, 'Das Abo konnte gerade nicht geprüft werden.')
assert.ok(res.projects.some((p) => p.projectId === 'pt_lesefehler'), 'das Projekt ist in der Liste')
assert.equal(featuresOf({ body: res }, 'pt_lesefehler').termine.state, 'active', 'die Zustaende der Projekte bleiben')
assert.ok(!JSON.stringify(res).includes('geheimer'))
assert.ok(fehler.mock.calls.every((aufruf) => !String(aufruf.arguments[0]).includes('geheimer')), 'auch nicht im Log')
assert.ok(fehler.mock.calls.length >= 1, 'der Fehler steht im Log')
// der naechste Aufruf liest wieder, nichts wird gemerkt
assert.deepEqual(await termineAntwort(), angebot(true, null))
})
test('GET /api/funktionen: ist der Terminplaner nicht verfuegbar, wird das Abo nicht gelesen', async (t) => {
const echt = globalThis.fetch
const abfragen = []
globalThis.fetch = async (url, optionen) => {
if (/\/collections\/subscriptions\/documents/.test(String(url))) abfragen.push(String(url))
return echt(url, optionen)
}
t.after(() => (globalThis.fetch = echt))
termineMit('off')
assert.deepEqual(await termineAntwort(), { verfuegbar: false })
assert.equal(abfragen.length, 0, abfragen.join(' | '))
})
test('GET /api/funktionen: Projekte tragen features.termine und News wie bisher', async () => {
termineMit('pilot', ['*'])
const body = await get('/api/funktionen', { cookie })
assert.equal(body.setupEstimate, 'meist innerhalb von 1–2 Wochen')
const pt1 = body.projects.find((p) => p.projectId === 'pt1')
assert.equal(pt1.features.termine.state, 'active')
assert.equal(pt1.features.news.state, 'disabled')
const pt2 = body.projects.find((p) => p.projectId === 'pt2')
assert.deepEqual([pt2.features.termine.state, pt2.features.news.state], ['off', 'off'])
assert.deepEqual(Object.keys(pt2.features), ['news', 'termine', 'shop', 'unterkunft', 'zeiten'])
// Abschaltgrund und Loeschfrist (E2, R105): News ist hier vom Team abgeschaltet: Grund und Zeitpunkt ja, ein Loeschdatum nein
// (der 30-Tage-Loeschlauf loescht nur Terminplaner-Daten); der aktive Terminplaner und das leere Projekt haben nichts
const aus = pt1.features.news
assert.equal(aus.disabledReason, 'team')
assert.match(aus.disabledAt, /^\d{4}-\d\d-\d\dT[\d:.]+Z$/)
assert.equal(aus.loeschungAm, null)
for (const view of [pt1.features.termine, pt2.features.news, pt2.features.termine]) {
assert.deepEqual([view.disabledReason, view.disabledAt, view.loeschungAm], [null, null, null])
}
// ohne Sitzung wie bisher gesperrt
assert.equal((await rawGet('/api/funktionen')).status, 401)
})
test('GET /api/funktionen: abgeschalteter Terminplaner nennt Grund, Zeitpunkt und Berliner Loeschtag; Altbestand ohne Grund', async () => {
const projekte = [
['pe2_aboende', { state: 'disabled', enabled: false, disabledReason: 'abo_ende', disabledAt: '2026-10-04T23:30:00.000Z' }],
['pe2_team', { state: 'disabled', enabled: false, disabledReason: 'team', disabledAt: '2026-10-01T10:00:00.000Z' }],
['pe2_alt', { state: 'disabled', enabled: false, disabledAt: '2026-10-01T10:00:00.000Z' }],
['pe2_aktiv', { state: 'active', enabled: true }],
]
for (const [id, felder] of projekte) {
aw.seed('websiteProjects', { $id: id, customerId: 'ce2', projectName: id })
aw.seed('portalFeatures', { $id: `f_${id}_termine`, customerId: 'ce2', projectId: id, featureKey: 'termine', siteKey: 'abcdefghijklmnop', ...felder })
}
const body = await get('/api/funktionen', { cookie: customerCookie('ce2') })
const termine = (id) => body.projects.find((p) => p.projectId === id).features.termine
const felder = (id) => ({ disabledReason: termine(id).disabledReason, disabledAt: termine(id).disabledAt, loeschungAm: termine(id).loeschungAm })
assert.deepEqual(felder('pe2_aboende'), { disabledReason: 'abo_ende', disabledAt: '2026-10-04T23:30:00.000Z', loeschungAm: '2026-11-04' }, 'Berliner Tag, nicht der UTC-Tag 2026-11-03')
assert.deepEqual(felder('pe2_team'), { disabledReason: 'team', disabledAt: '2026-10-01T10:00:00.000Z', loeschungAm: '2026-10-31' })
assert.deepEqual(felder('pe2_alt'), { disabledReason: null, disabledAt: '2026-10-01T10:00:00.000Z', loeschungAm: '2026-10-31' }, 'Altbestand ohne Grund')
assert.deepEqual(felder('pe2_aktiv'), { disabledReason: null, disabledAt: null, loeschungAm: null })
assert.equal(termine('pe2_aboende').state, 'disabled')
})
test('Abgeschaltetes News hat Grund und Zeitpunkt, aber kein Loeschdatum; die Teamansicht nennt es fuer den Terminplaner (R105)', async () => {
const lange_her = '2026-01-15T10:00:00.000Z'
aw.seed('websiteProjects', { $id: 'pe3_p', customerId: 'ce3', projectName: 'Alt abgeschaltet' })
aw.seed('portalFeatures', { $id: 'f_pe3_news', customerId: 'ce3', projectId: 'pe3_p', featureKey: 'news', siteKey: 'abcdefghijklmnop', state: 'disabled', enabled: false, disabledReason: 'team', disabledAt: lange_her })
aw.seed('portalFeatures', { $id: 'f_pe3_termine', customerId: 'ce3', projectId: 'pe3_p', featureKey: 'termine', siteKey: 'bcdefghijklmnopq', state: 'disabled', enabled: false, disabledReason: 'abo_ende', disabledAt: lange_her })
// Kundenansicht
const kunde = (await get('/api/funktionen', { cookie: customerCookie('ce3') })).projects.find((p) => p.projectId === 'pe3_p').features
assert.deepEqual([kunde.news.state, kunde.news.disabledReason, kunde.news.disabledAt, kunde.news.loeschungAm], ['disabled', 'team', lange_her, null])
assert.deepEqual([kunde.termine.disabledReason, kunde.termine.disabledAt, kunde.termine.loeschungAm], ['abo_ende', lange_her, '2026-02-14'])
// Teamansicht (adminView, GET /api/team/funktionen/:customerId): dieselben Felder, die WOMS-Anzeige (E12) liest loeschungAm
const team_ansicht = (await get('/api/team/funktionen/ce3', STAFF)).projects.find((p) => p.projectId === 'pe3_p').features
assert.deepEqual([team_ansicht.termine.state, team_ansicht.termine.disabledReason, team_ansicht.termine.disabledAt], ['disabled', 'abo_ende', lange_her])
assert.equal(team_ansicht.termine.loeschungAm, '2026-02-14', 'abgeschalteter Terminplaner traegt loeschungAm (Berliner Tag, 30 x 24 h spaeter)')
assert.equal(team_ansicht.termine.loeschungAm, loeschTag(lange_her))
assert.deepEqual([team_ansicht.news.disabledReason, team_ansicht.news.disabledAt, team_ansicht.news.loeschungAm], ['team', lange_her, null])
})
// --------------------------------------------------------- Kunde loeschen ---
/** Entwurf: Terminplaner im Zustand off samt Einstellung (termineConfig), nie bestellt. */
function legeEntwurf(customerId, projectId) {
const id = `f_${projectId}_termine`
aw.seed('portalFeatures', { $id: id, customerId, projectId, featureKey: 'termine', siteKey: 'abcdefghijklmnop', state: 'off', enabled: false })
aw.seed('termineConfig', { $id: id, customerId, projectId, einstellung: '{}', version: 1, updatedBy: 'kunde' })
}
test('Kunde loeschen schaltet seine Terminplaner ab (Notiz Kunde geloescht, Grund kunde_geloescht), nur seine', async () => {
aw.seed('customers', { $id: 'kt1', name: 'Weg Salon', email: 'weg@example.invalid' })
aw.seed('websiteProjects', { $id: 'ktp1', customerId: 'kt1', projectName: 'Weg Eins' })
aw.seed('websiteProjects', { $id: 'ktp2', customerId: 'kt1', projectName: 'Weg Zwei' })
aw.seed('websiteProjects', { $id: 'ktp3', customerId: 'kt1', projectName: 'Weg Drei' })
await sf.adminActivate('kt1', 'ktp1', 'termine', { by: 'test' })
await sf.adminActivate('kt1', 'ktp1', 'news', { by: 'test' })
await sf.adminActivate('kt1', 'ktp2', 'termine', { by: 'test' })
const schonAus = await sf.adminActivate('kt1', 'ktp3', 'termine', { by: 'test' })
const frueher = (await sf.disable(schonAus.$id, { by: 'test' })).disabledAt
const fremd = await sf.getFeatureDoc('c1', 'pt1', 'termine')
// Entwuerfe (off): der des Kunden samt Einstellung geht mit ihm, der eines anderen Kunden bleibt
aw.seed('websiteProjects', { $id: 'ktp5', customerId: 'kt1', projectName: 'Weg Fuenf' })
legeEntwurf('kt1', 'ktp5')
legeEntwurf('kt9', 'ktp9')
await deleteCustomerWithPortalAccess('kt1')
assert.equal(aw.docs('portalFeatures').has('f_ktp5_termine'), false, 'Entwurf des Kunden: Funktionsdokument weg')
assert.equal(aw.docs('termineConfig').has('f_ktp5_termine'), false, 'Entwurf des Kunden: Einstellung weg')
assert.equal(aw.docs('portalFeatures').has('f_ktp9_termine'), true, 'Entwurf eines anderen Kunden bleibt')
assert.equal(aw.docs('termineConfig').has('f_ktp9_termine'), true)
assert.equal(aw.docs('customers').has('kt1'), false)
for (const projekt of ['ktp1', 'ktp2']) {
const doc = await sf.getFeatureDoc('kt1', projekt, 'termine')
assert.deepEqual([doc.state, doc.enabled, doc.adminNote, doc.disabledReason], ['disabled', false, 'Kunde gelöscht', 'kunde_geloescht'], projekt)
assert.ok(Math.abs(Date.parse(doc.disabledAt) - Date.now()) < 5000, projekt)
}
const schonAusNachher = await sf.getFeatureDoc('kt1', 'ktp3', 'termine')
assert.equal(schonAusNachher.disabledAt, frueher, 'Beginn der 30 Tage bleibt')
assert.equal(schonAusNachher.disabledReason, 'team', 'ein schon abgeschalteter Terminplaner behaelt seinen Grund')
assert.equal((await sf.getFeatureDoc('kt1', 'ktp1', 'news')).state, 'active', 'News ist nicht Sache des Terminplaners')
assert.equal((await sf.getFeatureDoc('c1', 'pt1', 'termine')).state, fremd.state, 'anderer Kunde')
})
test('Kunde loeschen gelingt auch, wenn das Abschalten scheitert (Fehler im Log)', async (t) => {
const log = t.mock.method(console, 'error', () => {})
aw.seed('customers', { $id: 'kt2', name: 'Weg Zwei', email: 'weg2@example.invalid' })
aw.seed('websiteProjects', { $id: 'ktp4', customerId: 'kt2', projectName: 'Weg Vier' })
await sf.adminActivate('kt2', 'ktp4', 'termine', { by: 'test' })
aw.failNext({ collection: 'portalFeatures', method: 'PATCH', status: 500, message: 'kaputt' })
await deleteCustomerWithPortalAccess('kt2')
assert.equal(aw.docs('customers').has('kt2'), false)
assert.ok(log.mock.calls.some((c) => /Terminplaner von Kunde kt2 nicht abgeschaltet/.test(String(c.arguments[0]))))
})
test('Kunde loeschen gelingt auch, wenn das Loeschen der Entwuerfe scheitert (Fehler im Log, die uebrigen Entwuerfe gehen trotzdem)', async (t) => {
const log = t.mock.method(console, 'error', () => {})
aw.seed('customers', { $id: 'kt3', name: 'Weg Drei', email: 'weg3@example.invalid' })
aw.seed('websiteProjects', { $id: 'ktp6', customerId: 'kt3', projectName: 'Weg Sechs' })
aw.seed('websiteProjects', { $id: 'ktp7', customerId: 'kt3', projectName: 'Weg Sieben' })
legeEntwurf('kt3', 'ktp6')
legeEntwurf('kt3', 'ktp7')
aw.failNext({ collection: 'termineConfig', method: 'DELETE', status: 500, message: 'kaputt' })
await deleteCustomerWithPortalAccess('kt3')
assert.equal(aw.docs('customers').has('kt3'), false, 'der Kunde ist geloescht')
assert.ok(log.mock.calls.some((c) => /Entwürfe von Kunde kt3 nicht gelöscht/.test(String(c.arguments[0]))), 'Logzeile')
assert.equal(aw.docs('portalFeatures').has('f_ktp6_termine'), true, 'der Entwurf mit Fehler bleibt (ein spaeterer Lauf nach 90 Tagen holt ihn nach)')
assert.equal(aw.docs('portalFeatures').has('f_ktp7_termine'), false, 'der andere Entwurf ist weg')
assert.equal(aw.docs('termineConfig').has('f_ktp7_termine'), false)
})
test('Kunde loeschen laesst Terminplaner, die nicht off sind, den Abschaltweg gehen (Frist von 30 Tagen) und loescht ihre Einstellung nicht', async () => {
aw.seed('customers', { $id: 'kt4', name: 'Weg Vier', email: 'weg4@example.invalid' })
aw.seed('websiteProjects', { $id: 'ktp8', customerId: 'kt4', projectName: 'Weg Acht' })
await sf.adminActivate('kt4', 'ktp8', 'termine', { by: 'test' })
aw.seed('termineConfig', { $id: 'f_ktp8_termine', customerId: 'kt4', projectId: 'ktp8', einstellung: '{}', version: 1, updatedBy: 'kunde' })
await deleteCustomerWithPortalAccess('kt4')
const doc = await sf.getFeatureDoc('kt4', 'ktp8', 'termine')
assert.equal(doc.state, 'disabled')
assert.equal(aw.docs('termineConfig').has('f_ktp8_termine'), true, 'die Einstellung eines aktiven Betriebs loescht erst der Tageslauf nach 30 Tagen')
})
// ------------------------------------------------ Team-Aktivierung nur mit laufendem Abo (E9) ---
// Ein bezahlter Terminplaner (unlockedByPurchase) darf das Team nur einschalten, solange das Wartungs-Abo laeuft: sonst nimmt der
// naechste Abgleichslauf die Kulanz zurueck und K22 ginge ein zweites Mal. Pilot und Kulanz ohne Kauf sowie News sind nicht betroffen.
const KEIN_ABO = 'Das Wartungs-Abo des Kunden läuft nicht. Der Terminplaner kann erst wieder freigeschaltet werden, wenn es läuft.'
let kaNr = 0
/** Kunde mit Projekt und Funktionsdokument (key, over); abo: 'wartung' | 'hosting' | null (kein Abo). Liefert { customerId, projectId, docId }. */
function legeKunde({ abo = null, aboOver = {}, key = 'termine', over = {} } = {}) {
kaNr += 1
const customerId = `ka${kaNr}`
const projectId = `kap${kaNr}`
const docId = `f_${projectId}_${key}`
aw.seed('websiteProjects', { $id: projectId, customerId, projectName: `Salon ${kaNr}` })
if (abo) aw.seed('subscriptions', subscriptionDoc(`sub_ka${kaNr}`, { ...(abo === 'wartung' ? wartungDoc : hostingDoc), customerId, ...aboOver }))
aw.seed('portalFeatures', {
$id: docId, customerId, projectId, featureKey: key, siteKey: 'abcdefghijklmnop', state: 'disabled', enabled: false,
unlockedByPurchase: true, purchaseStatus: 'paid', disabledReason: 'abo_ende', disabledAt: '2026-10-01T10:00:00.000Z',
aboEndeMailAt: '2026-10-01T10:05:00.000Z', adminNote: 'Abgeschaltet: Wartungs-Abo beendet', ...over,
})
return { customerId, projectId, docId }
}
const docById = (id) => aw.docs('portalFeatures').get(id)
const aktivieren = (k, key = 'termine') => team(k.customerId, key, { projectId: k.projectId, action: 'activate', note: 'Kulanz' })
test('Team aktiviert einen bezahlten, abgeschalteten Terminplaner ohne laufendes Abo nicht: 409 mit Text, Zustand unveraendert', async () => {
for (const abo of [null, 'hosting']) {
const k = legeKunde({ abo })
const vorher = { ...docById(k.docId) }
const res = await aktivieren(k)
assert.equal(res.status, 409, `abo ${abo}`)
assert.equal(res.body.error, KEIN_ABO)
assert.deepEqual(docById(k.docId), vorher, 'kein Schreibzugriff: Zustand, Grund, Frist und Notiz bleiben')
}
// auch ein bezahlter Terminplaner, den das Team abgeschaltet hat, und ein ausgebliebenes Abo nach Kuendigung (canceled)
const team_aus = legeKunde({ over: { disabledReason: 'team', aboEndeMailAt: null } })
assert.equal((await aktivieren(team_aus)).status, 409)
assert.equal(docById(team_aus.docId).state, 'disabled')
const beendet = legeKunde({ abo: 'wartung', aboOver: { status: 'canceled', endedAt: '2026-10-01T10:00:00.000Z' } })
assert.equal((await aktivieren(beendet)).status, 409)
assert.equal(docById(beendet.docId).state, 'disabled')
})
test('Bezahlter Terminplaner in Einrichtung (setup) ohne Abo: das Team kann ihn auch nicht von Hand freischalten', async () => {
const k = legeKunde({ over: { state: 'setup', disabledReason: null, disabledAt: null, aboEndeMailAt: null, adminNote: '' } })
const res = await aktivieren(k)
assert.equal(res.status, 409)
assert.equal(res.body.error, KEIN_ABO)
assert.equal(docById(k.docId).state, 'setup')
})
test('Mit laufendem Wartungs-Abo aktiviert das Team den bezahlten Terminplaner: active, Grund und Frist leer, Marker leer', async () => {
const k = legeKunde({ abo: 'wartung' })
const res = await aktivieren(k)
assert.equal(res.status, 200, JSON.stringify(res.body))
const doc = docById(k.docId)
assert.deepEqual([doc.state, doc.enabled, doc.disabledReason, doc.disabledAt, doc.aboEndeMailAt], ['active', true, null, null, null])
assert.equal(doc.unlockedByPurchase, true, 'der Kauf bleibt vermerkt')
assert.equal(featuresOf(res, k.projectId).termine.state, 'active')
// eine vorgemerkte Kuendigung laesst das Abo bis zum Ende laufen
const gekuendigt = legeKunde({ abo: 'wartung', aboOver: { cancelAtPeriodEnd: true } })
assert.equal((await aktivieren(gekuendigt)).status, 200)
assert.equal(docById(gekuendigt.docId).state, 'active')
})
test('Wechsel widerrufen (Spiegel steht noch auf Wartung): 409, denn das Wartungs-Abo laeuft nicht', async () => {
const k = legeKunde({ abo: 'wartung' })
aw.seed('contractEvents', {
$id: `ev_${k.customerId}`, customerId: k.customerId, subscriptionId: `sub_ka${kaNr}`, type: 'wechsel', productKey: 'wartung', details: '{}', status: 'widerrufen',
})
const res = await aktivieren(k)
assert.equal(res.status, 409)
assert.equal(res.body.error, KEIN_ABO)
assert.equal(docById(k.docId).state, 'disabled')
})
test('Ohne Kauf (unlockedByPurchase false: Pilot, Kulanz) braucht die Aktivierung kein Abo und liest es nicht', async () => {
for (const unlockedByPurchase of [false, undefined]) {
const k = legeKunde({ over: { unlockedByPurchase, purchaseStatus: '', disabledReason: 'team', aboEndeMailAt: null } })
const vorher = aw.listCalls('subscriptions').length
const res = await aktivieren(k)
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.equal(docById(k.docId).state, 'active')
assert.equal(aw.listCalls('subscriptions').length, vorher, 'keine Abo-Abfrage')
}
// kein Dokument bisher (ensureFeatureDoc legt es an): ebenfalls frei
aw.seed('websiteProjects', { $id: 'kap_neu', customerId: 'ka_neu', projectName: 'Neu' })
const neu = await team('ka_neu', 'termine', { projectId: 'kap_neu', action: 'activate' })
assert.equal(neu.status, 200)
})
test('News ist nicht betroffen: auch mit unlockedByPurchase und ohne Abo aktiviert das Team', async () => {
const k = legeKunde({ key: 'news', over: { disabledReason: 'team', aboEndeMailAt: null } })
const res = await aktivieren(k, 'news')
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.equal(docById(k.docId).state, 'active')
})
test('Abo nicht lesbar: Fehler (5xx), nicht aktiviert; danach geht es mit lesbarem Abo', async (t) => {
const log = t.mock.method(console, 'error', () => {})
const k = legeKunde({ abo: 'wartung' })
const vorher = { ...docById(k.docId) }
aw.failNext({ collection: 'subscriptions', method: 'GET', status: 500, message: 'kaputt' })
const res = await aktivieren(k)
assert.ok(res.status >= 500 && res.status < 600, `Status ${res.status}`)
assert.equal(typeof res.body.error, 'string')
assert.deepEqual(docById(k.docId), vorher, 'nicht aktiviert')
assert.ok(log.mock.calls.length >= 1, 'der Fehler steht im Log')
assert.equal((await aktivieren(k)).status, 200)
assert.equal(docById(k.docId).state, 'active')
})
test('Ereignisse nicht lesbar (Wechsel-Regel): Fehler, nicht aktiviert', async (t) => {
t.mock.method(console, 'error', () => {})
const k = legeKunde({ abo: 'wartung' })
aw.failNext({ collection: 'contractEvents', method: 'GET', status: 500 })
const res = await aktivieren(k)
assert.ok(res.status >= 500 && res.status < 600, `Status ${res.status}`)
assert.equal(docById(k.docId).state, 'disabled')
})
test('adminActivate (Dienst): bezahlter Terminplaner ohne Abo => Fehler 409, bei laufendem Abo und fuer Dokumente ohne Kauf aktiv', async () => {
const k = legeKunde()
await assert.rejects(() => sf.adminActivate(k.customerId, k.projectId, 'termine', { by: 'test' }), { status: 409, message: KEIN_ABO })
assert.equal(docById(k.docId).state, 'disabled')
const mit = legeKunde({ abo: 'wartung' })
assert.equal((await sf.adminActivate(mit.customerId, mit.projectId, 'termine', { by: 'test' })).state, 'active')
const pilot = legeKunde({ over: { unlockedByPurchase: false } })
assert.equal((await sf.adminActivate(pilot.customerId, pilot.projectId, 'termine', { by: 'test' })).state, 'active')
})
// ------------------------------------------ Team-Aktionen Freischalten (Abnahme) und Ticket nachlegen (D7) ---
// abnahme: Terminplaner in setup freischalten, ohne dass das Einrichtungs-Ticket geschlossen wird (Ticket und Kauf bleiben, K16 einmal).
// ticket: ein fehlendes Einrichtungs-Ticket nachlegen - (a) Ticket nie angelegt: Zahlung bei Stripe pruefen und den Weg des Webhooks gehen
// (Ticket, K15, T16 genau einmal), (b) Ticket geloescht: neues Ticket, keine Mails, (c) Ticket da: nichts.
const K16_BETREFF = { termine: 'Dein Terminplaner ist bereit', news: 'Deine News-Pinnwand ist bereit' }
const K15_BETREFF = 'Deine Bestellung: Terminplaner'
const T16_BETREFF = 'Neue Funktion bestellt: Terminplaner'
const NUR_SETUP = 'Freischalten (Abnahme) gibt es nur für Funktionen in Einrichtung.'
const NUR_SETUP_TICKET = 'Ein Ticket nachlegen gibt es nur für Funktionen in Einrichtung.'
const OHNE_ABO = 'Der Kunde hat kein laufendes Wartungs-Abo. Freischalten ist erst mit laufendem Abo möglich.'
const OHNE_EINSTELLUNG = 'Es sind noch keine Einstellungen gespeichert.'
const ABO_UNKLAR = 'Das Abo konnte gerade nicht geprüft werden.'
const NICHT_BEZAHLT = 'Die Zahlung ist noch nicht eingegangen.'
const KEINE_AKTION = 'Diese Aktion gibt es für diese Funktion nicht.'
const mailsAn = (email, subject) => mails.sent.filter((m) => m.subject === subject && m.to.some((t) => t.address === email))
const seit = (n) => mails.sent.slice(n)
let d7Nr = 0
/**
* Kunde mit Projekt, Funktionsdokument (Zustand state), Wartungs-Abo und gespeicherter Einstellung.
* ticket: 'Open' | 'Closed' (Ticket da) | 'geloescht' (ticketId gesetzt, Ticket fehlt) | null (nie angelegt).
* session: null | 'paid' | 'unpaid' legt die Stripe-Sitzung samt Rechnung im Nachbau an und traegt sie am Dokument ein.
*/
function legeEinrichtung({ key = 'termine', abo = 'wartung', einstellung = true, ticket = 'Open', session = null, state = 'setup', over = {} } = {}) {
d7Nr += 1
const n = d7Nr
const customerId = `cd${n}`
const projectId = `cdp${n}`
const docId = `f_${projectId}_${key}`
const ticketId = `t_cd${n}`
const sessionId = session ? `cs_cd${n}` : ''
const email = `cd${n}@example.invalid`
aw.seed('customers', { $id: customerId, name: `Salon ${n}`, email, status: 'active' })
aw.seed('websiteProjects', { $id: projectId, customerId, projectName: `Salon ${n}` })
if (abo) aw.seed('subscriptions', subscriptionDoc(`sub_cd${n}`, { ...(abo === 'wartung' ? wartungDoc : hostingDoc), customerId }))
if (ticket === 'Open' || ticket === 'Closed') {
aw.seed('workorders', { $id: ticketId, woid: 7000 + n, status: ticket, topic: `Terminplaner einbauen – Salon ${n}`, details: 'Original-Ticket' })
}
if (session) {
st.invoices.set(`in_cd${n}`, { id: `in_cd${n}`, object: 'invoice', hosted_invoice_url: `https://invoice.stripe.test/in_cd${n}` })
st.checkoutSessions.set(sessionId, {
id: sessionId, object: 'checkout.session', mode: 'payment', status: 'complete', payment_status: session, invoice: `in_cd${n}`,
metadata: { customerId, projectId, featureKey: key },
})
}
aw.seed('portalFeatures', {
$id: docId, customerId, projectId, featureKey: key, siteKey: 'abcdefghijklmnop', state, enabled: state === 'active',
unlockedByPurchase: true, purchaseStatus: 'paid', orderedAt: '2026-10-01T10:00:00.000Z', stripeSessionId: sessionId,
...(ticket ? { ticketId } : {}), adminNote: '', ...over,
})
if (einstellung) {
aw.seed('termineConfig', {
$id: docId, customerId, projectId, einstellung: JSON.stringify({ betrieb: { name: `Salon ${n}` } }), version: 1, updatedAt: '2026-10-03T08:00:00.000Z', updatedBy: 'test',
})
}
return { customerId, projectId, docId, ticketId, sessionId, email }
}
const abnahme = (k, key = 'termine', opts) => team(k.customerId, key, { projectId: k.projectId, action: 'abnahme' }, opts)
const nachlegen = (k, key = 'termine', opts) => team(k.customerId, key, { projectId: k.projectId, action: 'ticket' }, opts)
const ticketDoc = (id) => aw.docs('workorders').get(id)
const dienst = (k, fn, key = 'termine') => sf[fn]({ customerId: k.customerId, projectId: k.projectId, key, by: 'Andrej', adminNote: `x Andrej: ${fn}` })
// ----------------------------------------------------------------------------- abnahme ---
test('abnahme auf setup mit Einstellung und Abo: active, Ticket (Open) und Kauf bleiben, adminNote, K16 genau einmal', async () => {
const k = legeEinrichtung()
const res = await abnahme(k)
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.deepEqual(res.body.projects.map((p) => p.projectId), [k.projectId])
const doc = docById(k.docId)
assert.deepEqual([doc.state, doc.enabled], ['active', true])
assert.ok(Math.abs(Date.parse(doc.activatedAt) - Date.now()) < 5000, 'activatedAt gesetzt')
assert.equal(featuresOf(res, k.projectId).termine.state, 'active')
assert.equal(featuresOf(res, k.projectId).termine.activatedAt, doc.activatedAt)
assert.equal(doc.ticketId, k.ticketId, 'ticketId bleibt')
assert.equal(ticketDoc(k.ticketId).status, 'Open', 'das Ticket wird nicht geschlossen')
assert.equal(doc.unlockedByPurchase, true)
assert.equal(doc.purchaseStatus, 'paid')
assert.match(doc.adminNote, /^\d{4}-\d\d-\d\dT[\d:.]+Z Andrej: abnahme$/)
const k16 = mailsAn(k.email, K16_BETREFF.termine)
assert.equal(k16.length, 1, 'K16 genau einmal')
assert.match(k16[0].text, /dashboard\.html#termine/)
})
test('abnahme: zweiter Klick => 200 ohne zweite Mail und ohne Schreibzugriff; Dienst meldet activated true, dann false', async () => {
const k = legeEinrichtung()
assert.equal((await abnahme(k)).status, 200)
const vorher = { ...docById(k.docId) }
const nochmal = await abnahme(k)
assert.equal(nochmal.status, 200)
assert.equal(featuresOf(nochmal, k.projectId).termine.state, 'active')
assert.deepEqual(docById(k.docId), vorher, 'nichts geschrieben: activatedAt und Notiz bleiben')
assert.equal(mailsAn(k.email, K16_BETREFF.termine).length, 1)
const k2 = legeEinrichtung()
const erster = await dienst(k2, 'freischaltenNachAbnahme')
assert.equal(erster.activated, true)
assert.equal(erster.doc.state, 'active')
const zweiter = await dienst(k2, 'freischaltenNachAbnahme')
assert.equal(zweiter.activated, false)
assert.equal(zweiter.doc.state, 'active')
assert.equal(mailsAn(k2.email, K16_BETREFF.termine).length, 1)
})
test('abnahme: zwei parallele Klicks => eine Mail, ein Zustandswechsel', async () => {
const k = legeEinrichtung()
const [a, b] = await Promise.all([abnahme(k), abnahme(k)])
assert.deepEqual([a.status, b.status], [200, 200])
assert.equal(docById(k.docId).state, 'active')
assert.equal(mailsAn(k.email, K16_BETREFF.termine).length, 1)
// auch ohne die Sperre der Route: zwei gleichzeitige Dienstaufrufe aktivieren nur einmal
const k2 = legeEinrichtung()
const [x, y] = await Promise.all([dienst(k2, 'freischaltenNachAbnahme'), dienst(k2, 'freischaltenNachAbnahme')])
assert.deepEqual([x.activated, y.activated].sort(), [false, true])
assert.equal(mailsAn(k2.email, K16_BETREFF.termine).length, 1)
})
test('abnahme und der Freischalt-Job (runActivation) bei geschlossenem Ticket gleichzeitig => eine Mail insgesamt, in beiden Reihenfolgen', async () => {
for (const abnahmeZuerst of [true, false]) {
const k = legeEinrichtung({ ticket: 'Closed' })
const start = [() => abnahme(k), () => sf.runActivation({ customerId: k.customerId })]
if (!abnahmeZuerst) start.reverse()
await Promise.all(start.map((f) => f()))
assert.equal(docById(k.docId).state, 'active', `abnahmeZuerst ${abnahmeZuerst}`)
assert.equal(mailsAn(k.email, K16_BETREFF.termine).length, 1, `abnahmeZuerst ${abnahmeZuerst}`)
}
})
test('abnahme: bereits aktive Funktion (Pilot, Kulanz) => 200 ohne Mail und ohne Schreibzugriff', async () => {
const k = legeEinrichtung({ state: 'active', ticket: null, over: { unlockedByPurchase: false, purchaseStatus: '', activatedAt: '2026-10-02T10:00:00.000Z' } })
const vorher = { ...docById(k.docId) }
const res = await abnahme(k)
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.deepEqual(docById(k.docId), vorher)
assert.equal(mailsAn(k.email, K16_BETREFF.termine).length, 0)
})
test('abnahme: off, ordered und disabled => 409 mit Text, nichts geschrieben, keine Mail', async () => {
const faelle = [
legeEinrichtung({ state: 'off', ticket: null, over: { unlockedByPurchase: false, purchaseStatus: '' } }),
legeEinrichtung({ state: 'ordered', ticket: null, over: { purchaseStatus: 'ordered', unlockedByPurchase: false } }),
legeEinrichtung({ state: 'disabled', over: { disabledReason: 'team', disabledAt: '2026-10-01T10:00:00.000Z' } }),
]
for (const k of faelle) {
const vorher = { ...docById(k.docId) }
const res = await abnahme(k)
assert.equal(res.status, 409, vorher.state)
assert.equal(res.body.error, NUR_SETUP, vorher.state)
assert.deepEqual(docById(k.docId), vorher, vorher.state)
assert.equal(mailsAn(k.email, K16_BETREFF.termine).length, 0, vorher.state)
}
})
test('abnahme ohne laufendes Wartungs-Abo (kein Abo, nur Hosting, beendet) => 409, setup bleibt, keine Mail', async () => {
const faelle = [
legeEinrichtung({ abo: null }),
legeEinrichtung({ abo: 'hosting' }),
legeEinrichtung({ abo: 'wartung' }),
]
// beendetes Wartungs-Abo: das dritte Abo auf canceled stellen
aw.seed('subscriptions', subscriptionDoc(`sub_cd${d7Nr}`, { ...wartungDoc, customerId: faelle[2].customerId, status: 'canceled', endedAt: '2026-10-01T10:00:00.000Z' }))
for (const k of faelle) {
const vorher = { ...docById(k.docId) }
const res = await abnahme(k)
assert.equal(res.status, 409, k.customerId)
assert.equal(res.body.error, OHNE_ABO)
assert.deepEqual(docById(k.docId), vorher, 'setup bleibt, nichts geschrieben')
assert.equal(mailsAn(k.email, K16_BETREFF.termine).length, 0)
}
})
test('abnahme: eine vorgemerkte Kuendigung und past_due laufen noch => 200', async () => {
for (const aboOver of [{ cancelAtPeriodEnd: true }, { status: 'past_due' }]) {
const k = legeEinrichtung()
aw.seed('subscriptions', subscriptionDoc(`sub_cd${d7Nr}`, { ...wartungDoc, customerId: k.customerId, ...aboOver }))
assert.equal((await abnahme(k)).status, 200, JSON.stringify(aboOver))
assert.equal(docById(k.docId).state, 'active')
}
})
test('abnahme ohne gespeicherte Einstellung => 409, setup bleibt', async () => {
const k = legeEinrichtung({ einstellung: false })
const vorher = { ...docById(k.docId) }
const res = await abnahme(k)
assert.equal(res.status, 409)
assert.equal(res.body.error, OHNE_EINSTELLUNG)
assert.deepEqual(docById(k.docId), vorher)
assert.equal(mailsAn(k.email, K16_BETREFF.termine).length, 0)
})
test('abnahme: Abo nicht lesbar => 503 mit Text (geloggt), setup bleibt; danach geht es', async (t) => {
const log = t.mock.method(console, 'error', () => {})
const k = legeEinrichtung()
const vorher = { ...docById(k.docId) }
aw.failNext({ collection: 'subscriptions', method: 'GET', status: 500, message: 'Nachbau: Abo kaputt' })
const res = await abnahme(k)
assert.equal(res.status, 503)
assert.equal(res.body.error, ABO_UNKLAR)
assert.deepEqual(docById(k.docId), vorher, 'nie freischalten, wenn etwas nicht lesbar war')
assert.equal(mailsAn(k.email, K16_BETREFF.termine).length, 0)
assert.ok(log.mock.calls.some((c) => /nicht prüfbar/.test(c.arguments.join(' '))), 'der Fehler steht im Log')
assert.equal((await abnahme(k)).status, 200)
assert.equal(docById(k.docId).state, 'active')
})
test('abnahme: Projekt eines anderen Kunden => 403, Kundensitzung statt JWT => 401/403, ohne Dokument => 409 und nichts angelegt', async () => {
const k = legeEinrichtung()
const vorher = { ...docById(k.docId) }
assert.equal((await team('c1', 'termine', { projectId: 'pt3', action: 'abnahme' })).status, 403, 'pt3 gehoert c2')
assert.equal((await team(k.customerId, 'termine', { projectId: 'pt3', action: 'abnahme' })).status, 403)
assert.equal((await team('c1', 'termine', { projectId: 'gibtsnicht', action: 'abnahme' })).status, 403)
const alsKunde = await abnahme(k, 'termine', { cookie: customerCookie(k.customerId) })
assert.ok(alsKunde.status === 401 || alsKunde.status === 403, `Status ${alsKunde.status}`)
assert.deepEqual(docById(k.docId), vorher)
const docs = aw.docs('portalFeatures').size
aw.seed('websiteProjects', { $id: 'pt_d7_leer', customerId: 'c1', projectName: 'Ohne Dokument D7' })
const ohneDoc = await team('c1', 'termine', { projectId: 'pt_d7_leer', action: 'abnahme' })
assert.equal(ohneDoc.status, 409)
assert.equal(ohneDoc.body.error, 'Für dieses Projekt gibt es noch keinen Terminplaner')
assert.equal(aw.docs('portalFeatures').size, docs, 'kein Dokument entstanden')
assert.equal(mails.sent.filter((m) => m.subject === K16_BETREFF.termine && m.to.some((to) => to.address === 'k@example.invalid')).length, 0)
})
test('abnahme bei News funktioniert ohne Abo-Pruefung (es gibt keinen Haken freischaltbar): active, K16 News, Abo nicht gelesen', async () => {
const k = legeEinrichtung({ key: 'news', abo: null, einstellung: false })
const abfragen = aw.listCalls('subscriptions').length
const res = await abnahme(k, 'news')
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.equal(featuresOf(res, k.projectId).news.state, 'active')
assert.equal(docById(k.docId).state, 'active')
assert.equal(docById(k.docId).ticketId, k.ticketId)
assert.equal(aw.listCalls('subscriptions').length, abfragen, 'keine Abo-Abfrage')
assert.equal(mailsAn(k.email, K16_BETREFF.news).length, 1)
})
test('abnahme und ticket bei analyse und unbekannten Schluesseln => 404, nichts angelegt', async () => {
const k = legeEinrichtung()
const docs = aw.docs('portalFeatures').size
for (const action of ['abnahme', 'ticket']) {
for (const key of ['analyse', 'kalender']) {
const res = await team(k.customerId, key, { projectId: k.projectId, action })
assert.equal(res.status, 404, `${key} ${action}`)
}
}
assert.equal(aw.docs('portalFeatures').size, docs)
})
test('abnahme und ticket brauchen ticketFreischaltung im Register, sonst 409 ohne Schreibzugriff', async () => {
const k = legeEinrichtung({ key: 'news' })
const vorher = { ...docById(k.docId) }
const alt = config.features.news.ticketFreischaltung
config.features.news.ticketFreischaltung = false
try {
for (const action of ['abnahme', 'ticket']) {
const res = await team(k.customerId, 'news', { projectId: k.projectId, action })
assert.equal(res.status, 409, action)
assert.equal(res.body.error, KEINE_AKTION, action)
}
} finally {
config.features.news.ticketFreischaltung = alt
}
assert.deepEqual(docById(k.docId), vorher)
assert.equal(mailsAn(k.email, K16_BETREFF.news).length, 0)
// activate und disable gehoeren nicht dazu und gehen weiter
assert.equal((await team(k.customerId, 'news', { projectId: k.projectId, action: 'disable' })).status, 200)
})
test('abnahme: scheitert der Versand von K16, ist die Funktion trotzdem freigeschaltet (Fehler im Log)', async (t) => {
const log = t.mock.method(console, 'error', () => {})
const k = legeEinrichtung()
setMailTransportForTests({ sendMail: async () => { throw new Error('SMTP weg') } })
t.after(() => setMailTransportForTests(mails))
const res = await abnahme(k)
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.equal(docById(k.docId).state, 'active')
assert.ok(log.mock.calls.some((c) => /K16 an Kunde .* fehlgeschlagen/.test(c.arguments.join(' '))))
})
// ----------------------------------------------------------------------------- ticket ---
test('ticket (a): setup ohne ticketId, Sitzung bezahlt => Ticket mit Kurzfassung, K15 und T16 je einmal; zweiter Klick und Webhook-Wiederholung aendern nichts', async () => {
const k = legeEinrichtung({ ticket: null, session: 'paid' })
const tickets = workorders()
const mailsVorher = mails.sent.length
const res = await nachlegen(k)
assert.equal(res.status, 200, JSON.stringify(res.body))
const doc = docById(k.docId)
assert.ok(doc.ticketId, 'ticketId gesetzt')
assert.equal(workorders(), tickets + 1)
const ticket = ticketDoc(doc.ticketId)
assert.match(ticket.topic, /^Terminplaner einbauen – Salon \d+$/)
assert.ok(ticket.details.includes('Konfigurationsstand: Version 1'), ticket.details)
assert.equal(ticket.status, 'Open')
assert.deepEqual([doc.state, doc.unlockedByPurchase], ['setup', true])
assert.match(doc.adminNote, /Andrej: ticket/)
assert.equal(featuresOf(res, k.projectId).termine.ticketMissing, false)
// K15 an den Kunden, T16 ans Team, je einmal
assert.equal(mailsAn(k.email, K15_BETREFF).length, 1)
const neu = seit(mailsVorher)
assert.equal(neu.filter((m) => m.subject === T16_BETREFF).length, 1)
assert.match(neu.find((m) => m.subject === T16_BETREFF).text, new RegExp(String(ticket.woid)))
assert.equal(neu.length, 2, 'sonst nichts')
// zweiter Klick: Ticket vorhanden => nichts Neues
const nochmal = await nachlegen(k)
assert.equal(nochmal.status, 200)
assert.equal(workorders(), tickets + 1)
assert.equal(docById(k.docId).ticketId, doc.ticketId)
assert.equal(seit(mailsVorher).length, 2, 'keine weiteren Mails')
// ein folgender Webhook mit derselben Sitzung (Stripe-Retry) sendet und legt nichts doppelt an
await sf.handleFeatureCheckoutEvent({ type: 'checkout.session.completed', data: { object: st.checkoutSessions.get(k.sessionId) } })
assert.equal(workorders(), tickets + 1)
assert.equal(seit(mailsVorher).length, 2, 'Webhook-Wiederholung: keine weiteren Mails')
assert.equal(docById(k.docId).ticketId, doc.ticketId)
})
test('ticket (a), Dienst: created true beim Anlegen, false danach; zwei gleichzeitige Aufrufe legen ein Ticket und senden K15/T16 einmal', async () => {
const k = legeEinrichtung({ ticket: null, session: 'paid' })
const tickets = workorders()
const mailsVorher = mails.sent.length
const [a, b] = await Promise.all([dienst(k, 'ticketNachlegen'), dienst(k, 'ticketNachlegen')])
assert.deepEqual([a.created, b.created].sort(), [false, true])
assert.equal(a.doc.ticketId, b.doc.ticketId)
assert.equal(workorders(), tickets + 1)
assert.equal(seit(mailsVorher).length, 2, 'K15 und T16 je einmal')
const spaeter = await dienst(k, 'ticketNachlegen')
assert.equal(spaeter.created, false)
assert.equal(workorders(), tickets + 1)
})
test('ticket (a): zwei parallele Klicks => ein Ticket, K15 und T16 je einmal', async () => {
const k = legeEinrichtung({ ticket: null, session: 'paid' })
const tickets = workorders()
const mailsVorher = mails.sent.length
const [a, b] = await Promise.all([nachlegen(k), nachlegen(k)])
assert.deepEqual([a.status, b.status], [200, 200])
assert.equal(workorders(), tickets + 1)
assert.equal(seit(mailsVorher).length, 2)
assert.equal(mailsAn(k.email, K15_BETREFF).length, 1)
})
test('ticket (a): Sitzung nicht bezahlt => 409, kein Ticket, keine Mail; Stripe-Ausfall => 503, danach geht es', async (t) => {
const log = t.mock.method(console, 'error', () => {})
const unbezahlt = legeEinrichtung({ ticket: null, session: 'unpaid' })
const tickets = workorders()
const mailsVorher = mails.sent.length
const vorher = { ...docById(unbezahlt.docId) }
const res = await nachlegen(unbezahlt)
assert.equal(res.status, 409)
assert.equal(res.body.error, NICHT_BEZAHLT)
assert.deepEqual(docById(unbezahlt.docId), vorher)
assert.equal(workorders(), tickets)
const k = legeEinrichtung({ ticket: null, session: 'paid' })
const vorherK = { ...docById(k.docId) }
st.failNext({ method: 'GET', path: /^\/v1\/checkout\/sessions\//, status: 400, message: 'Nachbau: Stripe kaputt' })
const aus = await nachlegen(k)
assert.equal(aus.status, 503)
assert.equal(typeof aus.body.error, 'string')
assert.ok(!JSON.stringify(aus.body).includes('Nachbau'), 'nichts Technisches nach aussen')
assert.deepEqual(docById(k.docId), vorherK)
assert.equal(workorders(), tickets)
assert.equal(seit(mailsVorher).length, 0)
assert.ok(log.mock.calls.some((c) => /Nachbau: Stripe kaputt/.test(c.arguments.join(' '))), 'Ursache im Log')
assert.equal((await nachlegen(k)).status, 200, 'Stripe wieder da')
assert.equal(workorders(), tickets + 1)
assert.equal(seit(mailsVorher).length, 2)
})
test('ticket (a): ohne vermerkte Sitzung => 409, Stripe wird nicht gefragt', async () => {
const k = legeEinrichtung({ ticket: null })
const aufrufe = st.calls.length
const res = await nachlegen(k)
assert.equal(res.status, 409)
assert.equal(res.body.error, 'Zu dieser Funktion ist keine Zahlung vermerkt.')
assert.equal(st.calls.length, aufrufe)
})
test('ticket (b): Ticket geloescht => neues Ticket mit neuer ticketId, keine Mail, Stripe nicht gefragt', async () => {
const k = legeEinrichtung({ ticket: 'geloescht', session: 'paid' })
const tickets = workorders()
const mailsVorher = mails.sent.length
const aufrufe = st.calls.length
assert.equal(ticketDoc(k.ticketId), undefined, 'Ausgangslage: Ticket fehlt')
assert.equal(featuresOf({ body: await get(`/api/team/funktionen/${k.customerId}`, STAFF) }, k.projectId).termine.ticketMissing, true)
const res = await nachlegen(k)
assert.equal(res.status, 200, JSON.stringify(res.body))
const doc = docById(k.docId)
assert.ok(doc.ticketId && doc.ticketId !== k.ticketId, 'neue ticketId')
assert.equal(workorders(), tickets + 1)
const ticket = ticketDoc(doc.ticketId)
assert.match(ticket.topic, /^Terminplaner einbauen – Salon \d+$/)
assert.ok(ticket.details.includes('Konfigurationsstand: Version 1'))
assert.equal(ticket.status, 'Open')
assert.equal(doc.state, 'setup')
assert.match(doc.adminNote, /Andrej: ticket/)
assert.equal(featuresOf(res, k.projectId).termine.ticketMissing, false)
assert.equal(seit(mailsVorher).length, 0, 'keine Mails: K15/T16 gingen beim ersten Mal raus')
assert.equal(st.calls.length, aufrufe, 'Stripe nicht gefragt')
})
test('ticket (b): zwei parallele Klicks und zwei gleichzeitige Dienstaufrufe => genau ein neues Ticket', async () => {
const k = legeEinrichtung({ ticket: 'geloescht', session: 'paid' })
const tickets = workorders()
const [a, b] = await Promise.all([nachlegen(k), nachlegen(k)])
assert.deepEqual([a.status, b.status], [200, 200])
assert.equal(workorders(), tickets + 1)
const k2 = legeEinrichtung({ ticket: 'geloescht', session: 'paid' })
const tickets2 = workorders()
const [x, y] = await Promise.all([dienst(k2, 'ticketNachlegen'), dienst(k2, 'ticketNachlegen')])
assert.deepEqual([x.created, y.created].sort(), [false, true])
assert.equal(workorders(), tickets2 + 1)
assert.equal(x.doc.ticketId, y.doc.ticketId)
})
test('ticket (c): Ticket vorhanden (offen oder geschlossen) => no-op, created false, nichts geschrieben, Stripe nicht gefragt', async () => {
for (const ticket of ['Open', 'Closed']) {
const k = legeEinrichtung({ ticket, session: 'paid' })
const tickets = workorders()
const mailsVorher = mails.sent.length
const aufrufe = st.calls.length
const vorher = { ...docById(k.docId) }
const res = await nachlegen(k)
assert.equal(res.status, 200, ticket)
assert.deepEqual(docById(k.docId), vorher, ticket)
assert.equal(workorders(), tickets, ticket)
assert.equal(seit(mailsVorher).length, 0, ticket)
assert.equal(st.calls.length, aufrufe, ticket)
const dienstErgebnis = await dienst(k, 'ticketNachlegen')
assert.equal(dienstErgebnis.created, false, ticket)
assert.equal(dienstErgebnis.doc.ticketId, k.ticketId, ticket)
}
})
test('ticket: nur in setup - off, ordered, active und disabled => 409; ohne Dokument => 409', async () => {
const faelle = [
legeEinrichtung({ state: 'off', ticket: null, over: { unlockedByPurchase: false, purchaseStatus: '' } }),
legeEinrichtung({ state: 'ordered', ticket: null, session: 'paid', over: { purchaseStatus: 'ordered', unlockedByPurchase: false } }),
legeEinrichtung({ state: 'active', ticket: 'geloescht', session: 'paid' }),
legeEinrichtung({ state: 'disabled', ticket: null, session: 'paid', over: { disabledReason: 'team', disabledAt: '2026-10-01T10:00:00.000Z' } }),
]
const tickets = workorders()
const mailsVorher = mails.sent.length
for (const k of faelle) {
const vorher = { ...docById(k.docId) }
const res = await nachlegen(k)
assert.equal(res.status, 409, vorher.state)
assert.equal(res.body.error, NUR_SETUP_TICKET, vorher.state)
assert.deepEqual(docById(k.docId), vorher, vorher.state)
}
assert.equal(workorders(), tickets)
assert.equal(seit(mailsVorher).length, 0)
aw.seed('websiteProjects', { $id: 'pt_d7_leer2', customerId: 'c1', projectName: 'Ohne Dokument D7b' })
const docs = aw.docs('portalFeatures').size
const ohneDoc = await team('c1', 'termine', { projectId: 'pt_d7_leer2', action: 'ticket' })
assert.equal(ohneDoc.status, 409)
assert.equal(ohneDoc.body.error, 'Für dieses Projekt gibt es noch keinen Terminplaner')
assert.equal(aw.docs('portalFeatures').size, docs)
})
test('ticket: fremdes Projekt => 403, Kundensitzung statt JWT => 401/403, nichts geschrieben', async () => {
const k = legeEinrichtung({ ticket: 'geloescht', session: 'paid' })
const vorher = { ...docById(k.docId) }
const tickets = workorders()
assert.equal((await team('c1', 'termine', { projectId: 'pt3', action: 'ticket' })).status, 403)
const alsKunde = await nachlegen(k, 'termine', { cookie: customerCookie(k.customerId) })
assert.ok(alsKunde.status === 401 || alsKunde.status === 403, `Status ${alsKunde.status}`)
assert.deepEqual(docById(k.docId), vorher)
assert.equal(workorders(), tickets)
})
test('ticket bei News (a): derselbe Weg des Webhooks, K15 und T16 der News-Pinnwand', async () => {
const k = legeEinrichtung({ key: 'news', abo: null, einstellung: false, ticket: null, session: 'paid' })
const mailsVorher = mails.sent.length
const res = await nachlegen(k, 'news')
assert.equal(res.status, 200, JSON.stringify(res.body))
const doc = docById(k.docId)
assert.ok(doc.ticketId)
assert.equal(doc.state, 'setup')
const neu = seit(mailsVorher)
assert.equal(neu.filter((m) => m.subject === 'Neue Funktion bestellt: News-Pinnwand').length, 1)
assert.equal(neu.filter((m) => m.to.some((to) => to.address === k.email)).length, 1, 'K15 an den Kunden')
})
// ------------------------------------------- Team-Route Einbauanleitung (D8) ---
// GET /api/team/funktionen/:customerId/termine/anleitung?projectId=: die volle Anleitung (ohne `kurz`) fuer das WOMS-Panel, nur lesend,
// in jedem Zustand und bei jedem Modus. Dazu hindernis je setup-Dokument in der Kundenansicht des Teams (GET /:customerId).
const ANLEITUNG_IDS = [
'modell', 'leistungen', 'ressourcen', 'regeln', 'gastfelder', 'benachrichtigungen',
'einbau', 'snippet', 'css', 'datenschutz_sie', 'datenschutz_du', 'checkliste', 'abrechnung',
]
const ANLEITUNG_KEYS = ['abschnitte', 'hindernis', 'stand', 'state', 'text', 'ticketVersion', 'ticketWoid', 'warnungen'] // sortiert
const D8_BETRIEB = {
name: 'Salon Muster', email: 'inhaber@salon-muster.example', telefon: '0631 123456', adresse: 'Hauptstraße 1, 67655 Kaiserslautern',
kleinunternehmer: false, impressumUrl: 'https://salon-muster.example/impressum', datenschutzUrl: 'https://salon-muster.example/datenschutz', bundesland: 'RP',
}
/** Salon-Vorlage mit einer zweiten Person; beide Personen tragen einen feedToken (nie in die Antwort). */
function d8Einstellung() {
const e = ausVorlage('salon', D8_BETRIEB, new Date('2026-10-05T10:00:00Z'))
e.ressourcen.push({ id: 'r_ben00001', typ: 'person', name: 'Ben', aktiv: true, plan: { 2: [['08:00', '16:00']] }, feedToken: neuerFeedToken() })
return e
}
const TICKET_MIT_STAND = (version) =>
`Funktion: Terminplaner\nProjekt: Salon\nDomain: salon.example\nKunde: Salon\nsiteKey: abcdefghijklmnop\nKonfigurationsstand: Version ${version}, 03.10.2026\n\nSchritte:\n1. WOMS`
/**
* Funktionsdokument wie legeEinrichtung (Abo, Ticket), aber mit einer vollen Einstellung in termineConfig.
* einstellung: Objekt | false (kein termineConfig-Dokument) | string (roh gespeichert); ticketDetails ersetzt die Details eines vorhandenen Tickets.
*/
function legeAnleitung({ einstellung = d8Einstellung(), version = 1, ticketDetails, ...rest } = {}) {
const k = legeEinrichtung({ ...rest, einstellung: false })
if (einstellung !== false) {
aw.seed('termineConfig', {
$id: k.docId, customerId: k.customerId, projectId: k.projectId,
einstellung: typeof einstellung === 'string' ? einstellung : JSON.stringify(einstellung), version, updatedAt: '2026-10-03T08:15:00.000Z', updatedBy: 'test',
})
}
const ticket = ticketDoc(k.ticketId)
if (ticketDetails !== undefined && ticket) {
aw.seed('workorders', { $id: k.ticketId, woid: ticket.woid, status: ticket.status, topic: ticket.topic, details: ticketDetails })
}
return k
}
const anleitungPfad = (k, query = `projectId=${k.projectId}`) => `/api/team/funktionen/${k.customerId}/termine/anleitung${query ? `?${query}` : ''}`
const anleitung = (k, opts = STAFF, query) => rawGet(anleitungPfad(k, query), opts)
const warnungDrift = (ticketVersion, jetzt) => `Einstellungen seit der Bestellung geändert (Ticket Version ${ticketVersion}, jetzt ${jetzt}).`
/** Fuehrt fn aus und zaehlt die schreibenden Anfragen (alles ausser GET) an den Appwrite-Nachbau. */
async function zaehleSchreibzugriffe(fn) {
const echt = globalThis.fetch
const schreibend = []
globalThis.fetch = async (url, optionen) => {
const methode = optionen?.method ?? 'GET'
if (methode !== 'GET' && String(url).startsWith(aw.url)) schreibend.push(`${methode} ${String(url).replace(/\?.*/, '')}`)
return echt(url, optionen)
}
try {
return { ergebnis: await fn(), schreibend }
} finally {
globalThis.fetch = echt
}
}
const modusMerken = (t) => {
const vorher = { mode: config.termine.mode, pilotEmails: [...config.termine.pilotEmails] }
t.after(() => termineMit(vorher.mode, vorher.pilotEmails))
}
test('Anleitung: ohne JWT 401, mit Kundensitzung statt JWT 401/403, die Route gibt es im Kundenrouter nicht (404)', async () => {
const k = legeAnleitung()
const ohne = await anleitung(k, {})
assert.equal(ohne.status, 401)
const alsKunde = await anleitung(k, { cookie: customerCookie(k.customerId) })
assert.ok(alsKunde.status === 401 || alsKunde.status === 403, `Status ${alsKunde.status}`)
assert.ok(!JSON.stringify(alsKunde.body).includes('abschnitte'), 'keine Anleitung fuer den Kunden')
// kein Pfad unter /api/funktionen (Kundenrouter), auch nicht mit gueltiger Kundensitzung
const kundenpfad = await rawGet(`/api/funktionen/termine/anleitung?projectId=${k.projectId}`, { cookie: customerCookie(k.customerId) })
assert.equal(kundenpfad.status, 404)
assert.equal((await rawGet('/api/funktionen/termine/anleitung', { cookie: customerCookie(k.customerId) })).status, 404)
})
test('Anleitung: projectId fehlt oder ist leer oder doppelt => 400; fremdes oder unbekanntes Projekt => 403; ohne Dokument => 409 ohne Dokument anzulegen', async () => {
const k = legeAnleitung()
for (const query of ['', 'projectId=', 'projectId=a&projectId=b', 'projekt=x']) {
const res = await anleitung(k, STAFF, query)
assert.equal(res.status, 400, query)
assert.equal(typeof res.body.error, 'string', query)
}
// pt3 gehoert c2, nicht dem Kunden in der URL
assert.equal((await anleitung(k, STAFF, 'projectId=pt3')).status, 403)
assert.equal((await rawGet(`/api/team/funktionen/c1/termine/anleitung?projectId=pt3`, STAFF)).status, 403, 'Projekt von c2 fuer c1')
assert.equal((await anleitung(k, STAFF, 'projectId=gibtsnicht')).status, 403)
const docs = aw.docs('portalFeatures').size
aw.seed('websiteProjects', { $id: 'pt_d8_leer', customerId: 'c1', projectName: 'Ohne Dokument D8' })
const ohneDoc = await rawGet('/api/team/funktionen/c1/termine/anleitung?projectId=pt_d8_leer', STAFF)
assert.equal(ohneDoc.status, 409)
assert.equal(ohneDoc.body.error, 'Für dieses Projekt gibt es noch keinen Terminplaner')
assert.equal(ohneDoc.body.error, `Für dieses Projekt gibt es noch ${sf.FEATURE_LABELS.termine.keine}`)
assert.equal(aw.docs('portalFeatures').size, docs, 'kein Dokument entstanden')
// ein Dokument der News-Pinnwand ist kein Terminplaner
const news = legeEinrichtung({ key: 'news', einstellung: false })
assert.equal((await rawGet(`/api/team/funktionen/${news.customerId}/termine/anleitung?projectId=${news.projectId}`, STAFF)).status, 409)
})
test('Anleitung: 200 in jedem Zustand und bei Modus off, nur lesend, ohne Kundenansicht-Protokoll, mit state und ohne kurz', async (t) => {
modusMerken(t)
termineMit('off')
const faelle = {
off: { state: 'off', ticket: null, over: { unlockedByPurchase: false, purchaseStatus: '' } },
ordered: { state: 'ordered', ticket: null, over: { unlockedByPurchase: false, purchaseStatus: 'ordered' } },
setup: { state: 'setup' },
active: { state: 'active' },
disabled: { state: 'disabled', over: { disabledReason: 'abo_ende', disabledAt: '2026-10-01T10:00:00.000Z' } },
}
const protokoll = aw.docs('customerViewLog').size
for (const [state, optionen] of Object.entries(faelle)) {
const k = legeAnleitung(optionen)
const { ergebnis: res, schreibend } = await zaehleSchreibzugriffe(() => anleitung(k))
assert.equal(res.status, 200, `${state}: ${JSON.stringify(res.body)}`)
assert.equal(res.body.state, state)
assert.deepEqual(Object.keys(res.body).sort(), ANLEITUNG_KEYS, state)
assert.ok(!('kurz' in res.body), `${state}: kurz bleibt im Ticket`)
assert.deepEqual(schreibend, [], `${state}: die Route schreibt nichts`)
}
assert.equal(aw.docs('customerViewLog').size, protokoll, 'kein Eintrag im Protokoll der Kundenansicht')
})
test('Anleitung: die Antwort enthaelt keinen feedToken der Einstellung', async () => {
const einstellung = d8Einstellung()
const tokens = einstellung.ressourcen.map((r) => r.feedToken)
assert.equal(tokens.length, 2)
assert.ok(tokens.every((tk) => typeof tk === 'string' && tk.length >= 20), 'Ausgangslage: echte Token')
const k = legeAnleitung({ einstellung })
const res = await anleitung(k)
assert.equal(res.status, 200)
const json = JSON.stringify(res.body)
for (const tk of tokens) assert.ok(!json.includes(tk), 'feedToken in der Antwort')
assert.ok(!json.includes('feedToken'))
assert.ok(!('kurz' in res.body))
assert.ok(!json.includes('Konfigurationsstand:'), 'die Kurzfassung des Tickets (mit ihrer Standzeile) gehoert nicht in die Antwort')
})
test('Anleitung mit Einstellung: 13 Abschnitte wie der Generator, stand.version 1, Volltext, Warnungen als Liste', async () => {
const k = legeAnleitung()
const res = await anleitung(k)
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.deepEqual(res.body.abschnitte.map((a) => a.id), ANLEITUNG_IDS)
for (const a of res.body.abschnitte) assert.deepEqual(Object.keys(a).sort(), ['id', 'kopierbar', 'text', 'titel'], a.id)
assert.deepEqual(res.body.abschnitte.filter((a) => a.kopierbar).map((a) => a.id), ['snippet', 'css', 'datenschutz_sie', 'datenschutz_du'])
assert.deepEqual(res.body.stand, { version: 1, updatedAt: '2026-10-03T08:15:00.000Z' })
assert.equal(typeof res.body.text, 'string')
assert.ok(res.body.text.includes(res.body.abschnitte[0].titel))
assert.ok(Array.isArray(res.body.warnungen) && res.body.warnungen.every((w) => typeof w === 'string'))
const snippet = res.body.abschnitte.find((a) => a.id === 'snippet').text
assert.ok(snippet.includes('data-webklar-termine="abcdefghijklmnop"'), 'siteKey des Dokuments im Einbau-Code')
// eine hoehere Version der Einstellung kommt an
const v4 = legeAnleitung({ version: 4 })
assert.equal((await anleitung(v4)).body.stand.version, 4)
})
test('Anleitung ohne gespeicherte Einstellung: nur der Einbau-Code und die Warnung, stand.version 0; unlesbare Einstellung ebenso mit der Version des Dokuments', async (t) => {
t.mock.method(console, 'error', () => {})
const k = legeAnleitung({ einstellung: false })
const res = await anleitung(k)
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.deepEqual(res.body.abschnitte.map((a) => a.id), ['snippet'])
assert.ok(res.body.warnungen.includes('Keine Einstellungen gespeichert'), res.body.warnungen.join(' | '))
assert.deepEqual(res.body.stand, { version: 0, updatedAt: null })
assert.ok(res.body.abschnitte[0].text.includes('abcdefghijklmnop'))
// kaputtes JSON im Dokument: wie "keine Einstellung", die Version bleibt sichtbar
const kaputt = legeAnleitung({ einstellung: '{kaputt', version: 4 })
const r2 = await anleitung(kaputt)
assert.equal(r2.status, 200, JSON.stringify(r2.body))
assert.deepEqual(r2.body.abschnitte.map((a) => a.id), ['snippet'])
assert.ok(r2.body.warnungen.includes('Keine Einstellungen gespeichert'))
assert.equal(r2.body.stand.version, 4)
})
test('Anleitung, Drift: Ticket auf Version 1, Einstellung jetzt Version 3 => Warnung am Ende von warnungen, ticketVersion und ticketWoid', async () => {
// eine andere Warnung muss davor stehen (ohne Entwurfs-Banner sonst keine): fehlende Impressum-URL
const einstellung = d8Einstellung()
einstellung.betrieb.impressumUrl = ''
const k = legeAnleitung({ einstellung, version: 3, ticketDetails: TICKET_MIT_STAND(1) })
const res = await anleitung(k)
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.equal(warnungDrift(1, 3), 'Einstellungen seit der Bestellung geändert (Ticket Version 1, jetzt 3).')
assert.equal(res.body.warnungen.at(-1), warnungDrift(1, 3))
assert.ok(res.body.warnungen.length > 1 && /Impressum/.test(res.body.warnungen[0]), `sie steht hinter den uebrigen Warnungen: ${res.body.warnungen.join(' | ')}`)
assert.equal(res.body.warnungen.filter((w) => w.startsWith('Einstellungen seit der Bestellung')).length, 1)
assert.equal(res.body.ticketVersion, 1)
assert.equal(res.body.ticketWoid, ticketDoc(k.ticketId).woid)
assert.deepEqual(res.body.stand.version, 3)
})
test('Anleitung, Drift: gleiche oder neuere Version im Ticket, Ticket ohne Standzeile, Ticket geloescht, Funktion ohne ticketId => keine Warnung und kein Fehler', async () => {
const faelle = {
'gleiche Version': { opts: { version: 3, ticketDetails: TICKET_MIT_STAND(3) }, ticketVersion: 3, hatTicket: true },
'Ticket neuer als die Einstellung': { opts: { version: 3, ticketDetails: TICKET_MIT_STAND(5) }, ticketVersion: 5, hatTicket: true },
'Ticket ohne Standzeile': { opts: { version: 3 }, ticketVersion: null, hatTicket: true },
'Ticket geloescht': { opts: { version: 3, ticket: 'geloescht' }, ticketVersion: null, hatTicket: false },
'ohne ticketId': { opts: { version: 3, ticket: null }, ticketVersion: null, hatTicket: false },
}
for (const [name, { opts, ticketVersion, hatTicket }] of Object.entries(faelle)) {
const k = legeAnleitung(opts)
const res = await anleitung(k)
assert.equal(res.status, 200, `${name}: ${JSON.stringify(res.body)}`)
assert.ok(!res.body.warnungen.some((w) => /seit der Bestellung/.test(w)), `${name}: ${res.body.warnungen.join(' | ')}`)
assert.equal(res.body.ticketVersion, ticketVersion, name)
assert.equal(res.body.ticketWoid, hatTicket ? ticketDoc(k.ticketId).woid : null, name)
}
// Ticket aus der Zeit ohne Einstellung (Version 0), inzwischen gespeichert (Version 1)
const nullStand = legeAnleitung({ version: 1, ticketDetails: 'Funktion: Terminplaner\nKonfigurationsstand: Version 0 (noch keine Einstellungen gespeichert)' })
const r0 = await anleitung(nullStand)
assert.equal(r0.body.ticketVersion, 0)
assert.equal(r0.body.warnungen.at(-1), warnungDrift(0, 1))
})
test('Anleitung, Drift: ein nicht lesbares Ticket (5xx) ist ein Fehler, keine stille Luecke; ein Aufruf spaeter geht', async (t) => {
t.mock.method(console, 'error', () => {})
const k = legeAnleitung({ version: 3, ticketDetails: TICKET_MIT_STAND(1) })
aw.failNext({ collection: 'workorders', method: 'GET', status: 500, message: 'Nachbau: Ticket kaputt' })
const aus = await anleitung(k)
assert.ok(aus.status >= 500 && aus.status < 600, `Status ${aus.status}`)
assert.equal(typeof aus.body.error, 'string')
assert.equal((await anleitung(k)).status, 200)
})
test('Anleitung, hindernis: nur in setup - ohne Abo kein_abo, mit Abo und Einstellung null, ohne Einstellung keine_einstellung; sonst null und das Abo bleibt ungelesen', async () => {
const setupOhneAbo = legeAnleitung({ abo: null })
assert.equal((await anleitung(setupOhneAbo)).body.hindernis, 'kein_abo')
assert.equal((await anleitung(legeAnleitung({ abo: 'hosting' }))).body.hindernis, 'kein_abo', 'Hosting ist kein Wartungs-Abo')
const beendet = legeAnleitung({ abo: 'wartung' })
aw.seed('subscriptions', subscriptionDoc(`sub_cd${d7Nr}`, { ...wartungDoc, customerId: beendet.customerId, status: 'canceled', endedAt: '2026-10-01T10:00:00.000Z' }))
assert.equal((await anleitung(beendet)).body.hindernis, 'kein_abo', 'beendetes Abo')
const frei = await anleitung(legeAnleitung())
assert.equal(frei.body.hindernis, null)
assert.equal(frei.body.state, 'setup')
assert.equal((await anleitung(legeAnleitung({ einstellung: false }))).body.hindernis, 'keine_einstellung')
// eine vorgemerkte Kuendigung und past_due laufen noch
const gekuendigt = legeAnleitung()
aw.seed('subscriptions', subscriptionDoc(`sub_cd${d7Nr}`, { ...wartungDoc, customerId: gekuendigt.customerId, cancelAtPeriodEnd: true }))
assert.equal((await anleitung(gekuendigt)).body.hindernis, null)
// active, disabled, ordered und off: null, auch ohne Abo, und das Abo wird gar nicht erst gelesen
const vorher = aw.listCalls('subscriptions').length
for (const [state, optionen] of Object.entries({
active: { state: 'active', abo: null },
disabled: { state: 'disabled', abo: null, over: { disabledReason: 'abo_ende', disabledAt: '2026-10-01T10:00:00.000Z' } },
ordered: { state: 'ordered', abo: null, ticket: null, over: { unlockedByPurchase: false, purchaseStatus: 'ordered' } },
off: { state: 'off', abo: null, ticket: null, over: { unlockedByPurchase: false, purchaseStatus: '' } },
})) {
const res = await anleitung(legeAnleitung(optionen))
assert.equal(res.status, 200, state)
assert.equal(res.body.hindernis, null, state)
}
assert.equal(aw.listCalls('subscriptions').length, vorher, 'ausserhalb von setup keine Abo-Abfrage')
})
test('Anleitung, hindernis: Abo nicht lesbar => 200 mit abo_unbekannt (geloggt), nie ein stilles null', async (t) => {
const log = t.mock.method(console, 'error', () => {})
const k = legeAnleitung()
aw.failNext({ collection: 'subscriptions', method: 'GET', status: 500, message: 'Nachbau: Abo kaputt' })
const res = await anleitung(k)
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.equal(res.body.hindernis, 'abo_unbekannt')
assert.ok(log.mock.calls.length >= 1, 'der Fehler steht im Log')
assert.equal((await anleitung(k)).body.hindernis, null, 'der naechste Aufruf liest wieder')
})
test('Anleitung, Abrechnung: der Rechnungs-Link erscheint, sobald invoiceUrl im Dokument steht (auch bei altem Ticket); Preis aus dem Register', async () => {
const k = legeAnleitung({ ticketDetails: TICKET_MIT_STAND(1) })
const abrechnung = async () => (await anleitung(k)).body.abschnitte.find((a) => a.id === 'abrechnung').text
const davor = await abrechnung()
assert.ok(davor.includes('Rechnung folgt im Portal'), davor)
assert.ok(davor.includes(config.features.termine.priceLabel), davor)
const ticketVorher = { ...ticketDoc(k.ticketId) }
// backfillInvoiceLinks traegt den Link spaeter nach: die Anleitung zeigt ihn sofort, das Ticket bleibt, wie es war
await updateDocument(config.collections.portalFeatures, k.docId, { invoiceUrl: 'https://invoice.stripe.test/in_d8' })
const danach = await abrechnung()
assert.ok(danach.includes('https://invoice.stripe.test/in_d8'), danach)
assert.ok(!danach.includes('folgt im Portal'), danach)
assert.deepEqual(ticketDoc(k.ticketId), ticketVorher, 'das Ticket wird nicht angefasst')
})
// ---- Teamansicht GET /:customerId: hindernis je setup-Dokument
test('Teamansicht: hindernis je Terminplaner in setup, null in jedem anderen Zustand und bei News; die Funktionsdokumente werden einmal gelesen', async (t) => {
const kunde = legeAnleitung() // Abo und Einstellung vorhanden
// zweites Projekt desselben Kunden in setup, ohne Einstellung
aw.seed('websiteProjects', { $id: 'cdp_zwei', customerId: kunde.customerId, projectName: 'Zweites Projekt' })
aw.seed('portalFeatures', { $id: 'f_cdp_zwei_termine', customerId: kunde.customerId, projectId: 'cdp_zwei', featureKey: 'termine', siteKey: 'bcdefghijklmnopq', state: 'setup', enabled: false, unlockedByPurchase: true, purchaseStatus: 'paid' })
// drittes Projekt: aktiv, viertes: News in setup
aw.seed('websiteProjects', { $id: 'cdp_drei', customerId: kunde.customerId, projectName: 'Drittes Projekt' })
aw.seed('portalFeatures', { $id: 'f_cdp_drei_termine', customerId: kunde.customerId, projectId: 'cdp_drei', featureKey: 'termine', siteKey: 'cdefghijklmnopqr', state: 'active', enabled: true })
aw.seed('portalFeatures', { $id: 'f_cdp_drei_news', customerId: kunde.customerId, projectId: 'cdp_drei', featureKey: 'news', siteKey: 'defghijklmnopqrs', state: 'setup', enabled: false, unlockedByPurchase: true, purchaseStatus: 'paid' })
const echt = globalThis.fetch
const abfragen = []
globalThis.fetch = async (url, optionen) => {
if (/\/collections\/portalFeatures\/documents/.test(String(url))) abfragen.push(`${optionen?.method ?? 'GET'} ${String(url).replace(/\?.*/, '')}`)
return echt(url, optionen)
}
t.after(() => (globalThis.fetch = echt))
const abosVorher = aw.listCalls('subscriptions').length
const res = await get(`/api/team/funktionen/${kunde.customerId}`, STAFF)
globalThis.fetch = echt
assert.equal(abfragen.length, 1, abfragen.join(' | '))
const features = (projectId) => res.projects.find((p) => p.projectId === projectId).features
assert.equal(features(kunde.projectId).termine.hindernis, null, 'setup mit Abo und Einstellung')
assert.equal(features('cdp_zwei').termine.hindernis, 'keine_einstellung', 'setup ohne Einstellung')
assert.equal(features('cdp_drei').termine.hindernis, null, 'active')
assert.equal(features('cdp_drei').news.hindernis, null, 'News in setup hat keine Freischalt-Hindernisse')
assert.equal(features(kunde.projectId).news.hindernis, null, 'kein Dokument')
for (const p of res.projects) for (const view of Object.values(p.features)) assert.ok('hindernis' in view, `${p.projectId}: Feld hindernis fehlt`)
assert.equal(aw.listCalls('subscriptions').length - abosVorher, 1, 'zwei setup-Dokumente, ein Abo-Lauf (aboMemo je Anfrage)')
// ohne Abo: kein_abo, und die Ansicht der uebrigen Felder bleibt wie bisher
const ohneAbo = legeAnleitung({ abo: null })
const r2 = await get(`/api/team/funktionen/${ohneAbo.customerId}`, STAFF)
const view = r2.projects.find((p) => p.projectId === ohneAbo.projectId).features.termine
assert.equal(view.hindernis, 'kein_abo')
assert.equal(view.state, 'setup')
assert.equal(view.ticketMissing, false)
})
// ------------------------------------------------------------- Staff-Aktivierung mit Zustimmung in Textform (TP2 F13a) ---
const { zustimmungFuerSitzung, leseZustimmung } = await import('../server/services/featureConsents.js')
const { setLegalDirForTests } = await import('../server/services/legalDocs.js')
const berlinTagF13 = (ms = Date.now()) => new Intl.DateTimeFormat('sv-SE', { timeZone: 'Europe/Berlin' }).format(ms)
const tagMitVersatzF13 = (tag, tage) => new Date(Date.parse(`${tag}T12:00:00Z`) + tage * 86400000).toISOString().slice(0, 10)
const ttmmjjjjF13 = (tag) => tag.split('-').reverse().join('.')
const consentsF13 = () => [...aw.docs('featureConsents').values()]
const consentsDesF13 = (projectId) => consentsF13().filter((c) => c.projectId === projectId)
let f13Nr = 0
/** Kunde mit eigenem Projekt (ohne Funktionsdokument); E-Mail frei waehlbar, das Wartungs-Abo laeuft. */
function legeKundeF13({ email, abo = true } = {}) {
f13Nr += 1
const customerId = `c_f13_${f13Nr}`
const projectId = `p_f13_${f13Nr}`
aw.seed('customers', { $id: customerId, email: email ?? `f13-${f13Nr}@example.invalid`, name: `Kunde F13 ${f13Nr}`, status: 'active', portalAccessEnabled: true })
aw.seed('websiteProjects', { $id: projectId, customerId, projectName: `Projekt F13 ${f13Nr}` })
if (abo) aw.seed('subscriptions', subscriptionDoc(`sub_f13_${f13Nr}`, { ...wartungDoc, customerId }))
return { customerId, projectId, email: aw.docs('customers').get(customerId).email }
}
const aktivierenF13 = (k, body = {}, opts = STAFF) => team(k.customerId, 'termine', { projectId: k.projectId, action: 'activate', ...body }, opts)
const feldF13 = (k, over = {}) => ({ textformDate: berlinTagF13(), textformChannel: 'chat', ...over })
const k16AnF13 = (email) => mails.sent.filter((m) => m.subject === 'Dein Terminplaner ist bereit' && m.to.some((t) => t.address === email))
test('textform_pflicht_modus_on: ohne Angaben 400, mit gültigen Angaben 200, Nachweis mit Datum, Weg, Fassung, Mitarbeiter und leerer Sitzung, K16 mit LB und AVV als PDF', async (t) => {
modusMerken(t)
termineMit('on', ['pilot@example.invalid'])
const k = legeKundeF13()
const v = config.termine.avvVersion
const mailsVorher = mails.sent.length
const ohne = await aktivierenF13(k)
assert.equal(ohne.status, 400)
assert.match(ohne.body.error, /Datum der Zustimmung/)
assert.equal(await sf.getFeatureDoc(k.customerId, k.projectId, 'termine'), null, 'nichts angelegt')
assert.equal(consentsDesF13(k.projectId).length, 0)
assert.equal(mails.sent.length, mailsVorher)
// nur Datum ohne Weg
const nurDatum = await aktivierenF13(k, { textformDate: berlinTagF13() })
assert.equal(nurDatum.status, 400)
assert.match(nurDatum.body.error, /Weg der Zustimmung/)
const tag = berlinTagF13()
const ok = await aktivierenF13(k, { textformDate: tag, textformChannel: 'email' })
assert.equal(ok.status, 200, JSON.stringify(ok.body))
assert.equal(featuresOf(ok, k.projectId).termine.state, 'active')
const doc = await sf.getFeatureDoc(k.customerId, k.projectId, 'termine')
assert.deepEqual([doc.state, doc.enabled, doc.unlockedByPurchase], ['active', true, false], 'ohne Bestellung freigeschaltet')
const [nachweis] = consentsDesF13(k.projectId)
assert.equal(consentsDesF13(k.projectId).length, 1)
assert.deepEqual([nachweis.customerId, nachweis.projectId, nachweis.featureKey], [k.customerId, k.projectId, 'termine'])
assert.equal(nachweis.stripeSessionId, '', 'keine Stripe-Sitzung')
assert.equal(nachweis.ip, '')
assert.equal(nachweis.userAgent, '')
assert.equal(nachweis.konto, 'Mitarbeiter: Andrej')
assert.ok(Math.abs(Date.parse(nachweis.acceptedAt) - Date.now()) < 10000, 'Zeitpunkt der Erfassung')
const gelesen = leseZustimmung(nachweis)
assert.deepEqual(gelesen.fassungen, { leistungsbeschreibung: v, avv: v })
assert.deepEqual(gelesen.texte, [{
id: 'textform',
text: `Der Kunde hat der Leistungsbeschreibung Terminplaner und dem Auftragsverarbeitungsvertrag Terminplaner (Fassung ${v}) am ${ttmmjjjjF13(tag)} per E-Mail in Textform zugestimmt und bestätigt, dass sein Angebot nicht im Gesundheitsbereich liegt.`,
}])
assert.match(nachweis.textHash, /^[0-9a-f]{64}$/)
// der Weg Chat heißt „Chat im Kundenportal“
const chat = legeKundeF13()
assert.equal((await aktivierenF13(chat, { textformDate: tag, textformChannel: 'chat' })).status, 200)
assert.ok(leseZustimmung(consentsDesF13(chat.projectId)[0]).texte[0].text.includes(`am ${ttmmjjjjF13(tag)} per Chat im Kundenportal in Textform`))
// K16 an den Kunden mit genau den beiden PDFs (Leistungsbeschreibung, AVV), keine Widerrufsbelehrung
const k16 = k16AnF13(k.email)
assert.equal(k16.length, 1, mails.sent.map((m) => m.subject).join(' / '))
assert.deepEqual(k16[0].attachments.map((a) => a.filename), [`Leistungsbeschreibung-Terminplaner-${v}.pdf`, `AVV-Terminplaner-${v}.pdf`])
for (const a of k16[0].attachments) {
assert.equal(a.contentType, 'application/pdf')
assert.equal(Buffer.from(a.content, 'base64').subarray(0, 4).toString(), '%PDF')
}
assert.equal(mails.sent.length, mailsVorher + 2, 'je eine K16 für den Kunden und den Chat-Kunden, sonst nichts')
})
const K16_NICHT_GESENDET = 'K16 mit Unterlagen NICHT gesendet – von Hand nachsenden'
test('textform A2: scheitert der Versand von K16 mit den Unterlagen, bleibt die Funktion freigeschaltet, adminNote und warnung:true sagen es; Erfolg und Kunde ohne Adresse', async (t) => {
modusMerken(t)
termineMit('on', [])
t.mock.method(console, 'error', () => {})
// Erfolg: keine Warnung, keine Notiz
const gut = legeKundeF13()
const ok = await aktivierenF13(gut, feldF13(gut))
assert.equal(ok.status, 200, JSON.stringify(ok.body))
assert.equal(ok.body.warnung, undefined, 'ohne Fehler kein Feld warnung')
assert.doesNotMatch((await sf.getFeatureDoc(gut.customerId, gut.projectId, 'termine')).adminNote, /NICHT gesendet/)
// Versandfehler: Freischaltung und Nachweis stehen, die Warnung kommt zurueck
const k = legeKundeF13()
setMailTransportForTests({ sendMail: async () => { throw new Error('SMTP weg') } })
t.after(() => setMailTransportForTests(mails))
const res = await aktivierenF13(k, feldF13(k))
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.equal(res.body.warnung, true)
const doc = await sf.getFeatureDoc(k.customerId, k.projectId, 'termine')
assert.equal(doc.state, 'active', 'die Freischaltung bleibt')
assert.equal(consentsDesF13(k.projectId).length, 1, 'der Nachweis bleibt')
assert.ok(doc.adminNote.includes(K16_NICHT_GESENDET), doc.adminNote)
assert.match(doc.adminNote, /^\d{4}-\d\d-\d\dT[\d:.]+Z System: K16 mit Unterlagen NICHT gesendet – von Hand nachsenden \| .*Andrej: activate/)
assert.equal(featuresOf(res, k.projectId).termine.adminNote, doc.adminNote, 'das Ticketsystem liest die Notiz mit der Antwort')
// Kunde ohne E-Mail-Adresse: K16 kann nicht raus, auch das ist eine Warnung
setMailTransportForTests(mails)
const leer = legeKundeF13({ email: '' })
aw.docs('customers').get(leer.customerId).email = ''
const ohneAdresse = await aktivierenF13(leer, feldF13(leer))
assert.equal(ohneAdresse.status, 200, JSON.stringify(ohneAdresse.body))
assert.equal(ohneAdresse.body.warnung, true)
assert.ok((await sf.getFeatureDoc(leer.customerId, leer.projectId, 'termine')).adminNote.includes(K16_NICHT_GESENDET))
})
test('textform_entfaellt: Modus off und pilot, Modus on mit Pilotadresse, News und Abschalten brauchen keine Angaben und schreiben keinen Nachweis', async (t) => {
modusMerken(t)
const mailsVorher = mails.sent.length
for (const [mode, pilot] of [['off', []], ['pilot', ['*']], ['pilot', []]]) {
termineMit(mode, pilot)
const k = legeKundeF13()
const res = await aktivierenF13(k)
assert.equal(res.status, 200, `${mode}: ${JSON.stringify(res.body)}`)
assert.equal(featuresOf(res, k.projectId).termine.state, 'active')
assert.equal(consentsDesF13(k.projectId).length, 0, mode)
}
// Modus on, aber die Adresse steht in der Pilotliste
const pilotKunde = legeKundeF13({ email: 'Pilot-F13@example.invalid' })
termineMit('on', ['pilot-f13@example.invalid'])
assert.equal((await aktivierenF13(pilotKunde)).status, 200)
assert.equal(consentsDesF13(pilotKunde.projectId).length, 0)
// Modus on, andere Adresse: Pflicht; News und Abschalten bleiben frei
const k = legeKundeF13()
assert.equal((await aktivierenF13(k)).status, 400)
const news = await team(k.customerId, 'news', { projectId: k.projectId, action: 'activate' })
assert.equal(news.status, 200)
assert.equal(featuresOf(news, k.projectId).news.state, 'active')
assert.equal((await team(k.customerId, 'news', { projectId: k.projectId, action: 'disable' })).status, 200)
assert.equal(await sf.getFeatureDoc(k.customerId, k.projectId, 'termine'), null)
const aktiv = legeKundeF13()
termineMit('off')
assert.equal((await aktivierenF13(aktiv)).status, 200)
termineMit('on', [])
const aus = await team(aktiv.customerId, 'termine', { projectId: aktiv.projectId, action: 'disable' })
assert.equal(aus.status, 200, 'Abschalten braucht keine Angaben')
assert.equal(featuresOf(aus, aktiv.projectId).termine.state, 'disabled')
assert.equal(consentsF13().filter((c) => [k.projectId, aktiv.projectId, pilotKunde.projectId].includes(c.projectId)).length, 0)
assert.equal(k16AnF13('k@example.invalid').length, 0)
assert.equal(mails.sent.slice(mailsVorher).filter((m) => m.subject === 'Dein Terminplaner ist bereit').length, 0, 'ohne Textform keine K16 (wie bisher)')
})
test('textform_entfaellt: ein bezahlter oder schon aktiver Terminplaner braucht keine neue Zustimmung in Textform (der Nachweis liegt schon vor)', async (t) => {
modusMerken(t)
termineMit('on', [])
// bezahlt (Zustimmung beim Checkout), Zustand setup, Wartungs-Abo läuft
const bezahlt = legeKundeF13()
aw.seed('portalFeatures', { $id: `f_${bezahlt.projectId}_termine`, customerId: bezahlt.customerId, projectId: bezahlt.projectId, featureKey: 'termine', siteKey: 'qrstuvwxyzabcdef', state: 'setup', enabled: false, unlockedByPurchase: true, purchaseStatus: 'paid' })
const res = await aktivierenF13(bezahlt)
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.equal(featuresOf(res, bezahlt.projectId).termine.state, 'active')
assert.equal(consentsDesF13(bezahlt.projectId).length, 0)
// schon aktiv (Doppelklick im WOMS): kein zweiter Nachweis, keine zweite Mail
const k = legeKundeF13()
termineMit('off')
assert.equal((await aktivierenF13(k)).status, 200)
termineMit('on', [])
const vorher = mails.sent.length
assert.equal((await aktivierenF13(k)).status, 200)
assert.equal(consentsDesF13(k.projectId).length, 0)
assert.equal(mails.sent.length, vorher)
})
test('textform_datum: Datum vor der Fassung, in der Zukunft, ungültig oder fehlend und ungültiger Weg ergeben 400 ohne Nachweis, ohne Dokument, ohne Mail', async (t) => {
modusMerken(t)
termineMit('on', [])
const k = legeKundeF13()
const v = config.termine.avvVersion
const heute = berlinTagF13()
const mailsVorher = mails.sent.length
const faelle = [
[{ textformDate: tagMitVersatzF13(v, -1), textformChannel: 'chat' }, /liegt vor der Fassung/],
[{ textformDate: tagMitVersatzF13(heute, 1), textformChannel: 'chat' }, /in der Zukunft/],
[{ textformDate: '2026-02-30', textformChannel: 'chat' }, /fehlt oder ist ungültig/],
[{ textformDate: '05.10.2026', textformChannel: 'chat' }, /fehlt oder ist ungültig/],
[{ textformDate: 20261005, textformChannel: 'chat' }, /fehlt oder ist ungültig/],
[{ textformDate: heute }, /Weg der Zustimmung/],
[{ textformDate: heute, textformChannel: 'telefon' }, /Weg der Zustimmung/],
[{ textformDate: heute, textformChannel: '' }, /Weg der Zustimmung/],
[{ textformDate: heute, textformChannel: ['chat'] }, /Weg der Zustimmung/],
]
for (const [body, meldung] of faelle) {
const res = await aktivierenF13(k, body)
assert.equal(res.status, 400, JSON.stringify(body))
assert.match(res.body.error, meldung, JSON.stringify(body))
}
assert.equal(await sf.getFeatureDoc(k.customerId, k.projectId, 'termine'), null, 'nichts angelegt')
assert.equal(consentsDesF13(k.projectId).length, 0)
assert.equal(mails.sent.length, mailsVorher)
// genau am Tag der Fassung und heute ist erlaubt
assert.equal((await aktivierenF13(k, { textformDate: v, textformChannel: 'email' })).status, 200)
assert.equal(consentsDesF13(k.projectId).length, 1)
})
test('textform_pflicht_modus_on: fremdes Projekt, Kundensitzung und nicht lesbare Rechtsunterlagen ändern nichts (die Berechtigung des Team-Wegs bleibt)', async (t) => {
modusMerken(t)
termineMit('on', [])
const k = legeKundeF13()
const fremd = legeKundeF13()
// Projekt eines anderen Kunden: 403 vor jeder Prüfung der Zustimmung
const res = await team(k.customerId, 'termine', { projectId: fremd.projectId, action: 'activate', ...feldF13(k) })
assert.equal(res.status, 403)
const alsKunde = await aktivierenF13(k, feldF13(k), { cookie: customerCookie(k.customerId) })
assert.ok(alsKunde.status === 401 || alsKunde.status === 403)
assert.equal(consentsF13().filter((c) => [k.projectId, fremd.projectId].includes(c.projectId)).length, 0)
assert.equal(await sf.getFeatureDoc(k.customerId, k.projectId, 'termine'), null)
// PDFs nicht lesbar: nichts wird freigeschaltet, nichts gespeichert, keine Mail
const leer = await (await import('node:fs/promises')).mkdtemp((await import('node:path')).join((await import('node:os')).tmpdir(), 'f13-leer-'))
setLegalDirForTests(leer)
t.after(() => setLegalDirForTests(null))
const mailsVorher = mails.sent.length
const fehler = await aktivierenF13(k, feldF13(k))
assert.ok(fehler.status >= 500, `Status ${fehler.status}`)
assert.match(fehler.body.error, /Rechtsunterlagen/)
assert.equal(await sf.getFeatureDoc(k.customerId, k.projectId, 'termine'), null)
assert.equal(consentsDesF13(k.projectId).length, 0)
assert.equal(mails.sent.length, mailsVorher)
setLegalDirForTests(null)
assert.equal((await aktivierenF13(k, feldF13(k))).status, 200)
})
test('textform_pflicht_modus_on: eine leere oder keine PDF-Datei als Unterlage ergibt 503 vor jeder Änderung (R121), danach mit gültigen Dateien 200', async (t) => {
modusMerken(t)
termineMit('on', [])
const fs = await import('node:fs')
const os = await import('node:os')
const path = await import('node:path')
const { legalPdfFiles } = await import('../server/services/legalDocs.js')
const echt = legalPdfFiles('termine', config.termine.avvVersion)
const kopien = fs.mkdtempSync(path.join(os.tmpdir(), 'f13-pdf-'))
fs.mkdirSync(path.join(kopien, 'rechtliches'))
const kopiere = () => {
for (const f of Object.values(echt)) fs.copyFileSync(f.path, path.join(kopien, 'rechtliches', path.basename(f.path)))
}
t.after(() => {
setLegalDirForTests(null)
fs.rmSync(kopien, { recursive: true, force: true })
})
setLegalDirForTests(kopien)
const k = legeKundeF13()
const mailsVorher = mails.sent.length
for (const [doc, inhalt] of [['lb', ''], ['avv', '<html>kein PDF</html>'], ['lb', '%PDF']]) {
kopiere()
fs.writeFileSync(path.join(kopien, 'rechtliches', path.basename(echt[doc].path)), inhalt)
const res = await aktivierenF13(k, feldF13(k))
assert.equal(res.status, 503, `${doc}: ${JSON.stringify(inhalt)}`)
assert.match(res.body.error, /Rechtsunterlagen/)
assert.equal(await sf.getFeatureDoc(k.customerId, k.projectId, 'termine'), null, 'nichts angelegt')
assert.equal(consentsDesF13(k.projectId).length, 0, 'kein Nachweis')
assert.equal(mails.sent.length, mailsVorher)
}
kopiere()
const ok = await aktivierenF13(k, feldF13(k))
assert.equal(ok.status, 200, JSON.stringify(ok.body))
assert.equal(consentsDesF13(k.projectId).length, 1)
assert.equal(k16AnF13(k.email).length, 1)
})
test('get_admin_termine_feld: GET /api/team/funktionen/:kunde liefert termine { textformNoetig, avvVersion, links } je Modus', async (t) => {
modusMerken(t)
const k = legeKundeF13({ email: 'get-f13@example.invalid' })
const v = config.termine.avvVersion
const feldVon = async () => (await get(`/api/team/funktionen/${k.customerId}`, STAFF)).termine
termineMit('off')
assert.equal((await feldVon()).textformNoetig, false)
termineMit('pilot', ['*'])
assert.equal((await feldVon()).textformNoetig, false)
termineMit('on', ['get-f13@example.invalid'])
assert.equal((await feldVon()).textformNoetig, false, 'Pilotadresse')
termineMit('on', [])
const f = await feldVon()
assert.deepEqual(Object.keys(f).sort(), ['avvVersion', 'links', 'textformNoetig'])
assert.equal(f.textformNoetig, true)
assert.equal(f.avvVersion, v)
assert.deepEqual(Object.keys(f.links).sort(), ['avv', 'lb'])
// bisherige Antwort bleibt: projects
const ganz = await get(`/api/team/funktionen/${k.customerId}`, STAFF)
assert.ok(Array.isArray(ganz.projects))
// unbekannter Kunde: Modus on => Textform nötig (ohne Adresse), keine Ausnahme
termineMit('on', ['*'])
assert.equal((await get('/api/team/funktionen/c_gibtsnicht', STAFF)).termine.textformNoetig, true)
})
test('chat_vorlage_versioniert: die Adressen für die Chat-Vorlage verweisen nur auf Seiten mit Fassung (-<Fassung>.html), absolut, folgen der aktuellen Fassung', async (t) => {
modusMerken(t)
termineMit('on', [])
const k = legeKundeF13()
const v = config.termine.avvVersion
const { links } = (await get(`/api/team/funktionen/${k.customerId}`, STAFF)).termine
assert.equal(links.lb, `${config.publicBaseUrl}/rechtliches/leistungsbeschreibung-terminplaner-${v}.html`)
assert.equal(links.avv, `${config.publicBaseUrl}/rechtliches/avv-terminplaner-${v}.html`)
for (const adresse of Object.values(links)) {
assert.ok(adresse.endsWith(`-${v}.html`), adresse)
assert.match(adresse, /^https:\/\//)
}
// eine neue Fassung ändert die Adressen mit
const vorher = config.termine.avvVersion
t.after(() => { config.termine.avvVersion = vorher })
config.termine.avvVersion = '2099-01-02'
const neu = (await get(`/api/team/funktionen/${k.customerId}`, STAFF)).termine
assert.equal(neu.avvVersion, '2099-01-02')
for (const adresse of Object.values(neu.links)) assert.ok(adresse.endsWith('-2099-01-02.html'), adresse)
})
test('zustimmungFuerSitzung(""): der Nachweis der Staff-Aktivierung (leere Sitzung) wird über keine Sitzung gefunden', async (t) => {
modusMerken(t)
termineMit('on', [])
const k = legeKundeF13()
assert.equal((await aktivierenF13(k, feldF13(k))).status, 200)
assert.equal(consentsDesF13(k.projectId).length, 1)
assert.equal(consentsDesF13(k.projectId)[0].stripeSessionId, '')
for (const leer of ['', ' ', undefined, null]) assert.equal(await zustimmungFuerSitzung(leer), null, JSON.stringify(leer))
})