Merge feature/terminplaner-tp2 (a629b25) in feature/oeffnungszeiten

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-05 04:42:31 +00:00
commit 1d5a565e9f
95 files changed
+12879 -546

No files matched your search

+860 -5
View File
@@ -20,9 +20,21 @@ 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 })
after(() => Promise.all([app.close(), aw.close(), st.close()]))
// 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' })
@@ -165,7 +177,8 @@ test('GET /api/funktionen: termine.verfuegbar je Modus und Pilot-E-Mail; false b
assert.equal(await verfuegbar(customerCookie('c2')), true)
})
// ---- Abo-Felder der Kachel (C10a): bestellbar, grund, preis, einrichtung nur bei verfuegbar: 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,
@@ -173,6 +186,7 @@ const angebot = (bestellbar, grund) => ({
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) => {
@@ -182,7 +196,38 @@ test('GET /api/funktionen: Kunde mit Wartungs-Abo => bestellbar true, grund null
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'])
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) => {
@@ -212,11 +257,15 @@ test('GET /api/funktionen: Lesefehler im Abo => bestellbar null, grund null, die
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))
for (const id of ['pt1', 'pt2']) assert.ok(res.projects.some((p) => p.projectId === id), `Projekt ${id} ist in der Liste`)
assert.equal(featuresOf({ body: res }, 'pt1').termine.state, 'active', 'die Zustaende der Projekte bleiben')
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')
@@ -508,3 +557,809 @@ test('adminActivate (Dienst): bezahlter Terminplaner ohne Abo => Fehler 409, bei
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)
})