import { test, after, mock } from 'node:test'
import assert from 'node:assert/strict'
import { startAppwriteMock } from './support/appwriteMock.js'
import { useTestEnv } from './support/testEnv.js'
import { schemaKeys } from './support/schema.js'
import { TERMINE_DATETIMES } from '../server/services/termine/schema.js'
// Kurzfassung der Einbauanleitung im Einrichtungs-Ticket (TP2 Task D4): termineTicketDetails ist der Inhalt von
// config.features.termine.ticket.details. Es wirft nie ("bezahlt ist bezahlt, das Ticket entsteht immer"): bei Lesefehlern
// stehen Kopfzeilen, der Hinweis und der Verweis im Ticket. websiteProjects, workorders und customers legt das Setup-Skript nicht
// an: ohne Schema im Nachbau.
const aw = await startAppwriteMock({
schema: { portalFeatures: schemaKeys('portalFeatures'), termineConfig: schemaKeys('termineConfig'), termineBookings: schemaKeys('termineBookings') },
datetimes: TERMINE_DATETIMES,
})
after(() => aw.close())
useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' })
const { config } = await import('../server/config.js')
const { ausVorlage } = await import('../server/services/termine/vorlagen.js')
const { neuerFeedToken } = await import('../server/services/termine/einstellungen.js')
const reg = await import('../server/services/featureRegistry.js')
const sf = await import('../server/services/siteFeatures.js')
const { termineTicketDetails } = await import('../server/services/termine/ticketDetails.js')
const { ticketStand, driftWarnung, gesundheitsMeldungen } = await import('../server/services/termine/einbauanleitung.js')
aw.seed('customers', { $id: 'c1', name: 'Kunde Eins', location: 'Berlin', email: 'kunde1@example.test' })
const NOW = new Date('2026-10-05T10:00:00Z')
const GEKUERZT = '… gekürzt, vollständig im Ticketsystem unter Kunden → Funktionen'
const HALBES_PAAR = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(? {
const e = ausVorlage('salon', BETRIEB, NOW)
e.farbe = '#9d4b6f'
return e
}
/** Maximalbefuellung wie im Plan: 50 Leistungen mit 100 Zeichen, 20 Personen, 5 Zusatzfelder, 10 Gesundheitsmeldungen, 6 Warnungen. */
function maximalEinstellung() {
const e = einstellung()
e.betrieb.telefon = ''
e.betrieb.adresse = ''
e.betrieb.impressumUrl = ''
e.betrieb.datenschutzUrl = ''
e.farbe = '#ffff00'
const vorlage = e.leistungen[0]
e.leistungen = Array.from({ length: 50 }, (_, i) => ({
...vorlage,
id: `s_max${String(i).padStart(5, '0')}`,
name: `${String(i + 1).padStart(2, '0')} ${'L'.repeat(97)}`,
aktiv: true,
ressourcen: [],
beschreibung: i < 10 ? 'Physiotherapie' : '',
}))
e.leistungen[0].name = 'Physiotherapie'
e.ressourcen = Array.from({ length: 20 }, (_, i) => ({
id: `r_max${String(i).padStart(5, '0')}`, typ: 'person', name: `Person ${i + 1} ${'P'.repeat(80)}`, aktiv: true, plan: { 1: [['09:00', '12:00']] }, feedToken: neuerFeedToken(),
}))
e.gastFelder = {
telefon: 'pflicht',
nachricht: 'optional',
zusatz: Array.from({ length: 5 }, (_, i) => ({ key: `z${i}`, label: `Zusatzfeld ${i + 1} ${'Z'.repeat(90)}`, typ: 'text', optionen: [], pflicht: false })),
}
return e
}
const MAX_FRAGEBOGEN = { einbau: { modus: 'knopf', knopfText: 'K'.repeat(40), stelle: 'S'.repeat(300), gestaltung: 'G'.repeat(1000), bemerkung: 'B'.repeat(1000) } }
let zaehler = 0
/** Projekt (Kunde c1) anlegen und den Terminplaner bestellen; mit e (und fb) liegt ein termineConfig-Dokument unter der Funktions-ID. */
async function bestelle({ e = null, fb = null, version = 1, projekt = PROJEKT } = {}) {
zaehler += 1
const projectId = `tt${zaehler}`
const sessionId = `cs_tt${zaehler}`
aw.seed('websiteProjects', { $id: projectId, customerId: 'c1', ...projekt })
const doc = await sf.markOrdered({ customerId: 'c1', projectId, key: 'termine', sessionId, consent: {} })
if (e) {
aw.seed('termineConfig', {
$id: doc.$id, customerId: 'c1', projectId, einstellung: JSON.stringify(e), version, updatedAt: UPDATED_AT, updatedBy: 'kunde:c1',
...(fb ? { fragebogen: JSON.stringify(fb) } : {}),
})
}
return { projectId, sessionId, doc }
}
/** ctx genau in der Form, die markPaid uebergibt: { feature: Register-Eintrag, doc, project, customer }. */
const ctxFuer = (doc, project = PROJEKT, customer = { name: 'Kunde Eins' }) => ({ feature: reg.featureConfig('termine'), doc, project, customer })
const absaetze = (text) => text.split('\n\n')
const stumm = () => mock.method(console, 'error', () => {})
// ------------------------------------------------------------------------------ termineTicketDetails ---
test('termineTicketDetails mit Einstellungen: Kopf mit Konfigurationsstand: Version 1, Snippet mit siteKey, Verweis, Stundenkontingent', async () => {
const { projectId, doc } = await bestelle({ e: einstellung() })
const text = await termineTicketDetails(ctxFuer(doc))
const [kopf] = absaetze(text)
assert.deepEqual(kopf.split('\n'), [
'Funktion: Terminplaner',
'Projekt: Salon Haarlinie',
'Domain: haarlinie.example',
'Kunde: Kunde Eins',
`siteKey: ${doc.siteKey}`,
'Konfigurationsstand: Version 1, 03.10.2026',
])
assert.ok(text.includes('Konfigurationsstand: Version 1'))
assert.equal(ticketStand(text), 1)
assert.ok(text.includes(`data-webklar-termine="${doc.siteKey}"`))
assert.ok(text.endsWith(`Verweis: ${reg.anleitungsLink('c1', projectId)}`))
assert.ok(text.includes('Nicht auf das Stundenkontingent anrechnen'))
assert.ok(text.includes('Umfang:\n- 4 Leistungen'), text)
assert.ok(!text.includes('Online-Terminbuchung') && !text.includes(BETRIEB.email), 'weder Datenschutz-Absatz noch Betriebs-E-Mail')
assert.ok(text.length <= MAX)
assert.ok(!text.includes('bezahlt'), 'hier wurde nur bestellt: keine Behauptung, die Einrichtung sei bezahlt')
})
test('termineTicketDetails: der Wunsch des Kunden aus dem Fragebogen steht im Ticket, der Modus im Snippet', async () => {
const fb = { einbau: { modus: 'knopf', knopfText: 'Termin sichern', stelle: 'Startseite unter dem Hero', gestaltung: 'Schlicht', bemerkung: 'Bitte vor dem 20.10.' } }
const { doc } = await bestelle({ e: einstellung(), fb })
const text = await termineTicketDetails(ctxFuer(doc))
assert.ok(text.includes(' Modus: knopf\n Knopf-Text: Termin sichern\n Stelle: Startseite unter dem Hero\n Gestaltung: Schlicht\n Bemerkung: Bitte vor dem 20.10.'), text)
assert.ok(text.includes('data-modus="knopf" data-knopf="Termin sichern"'))
})
test('termineTicketDetails ohne termineConfig: noch keine Einstellungen, Snippet, Verweis', async () => {
const { projectId, doc } = await bestelle()
const text = await termineTicketDetails(ctxFuer(doc))
assert.ok(text.includes('Konfigurationsstand: Version 0 (noch keine Einstellungen gespeichert)'), text)
assert.ok(text.includes('noch keine Einstellungen'))
assert.equal(ticketStand(text), 0)
assert.ok(text.includes(`data-webklar-termine="${doc.siteKey}"`))
assert.ok(text.endsWith(`Verweis: ${reg.anleitungsLink('c1', projectId)}`))
assert.ok(text.includes('Keine Einstellungen gespeichert'))
})
test('termineTicketDetails bei Lesefehler: loest auf, ohne Wurf, mit Kopfzeilen, Hinweis und Link', async () => {
const { projectId, doc } = await bestelle({ e: einstellung() })
const fehler = stumm()
try {
aw.failNext({ collection: 'termineConfig', method: 'GET', status: 500 })
const text = await termineTicketDetails(ctxFuer(doc))
const link = reg.anleitungsLink('c1', projectId)
for (const teil of ['Projekt: Salon Haarlinie', 'Domain: haarlinie.example', 'Kunde: Kunde Eins', `siteKey: ${doc.siteKey}`, `Verweis: ${link}`, 'Anleitung konnte nicht erzeugt werden, im WOMS öffnen', 'Nicht auf das Stundenkontingent anrechnen']) {
assert.ok(text.includes(teil), `${teil}\n---\n${text}`)
}
assert.equal(ticketStand(text), null, 'ohne Konfigurationsstand-Zeile kein Vergleich')
assert.equal(fehler.mock.callCount(), 1)
assert.ok(!String(fehler.mock.calls[0].arguments.join(' ')).includes(doc.siteKey), 'das Log nennt keine Daten des Betriebs')
} finally {
fehler.mock.restore()
}
// danach funktioniert es wieder (der Fehler war einmalig)
assert.ok((await termineTicketDetails(ctxFuer(doc))).includes('Konfigurationsstand: Version 1'))
})
test('termineTicketDetails wirft nie: auch nicht ohne Dokument, ohne ctx oder bei fehlender Collection', async () => {
const fehler = stumm()
try {
for (const ctx of [undefined, null, {}, { doc: null }, { doc: {} }, { doc: { $id: '' } }, { doc: { $id: 7 } }, ctxFuer({ $id: 'f_gibt_es_nicht', customerId: 'c1', projectId: 'p0', siteKey: SITE_KEY })]) {
const text = await termineTicketDetails(ctx)
assert.equal(typeof text, 'string', JSON.stringify(ctx))
assert.ok(text.includes('Terminplaner'), JSON.stringify(ctx))
assert.ok(text.length <= MAX && text.isWellFormed())
}
} finally {
fehler.mock.restore()
}
})
test('termineTicketDetails: Namen mit Markup bleiben Text, halbe Zeichenpaare werden ersetzt', async () => {
const e = einstellung()
e.leistungen[0].name = `Schnitt x \uD83D${'x'.repeat(8)}😀`
const projekt = { projectName: `Salon \uD83D x`, liveDomain: 'haarlinie.example' }
const { doc } = await bestelle({ e, projekt })
const text = await termineTicketDetails(ctxFuer(doc, projekt, { name: `Kunde \uDE00 & Co` }))
assert.ok(text.isWellFormed() && !HALBES_PAAR.test(text))
assert.ok(text.includes('x') && text.includes('x'), 'Klartext, kein HTML')
assert.ok(text.includes('😀'))
// ohne Kunde: die Kunden-ID wie in den Notfall-Details
assert.ok((await termineTicketDetails(ctxFuer(doc, projekt, null))).includes('Kunde: c1'))
})
test('Ticket als bleibender Arbeitsnachweis (AVV Nr. 9 Abs. 6): Betriebsangaben, Leistungsnamen, nur die ersten acht Personen, Wünsche des Kunden - und nichts von den Gästen', async () => {
const e = einstellung()
e.ressourcen = Array.from({ length: 10 }, (_, i) => ({
id: `r_tk${String(i).padStart(5, '0')}`, typ: 'person', name: `Mitarbeiter ${i + 1}`, aktiv: true, plan: { 1: [['09:00', '12:00']] }, feedToken: neuerFeedToken(),
}))
const fb = { einbau: { modus: 'inline', knopfText: '', stelle: 'Startseite', gestaltung: '', bemerkung: 'Bitte vor dem 20.10.' } }
const { doc } = await bestelle({ e, fb })
// eine Buchung dieses Terminplaners mit allen Angaben des Gastes: nichts davon darf ins Ticket gelangen
aw.seed('termineBookings', {
$id: 'b_tk1', featureId: doc.$id, customerId: 'c1', status: 'bestaetigt', leistungName: e.leistungen[0].name, ressourceName: 'Mitarbeiter 1',
gastName: 'Erika Gastgeberin', gastEmail: 'erika.gast@example.test', gastTelefon: '0171 5550000', gastNachricht: 'Bitte ohne Föhn', gastZusatz: '{"allergie":"Nuss"}', absageGrund: 'Krank',
})
const text = await termineTicketDetails(ctxFuer(doc))
assert.ok(text.includes('Projekt: Salon Haarlinie') && text.includes('Kunde: Kunde Eins'), 'Betriebsangaben')
assert.ok(text.includes(BETRIEB.telefon), 'Telefon des Betriebs (Ausweichtext im Einbau-Code)')
for (const l of e.leistungen) assert.ok(text.includes(l.name), `Leistung ${l.name}`)
for (let i = 1; i <= 8; i += 1) assert.ok(text.includes(`Mitarbeiter ${i}`), `Mitarbeiter ${i}`)
assert.ok(!text.includes('Mitarbeiter 9') && !text.includes('Mitarbeiter 10') && text.includes('und 2 weitere'), 'nur die ersten acht Namen')
assert.ok(text.includes('Bemerkung: Bitte vor dem 20.10.') && text.includes('Stelle: Startseite'), 'Wünsche des Kunden zum Einbau')
for (const gast of ['Erika', 'Gastgeberin', 'erika.gast', '0171', 'Föhn', 'Nuss', 'Krank']) assert.ok(!text.includes(gast), `keine Gastdaten: ${gast}`)
})
test('termineTicketDetails: Gesundheit (Physiotherapie) und Maximalbefuellung: enthaelt Physio und bleibt hoechstens 7000 Zeichen', async () => {
const e = maximalEinstellung()
const meldungen = gesundheitsMeldungen(e)
assert.equal(meldungen.length, 10)
const { doc } = await bestelle({ e, fb: MAX_FRAGEBOGEN, version: 12 })
const text = await termineTicketDetails(ctxFuer(doc))
assert.ok(text.length <= MAX, `kurz ${text.length}`)
assert.ok(text.includes('Physio'))
assert.ok(text.includes(`Gesundheitsbereich prüfen:\n${meldungen.map((m) => `- ${m}`).join('\n')}`), 'alle zehn Meldungen')
assert.ok(text.includes(`data-webklar-termine="${doc.siteKey}"`))
assert.ok(text.includes('Nicht auf das Stundenkontingent anrechnen'))
assert.equal(ticketStand(text), 12)
assert.ok(!text.includes('Online-Terminbuchung'))
for (const r of e.ressourcen) assert.ok(!text.includes(r.feedToken))
assert.ok(!text.includes(GEKUERZT), 'die Budgetliste laesst Teile weg und schneidet nicht ab')
// ticketTexte kuerzt nichts mehr
const texte = await reg.ticketTexte(ctxFuer(doc))
assert.equal(texte.details, text)
})
// ------------------------------------------------------------------------------------- Registry ---
test('Registry: featureConfig("termine").ticket.details(ctx) liefert den Text von termineTicketDetails, siteKey und $id aus ctx.doc', async () => {
const { doc } = await bestelle({ e: einstellung() })
const feature = reg.featureConfig('termine')
assert.equal(feature.ticket.topic(PROJEKT), 'Terminplaner einbauen – Salon Haarlinie')
// das Register-Objekt traegt keinen siteKey: der Text darf nur aus ctx.doc kommen
const ctx = { feature: { ...feature, siteKey: 'FALSCH_AUS_REGISTER', $id: 'f_falsch' }, doc, project: PROJEKT, customer: { name: 'Kunde Eins' } }
const ueberRegister = await feature.ticket.details(ctx)
assert.equal(ueberRegister, await termineTicketDetails(ctx))
assert.equal(ueberRegister, await termineTicketDetails(ctxFuer(doc)))
assert.ok(ueberRegister.includes(doc.siteKey) && !ueberRegister.includes('FALSCH_AUS_REGISTER'))
assert.ok(ueberRegister.includes('Konfigurationsstand: Version 1'), 'gelesen wurde das Dokument der Funktion (doc.$id)')
})
// ----------------------------------------------------------------------- Integration (Review Focus 1) ---
const workorder = (doc) => aw.docs('workorders').get(doc.ticketId)
test('markOrdered + markPaid: das Ticket traegt die Kurzfassung (hoechstens 7000, mit Snippet), die Stand-Zeile und der Vergleich meldet Aenderungen', async () => {
const { sessionId, doc: bestellt } = await bestelle({ e: maximalEinstellung(), fb: MAX_FRAGEBOGEN })
const { doc, created, fall } = await sf.markPaid({ sessionId })
assert.deepEqual([created, fall, doc.state], [true, null, 'setup'])
const ticket = workorder(doc)
assert.ok(ticket.details.length <= MAX, `details ${ticket.details.length}`)
assert.ok(ticket.details.includes(`data-webklar-termine="${bestellt.siteKey}"`))
assert.ok(ticket.details.includes('Einrichtung bezahlt, 149 €'), 'markPaid uebergibt das bezahlte Dokument (unlockedByPurchase)')
assert.ok(ticket.details.includes('Physio'))
assert.ok(!ticket.details.includes(GEKUERZT))
assert.equal(ticket.topic, 'Terminplaner einbauen – Salon Haarlinie')
// Ticket Version 1; die Einstellungen aendern sich spaeter: der Vergleich (Team-Route, D8) meldet es
const ticketVersion = ticketStand(ticket.details)
assert.equal(ticketVersion, 1)
assert.equal(driftWarnung(ticketVersion, 1), null)
assert.equal(driftWarnung(ticketVersion, 4), 'Einstellungen seit der Bestellung geändert (Ticket Version 1, jetzt 4).')
})
test('markPaid mit kaputtem termineConfig-Lesen legt das Ticket trotzdem an: created true, Notfall-Details mit Kopf, Hinweis und Link', async () => {
const { projectId, sessionId } = await bestelle({ e: einstellung() })
const fehler = stumm()
try {
aw.failNext({ collection: 'termineConfig', method: 'GET', status: 500 })
const { doc, created, fall } = await sf.markPaid({ sessionId })
assert.deepEqual([created, fall, doc.state], [true, null, 'setup'])
assert.ok(doc.ticketId, 'Zahlung nie ohne Ticket')
const ticket = workorder(doc)
assert.ok(ticket.details.includes(`siteKey: ${doc.siteKey}`))
assert.ok(ticket.details.includes('Anleitung konnte nicht erzeugt werden, im WOMS öffnen'))
assert.ok(ticket.details.includes(reg.anleitungsLink('c1', projectId)))
assert.ok(ticket.details.length <= MAX)
} finally {
fehler.mock.restore()
}
})
test('Webhook-Wiederholung nach einem Lesefehler: ohne Ticket bleibt setup fortsetzbar, die Kurzfassung entsteht beim zweiten Versuch', async () => {
const { sessionId } = await bestelle({ e: einstellung() })
const fehler = stumm()
try {
// das Anlegen des Tickets scheitert (nicht das Lesen): setup ohne ticketId
aw.failNext({ collection: 'workorders', method: 'POST', status: 500 })
await assert.rejects(() => sf.markPaid({ sessionId }))
const { doc, created } = await sf.markPaid({ sessionId })
assert.equal(created, true)
assert.ok(workorder(doc).details.includes('Konfigurationsstand: Version 1'))
} finally {
fehler.mock.restore()
}
})
test('config.features.termine.ticket.details ruft die Datei lazy auf (config.js bleibt ohne statischen Dienst-Import)', async () => {
const { readFileSync } = await import('node:fs')
const src = readFileSync(new URL('../server/config.js', import.meta.url), 'utf8')
assert.match(src, /import\('\.\/services\/termine\/ticketDetails\.js'\)/)
assert.ok(!/^import .*ticketDetails/m.test(src))
assert.equal(typeof config.features.termine.ticket.details, 'function')
})