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') })