Files
ClaudeandClaude Sonnet 5.5 bea07e73b1 TP2 Abschlussprüfung: Rechtstexte (AVV, 6c, News-LB-Archiv, Livegang-Checkliste)
- AVV Vorbemerkung (2): K16 der Textform-Aktivierung trägt nur Leistungsbeschreibung und AVV als PDF
- AVV Nr. 9 Abs. 6, Anlage (Löschung), LB Nr. 8 Abs. 3 und Datenschutz 6c: das Einrichtungs-Ticket bleibt mit einer Kurzfassung der Einstellungen als Arbeitsnachweis (keine Daten von Gästen)
- AVV Nr. 8 Abs. 2: "Gastdaten löschen" löscht nur wartende E-Mails; fehlgeschlagene behalten Adresse und Inhalt bis Nr. 9 Abs. 4
- Datenschutz § 7: Frist für Bestellungen ohne Abo (drei Jahre nach Ablauf des Jahres der Bestellung)
- News-Leistungsbeschreibung vom 30.09.2026 unverändert archiviert (SHA-256-Pin), die geltende Datei verweist darauf
- Kanzlei-Paket: Zitate, Q4/Q9/Q23, Livegang-Checkliste (Abschnitt 7.6/7.7), K23-Hinweis
- build-legal-html: Rückwärtsschrägstrich in Navigationsadressen abgelehnt; TP1-Spezifikation E2 (ohne USt)
- HTML, PDFs und Prüfsummen neu erzeugt (Terminplaner-Unterlagen und Datenschutzerklärung)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 06:54:55 +00:00

311 lines
18 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, 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])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/
const MAX = reg.TICKET_DETAILS_MAX
const BETRIEB = {
name: 'Salon Haarlinie',
email: 'inhaber-intern@haarlinie.example',
telefon: '06301 1234',
adresse: 'Hauptstraße 1, 67722 Winnweiler',
kleinunternehmer: false,
impressumUrl: 'https://haarlinie.example/impressum',
datenschutzUrl: 'https://haarlinie.example/datenschutz',
bundesland: 'RP',
}
const PROJEKT = { projectName: 'Salon Haarlinie', liveDomain: 'haarlinie.example', subdomain: 'haarlinie' }
const SITE_KEY = 'abcdefghijklmnop'
const UPDATED_AT = '2026-10-03T08:15:00.000Z'
const einstellung = () => {
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 <b>x</b> \uD83D${'x'.repeat(8)}😀`
const projekt = { projectName: `Salon \uD83D <i>x</i>`, 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('<b>x</b>') && text.includes('<i>x</i>'), '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')
})