Gästetexte des Terminplaners: Fassung 2026-10-05, Erinnerungs- und Abo-Ende-Satz, Archiv je Fassung

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-05 04:01:49 +00:00
1 parent 1a91a331b4
commit c1266fcce7
11 files changed
+313 -33

No files matched your search

+34 -2
View File
@@ -22,7 +22,7 @@ const aw = await startAppwriteMock({
useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' })
const { config } = await import('../server/config.js')
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
const { deleteDocument, updateDocument } = await import('../server/services/appwriteAdmin.js')
const { setMailTransportForTests } = await import('../server/services/mailer.js')
const { startTestApp, send } = await import('./support/testApp.js')
const { kundenansichtCookie } = await import('./support/fixtures.js')
@@ -32,7 +32,7 @@ const { resetOutboxForTests, warteAufVersand } = await import('../server/service
const { buchungsNummer, formToken, pruefeFormToken, verwaltungsToken } = await import('../server/services/termine/token.js')
const { speichereEinstellung, resetKontextForTests } = await import('../server/services/termine/kontext.js')
const { resetTermineLimitsForTests, darfHaltung } = await import('../server/services/termine/limits.js')
const { ZUSTIMMUNG_STORNO, ZUSTIMMUNG_ERINNERUNG, ZUSTIMMUNG_VORZEITIG, GESUNDHEIT_HINWEIS, formularHinweis, widerrufsbelehrung } = await import(
const { RECHTSTEXTE_VERSION, ZUSTIMMUNG_STORNO, ZUSTIMMUNG_ERINNERUNG, ZUSTIMMUNG_VORZEITIG, GESUNDHEIT_HINWEIS, formularHinweis, widerrufsbelehrung } = await import(
'../server/services/termine/rechtstexte.js'
)
const { halte } = await import('../server/services/termine/ablauf.js')
@@ -274,6 +274,8 @@ test('config: Betriebsdaten, Leistungen, Regeln, Texte und Formular-Token; keine
gesundheit: GESUNDHEIT_HINWEIS,
widerrufsbelehrung: widerrufsbelehrung(ctx.einstellung.betrieb),
})
// Haken Erinnerung: Wortlaut der Fassung mit dem Widerrufssatz (Art. 7 Abs. 3 DSGVO), wörtlich in der öffentlichen Konfiguration
assert.equal(c.texte.erinnerung, 'Ich möchte 24 Stunden vor dem Termin eine Erinnerung per E-Mail erhalten. Diese Einwilligung kann ich jederzeit widerrufen.')
assert.equal(pruefeFormToken(SITE_KEY, c.formToken, plus(JETZT, 10000)), 'ok')
assert.equal(pruefeFormToken('bcdefghijklmnopq', c.formToken, plus(JETZT, 10000)), 'ungueltig', 'das Token gilt nur fuer diesen siteKey')
@@ -564,6 +566,36 @@ test('Buchung: 201 mit maskierter Adresse und Haltezeit; Buchung gehalten, Mail
assert.ok(!JSON.stringify(res.body).includes(b.$id))
})
test('nachweis_fassung: eine neue Buchung trägt die aktuelle Fassung der Gästetexte, eine Altbuchung behält 2026-10-02 (der Nachweis wird nie umgeschrieben)', async () => {
await speichere()
const { id: neu, verwaltung } = await gebucht({ erinnerung: true, zustimmungen: { storno: true, vorzeitig: false } })
const nachweisNeu = JSON.parse(aw.docs('termineBookings').get(neu).nachweis)
assert.equal(nachweisNeu.fassung, RECHTSTEXTE_VERSION)
assert.notEqual(nachweisNeu.fassung, '2026-10-02')
assert.equal(nachweisNeu.erinnerung.hash.length, 64)
// Altbuchung: Nachweis der Fassung 2026-10-02; Bestätigen, Verschieben und Absagen schreiben den Nachweis nicht um
const alt = await buchen({ email: 'alt@example.com', zeit: '11:00', ressource: R1 })
assert.equal(alt.status, 201)
await warteAufVersand()
const buchungAlt = buchungen().find((b) => b.gastEmail === 'alt@example.com')
const nachweisAlt = JSON.stringify({ ...JSON.parse(buchungAlt.nachweis), fassung: '2026-10-02' })
await updateDocument('termineBookings', buchungAlt.$id, { nachweis: nachweisAlt })
const vToken = tokenAusMail(mailsAn('alt@example.com')[0], 'v')
assert.equal((await tokenAnruf('bestaetigen', { token: vToken })).status, 200)
await warteAufVersand()
const mToken = tokenAusMail(mailsAn('alt@example.com').find((m) => /#m=/.test(m.text)), 'm')
assert.equal((await tokenAnruf('verschieben', { token: mToken, tag: TAG_FERN, zeit: '11:30' })).status, 200)
assert.equal((await tokenAnruf('absagen', { token: mToken })).status, 200)
const danach = aw.docs('termineBookings').get(buchungAlt.$id)
assert.equal(danach.status, 'abgesagt')
assert.equal(danach.nachweis, nachweisAlt, 'der Nachweis der Altbuchung bleibt byte-gleich')
assert.equal(JSON.parse(danach.nachweis).fassung, '2026-10-02')
// die neue Buchung ist davon unberührt
assert.equal(JSON.parse(aw.docs('termineBookings').get(neu).nachweis).fassung, RECHTSTEXTE_VERSION)
assert.ok(verwaltung)
})
test('zu viele offene Termine: dieselbe 201-Antwort, keine neue Buchung, statt der Bestaetigung geht G01 "zu viele"', async () => {
await speichere()
// drei bestaetigte Termine belegen die drei offenen Plaetze; die Kappe der Adresse (3 Mails je Stunde) ist damit ausgeschoepft,
+85 -9
View File
@@ -55,8 +55,11 @@ function fristBaustein() {
const belehrung = (betrieb = BETRIEB) => widerrufsbelehrung(betrieb)
test('Fassung der Rechtstexte', () => {
assert.equal(RECHTSTEXTE_VERSION, '2026-10-02')
test('version_gleich_register: RECHTSTEXTE_VERSION ist das Fassungsdatum des Registers und nicht mehr die Altfassung 2026-10-02', () => {
assert.equal(RECHTSTEXTE_VERSION, config.termine.avvVersion)
assert.notEqual(RECHTSTEXTE_VERSION, '2026-10-02')
assert.match(RECHTSTEXTE_VERSION, /^\d{4}-\d{2}-\d{2}$/)
assert.ok(RECHTSTEXTE_VERSION > '2026-10-02', 'die Fassung liegt nach der eingefrorenen Altfassung')
})
// ---------------------------------------------------------------- Widerrufsbelehrung
@@ -177,8 +180,8 @@ test('Zustimmungstexte wortgleich zu den Vorgaben (Sie-Form)', () => {
+ 'Widerrufe ich vor der vollständigen Erbringung, schulde ich einen angemessenen Betrag für die bis dahin erbrachte Leistung (§ 357a Abs. 2 BGB).',
)
assert.equal(ZUSTIMMUNG_STORNO, 'Ich habe die Stornobedingungen gelesen.')
assert.equal(ZUSTIMMUNG_ERINNERUNG(24), 'Ich möchte 24 Stunden vor dem Termin eine Erinnerung per E-Mail erhalten.')
assert.equal(ZUSTIMMUNG_ERINNERUNG(2), 'Ich möchte 2 Stunden vor dem Termin eine Erinnerung per E-Mail erhalten.')
assert.equal(ZUSTIMMUNG_ERINNERUNG(24), 'Ich möchte 24 Stunden vor dem Termin eine Erinnerung per E-Mail erhalten. Diese Einwilligung kann ich jederzeit widerrufen.')
assert.equal(ZUSTIMMUNG_ERINNERUNG(2), 'Ich möchte 2 Stunden vor dem Termin eine Erinnerung per E-Mail erhalten. Diese Einwilligung kann ich jederzeit widerrufen.')
assert.equal(GESUNDHEIT_HINWEIS, 'Bitte keine Gesundheitsangaben.')
})
@@ -187,6 +190,23 @@ test('Zustimmung zum vorzeitigen Beginn setzt den Betriebsnamen unverändert ein
assert.ok(text.startsWith("Ich verlange ausdrücklich, dass Frisör $& $1 'Anna' mit der Leistung vor Ablauf"))
})
test('erinnerung_widerrufssatz: Haken-Text, Hinweise-Dokument wörtlich gleich; der Haken trägt den Widerrufssatz (Art. 7 Abs. 3 DSGVO)', () => {
const satz = (n) => `Ich möchte ${n} Stunden vor dem Termin eine Erinnerung per E-Mail erhalten. Diese Einwilligung kann ich jederzeit widerrufen.`
for (const n of [2, 12, 24, 48]) assert.equal(ZUSTIMMUNG_ERINNERUNG(n), satz(n))
const md = lies('docs/rechtliches/termine-hinweise.md').replace(/\r/g, '')
assert.ok(md.includes(satz('{{n}}')), 'Hinweise-Dokument: Haken Erinnerung weicht ab')
assert.ok(md.includes(ZUSTIMMUNG_ERINNERUNG('{{n}}')))
assert.ok(!md.includes('Erinnerung per E-Mail erhalten. |'), 'der alte Haken ohne Widerrufssatz steht nicht mehr im Dokument')
assert.equal(ZUSTIMMUNG_ERINNERUNG(24).split('\n').length, 1, 'ein Satz im Haken, keine Umbrüche')
})
test('dokumente_nennen_fassung: die drei Gäste-Dokumente tragen die Fassung der Gästetexte', () => {
for (const rel of ['docs/rechtliches/termine-hinweise.md', 'docs/rechtliches/termine-datenschutz-absatz.md', 'docs/rechtliches/termine-widerrufsbelehrung-muster.md']) {
assert.ok(lies(rel).includes(`Fassung ${RECHTSTEXTE_VERSION}.`), `${rel} nennt nicht „Fassung ${RECHTSTEXTE_VERSION}.“`)
assert.ok(!lies(rel).includes('Fassung 2026-10-02.'), `${rel} trägt noch die Altfassung`)
}
})
const EINSTELLUNG = { anrede: 'sie', aufbewahrungTage: 90, betrieb: BETRIEB }
test('Formular-Hinweis (Sie): Zweck, Auftragsverarbeiter, Löschfristen, keine Cookies, Gesundheit, Verantwortlicher', () => {
@@ -271,8 +291,8 @@ const normalisiere = (text) => text.split(/\n[ \t]*\n/).map((a) => a.replace(/\s
/**
* Datenschutz-Absatz aus der Markdown-Quelle: Blockquote der Fassung („Sie“ | „Du“), `> `, `**` und `###` entfernt,
* [[wenn …: X]] durch X (Schalter an) oder nichts (Schalter aus) ersetzt, Platzhalter mit den Testwerten gefüllt.
* Ein Satzteil, der mit einem Wort beginnt („und die Angaben …“), steht in der Quelle ohne Leerzeichen am Vordertext
* („Platz[[…: und die Angaben …]]“); er wird hier mit Leerzeichen angefügt (Satzteile mit Komma bleiben unmittelbar dran).
* Ein Satzteil, der mit einem Wort beginnt („und die Angaben …“), trägt sein Leerzeichen selbst in der Klammer (zwei Leerzeichen
* nach dem Doppelpunkt); die Ersetzung fügt nichts hinzu.
*/
function datenschutzQuelle(fassung, zustand, { tage = 90 } = {}) {
const md = lies('docs/rechtliches/termine-datenschutz-absatz.md').replace(/\r/g, '')
@@ -281,11 +301,10 @@ function datenschutzQuelle(fassung, zustand, { tage = 90 } = {}) {
const zitat = teil.split('\n').filter((z) => z.startsWith('>')).map((z) => z.replace(/^>\s?/, '').replace(/^### /, ''))
assert.ok(zitat.length > 10, 'Blockquote der Quelle nicht gefunden')
const roh = zitat.join('\n').replace(/\*\*/g, '')
const bedingt = roh.replace(/\[\[wenn ([^:]+): ([\s\S]*?)\]\]/g, (_, bedingung, satz, offset, ganz) => {
const bedingt = roh.replace(/\[\[wenn ([^:]+): ([\s\S]*?)\]\]/g, (_, bedingung, satz) => {
const eintrag = DS_BEDINGUNGEN.find(([muster]) => muster.test(bedingung))
assert.ok(eintrag, `unbekannte Bedingung in der Quelle: ${bedingung}`)
if (!zustand[eintrag[1]]) return ''
return /^[A-Za-zÄÖÜäöüß]/.test(satz) && offset > 0 && !/\s/.test(ganz[offset - 1]) ? ` ${satz}` : satz
return zustand[eintrag[1]] ? satz : ''
})
const werte = { Firma: BETRIEB.name, Anschrift: BETRIEB.adresse, 'E-Mail': BETRIEB.email, Telefon: BETRIEB.telefon, Tage: String(tage) }
const gefuellt = bedingt.replace(/\{\{([^}]+)\}\}/g, (_, name) => {
@@ -364,6 +383,63 @@ test('Datenschutz-Absatz: die Bedingungen lesen die Einstellung wie das Widget',
assert.ok(text({ ...alleAn, betrieb: { ...BETRIEB, telefon: ' 06301\n1234 ' } }).includes('telefonisch erreichen: 06301 1234.'))
})
const ABO_ENDE_SATZ = 'Wird die Online-Terminbuchung abgeschaltet, zum Beispiel weil unser Vertrag mit WEBklar endet, werden alle Termindaten 30 Tage nach dem Abschalten gelöscht.'
test('absatz_abo_ende: Funktion und Markdown (Sie und Du) nennen den Abo-Ende-Satz mit „30 Tage nach dem Abschalten“; der alte Satz ist weg', () => {
const md = lies('docs/rechtliches/termine-datenschutz-absatz.md').replace(/\r/g, '')
const fassungen = md.split(/^## Fassung /m).slice(1)
assert.equal(fassungen.length, 2)
for (const f of fassungen) {
assert.ok(normalisiere(f.replace(/^> ?/gm, '')).includes(ABO_ENDE_SATZ), 'Markdown: Abo-Ende-Satz fehlt')
assert.ok(f.includes('30 Tage nach dem Abschalten'))
assert.ok(!f.includes('löschen wir alle Termindaten 30 Tage'), 'Markdown: alter Satz steht noch')
}
for (const anrede of ['sie', 'du']) {
for (const zustand of [DS_ALLE_AN, DS_ALLE_AUS]) {
const text = datenschutzAbsatz(dsEinstellung(zustand), anrede)
assert.equal(text.split(ABO_ENDE_SATZ).length, 2, `${anrede}: Satz genau einmal`)
assert.ok(text.includes('30 Tage nach dem Abschalten'))
assert.ok(!text.includes('löschen wir alle Termindaten'), `${anrede}: alter Satz steht noch`)
}
}
})
test('haltezeit_aus_config: „der Bestätigungslink gilt N Minuten“ folgt config.termine.holdMinutes; die Markdown-Quelle nennt denselben Wert', () => {
const md = lies('docs/rechtliches/termine-datenschutz-absatz.md')
for (const anrede of ['sie', 'du']) {
assert.ok(datenschutzAbsatz(dsEinstellung(DS_ALLE_AN), anrede).includes(`(der Bestätigungslink gilt ${config.termine.holdMinutes} Minuten)`))
}
assert.equal((md.match(new RegExp(`Bestätigungslink gilt ${config.termine.holdMinutes} Minuten`, 'g')) || []).length, 2, 'die Quelle nennt beide Fassungen mit dem Wert aus config')
const vorher = config.termine.holdMinutes
try {
config.termine.holdMinutes = 45
for (const anrede of ['sie', 'du']) {
const text = datenschutzAbsatz(dsEinstellung(DS_ALLE_AN), anrede)
assert.ok(text.includes('(der Bestätigungslink gilt 45 Minuten)'), anrede)
assert.ok(!text.includes('30 Minuten'), `${anrede}: kein fester Wert mehr im Text`)
}
} finally {
config.termine.holdMinutes = vorher
}
})
test('quelle_satzteile_mit_leerzeichen: ein Satzteil der Quelle, der mit einem Wort beginnt, trägt sein Leerzeichen in der Klammer', () => {
const md = lies('docs/rechtliches/termine-datenschutz-absatz.md').replace(/\r/g, '')
let mitLeerzeichen = 0
for (const m of md.matchAll(/\[\[wenn ([^:]+): ([\s\S]*?)\]\]/g)) {
if (m[1] === '…') continue // die Erklärung der Schreibweise im Kopf der Quelle
const davor = md[m.index - 1]
if (/^[A-Za-zÄÖÜäöüß]/.test(m[2])) {
// Satzteil beginnt mit einem Wort: nur nach Leerzeichen erlaubt (Satzanfang), sonst klebte er am Vordertext („Platzund“)
assert.ok(/\s/.test(davor), `Satzteil „${m[2].slice(0, 30)}…“ hängt ohne Leerzeichen am Vordertext`)
} else if (m[2].startsWith(' ')) {
mitLeerzeichen += 1
assert.ok(/^ [A-Za-zÄÖÜäöüß]/.test(m[2]) && !/\s/.test(davor), `Leerzeichen in der Klammer nur am Anschluss an ein Wort: „${m[2].slice(0, 30)}…“`)
}
}
assert.equal(mitLeerzeichen, 4, 'vier Satzteile mit „und“ tragen ihr Leerzeichen selbst (Sie und Du, je zwei)')
})
test('Datenschutz-Absatz: Aufbewahrung nach dem Termin, ungültiger Wert wird zu 90 Tagen (wie der Formular-Hinweis)', () => {
for (const anrede of ['sie', 'du']) {
const mit = (aufbewahrungTage) => datenschutzAbsatz(dsEinstellung(DS_ALLE_AN, { aufbewahrungTage }), anrede)
+17
View File
@@ -88,6 +88,10 @@ test('archiv_inhalt: alle Gästetexte stehen wörtlich im Archiv, die Betriebsan
assert.ok(text.includes('Auf unserer Website kannst du Termine online buchen.'))
assert.ok(text.includes('Vertrag widerrufen'))
assert.ok(text.includes('Wenn Sie eine Erinnerung vor dem Termin wünschen'))
// Fassung 2026-10-05: Widerrufssatz am Erinnerungshaken und Abo-Ende-Satz im Datenschutz-Absatz (Sie und Du)
assert.ok(text.includes('> Ich möchte {{Stunden}} Stunden vor dem Termin eine Erinnerung per E-Mail erhalten. Diese Einwilligung kann ich jederzeit widerrufen.\n'))
assert.equal(text.split('zum Beispiel weil unser Vertrag mit WEBklar endet, werden alle Termindaten 30 Tage nach dem Abschalten gelöscht.').length, 3, 'Abo-Ende-Satz in Sie- und Du-Fassung')
assert.ok(!text.includes('löschen wir alle Termindaten 30 Tage'))
// Nachweis-Hinweis (⚖ Q26)
assert.ok(text.includes('Der Nachweis speichert Hashes der Texte mit den Betriebsangaben zum Buchungszeitpunkt (⚖ Q26).'))
// kein Rest, keine echten Betriebsdaten
@@ -96,9 +100,22 @@ test('archiv_inhalt: alle Gästetexte stehen wörtlich im Archiv, die Betriebsan
assert.ok(text.endsWith('\n') && !text.endsWith('\n\n'))
})
// SHA-256 der eingefrorenen Datei der Fassung 2026-10-02 (aus den unveränderten Texten erzeugt, vor jeder Textänderung eingecheckt)
const HASH_2026_10_02 = '920633e2e23c9c98aded0c1509db5cac1465058a025aeddc76a9a1282041a6a9'
// SHA-256 der Archivdatei der aktuellen Fassung: ändert sich ein Gästetext, ohne dass die Fassung (RECHTSTEXTE_VERSION) ein neues Datum
// und diese Datei eine neue Archivdatei bekommt, schlägt der Test an. Bei einer neuen Fassung: neue Datei, neues Datum, neuer Pin.
const HASH_AKTUELL = { '2026-10-05': '6bcad4c4a02ea5e39b3ec690d40617fdfb3cb1290aadd3304f0ecabab14c4a59' }
test('textstand_pin: Hash der aktuellen Archivdatei ist festgenagelt (Textänderung ohne neues Datum fällt auf)', () => {
assert.ok(Object.hasOwn(HASH_AKTUELL, RECHTSTEXTE_VERSION), `kein Hash-Pin für die Fassung ${RECHTSTEXTE_VERSION}`)
const datei = lies(archivPfad())
assert.equal(sha256(datei), HASH_AKTUELL[RECHTSTEXTE_VERSION], 'Gästetext geändert, aber die Fassung ist dieselbe: neues Datum, neue Archivdatei und neuer Pin nötig')
assert.equal(sha256(baueArchiv()), HASH_AKTUELL[RECHTSTEXTE_VERSION])
assert.notEqual(HASH_AKTUELL[RECHTSTEXTE_VERSION], HASH_2026_10_02, 'die neue Fassung unterscheidet sich von der eingefrorenen')
})
test('archiv_altfassung_eingefroren: Datei 2026-10-02 hat den alten Erinnerungstext ohne den Widerrufssatz; ihr Hash ist festgenagelt', () => {
const alt = lies(`${ARCHIV_DIR}/termine-gaestetexte-2026-10-02.md`)
assert.ok(alt.startsWith('# Gästetexte des Terminplaners, Fassung 2026-10-02\n'))
+1 -1
View File
@@ -447,7 +447,7 @@ test('avv_gleich_datenschutz_absatz: Fristen und Orte stimmen mit dem Datenschut
assert.ok(absatz.includes('löschen wir 48 Stunden nach Ablauf dieser Frist'))
assert.ok(absatz.includes(`Bestätigte Termine löschen wir ${einstellung.aufbewahrungTage} Tage nach dem Termin`))
assert.ok(absatz.includes('abgesagte oder abgelehnte Termine 30 Tage nach der Absage oder Ablehnung'))
assert.ok(absatz.includes(`löschen wir alle Termindaten ${LOESCHFRIST_TAGE} Tage nach dem Abschalten`))
assert.ok(absatz.includes(`zum Beispiel weil unser Vertrag mit WEBklar endet, werden alle Termindaten ${LOESCHFRIST_TAGE} Tage nach dem Abschalten gelöscht`))
assert.ok(absatz.includes('nicht zustellbare E-Mails nach 48 Stunden'))
assert.ok(absatz.includes('Rechenzentren Falkenstein und Nürnberg') && absatz.includes('Eine Übermittlung in Drittländer findet nicht statt.'))
assert.ok(absatz.includes('Der Server speichert sie kurzzeitig in Protokollen und löscht sie nach 14 Tagen'))
+1 -1
View File
@@ -182,7 +182,7 @@ async function pruefungen() {
// Haken: Storno und vorzeitiger Beginn Pflicht (12.10. liegt in der Widerrufsfrist), Erinnerung freiwillig und nicht vorangekreuzt
eq(await wd.locator('label[for$="h-storno"]').innerText(), 'Ich habe die Stornobedingungen gelesen.', 'Haken Storno')
assert((await wd.locator('label[for$="h-vorzeitig"]').innerText()).startsWith('Ich verlange ausdrücklich, dass Salon Müller mit der Leistung vor Ablauf der Widerrufsfrist beginnt.'), 'Haken vorzeitig')
eq(await wd.locator('label[for$="h-erinnerung"]').innerText(), 'Ich möchte 24 Stunden vor dem Termin eine Erinnerung per E-Mail erhalten.', 'Haken Erinnerung')
eq(await wd.locator('label[for$="h-erinnerung"]').innerText(), 'Ich möchte 24 Stunden vor dem Termin eine Erinnerung per E-Mail erhalten. Diese Einwilligung kann ich jederzeit widerrufen.', 'Haken Erinnerung')
assert(!(await wd.locator('input[name=erinnerung]').isChecked()), 'Erinnerung darf nicht vorangekreuzt sein')
assert(await wd.locator('input[name=storno]').getAttribute('required') !== null && await wd.locator('input[name=vorzeitig]').getAttribute('required') !== null, 'Pflicht-Haken')
assert(await wd.locator('input[name=erinnerung]').getAttribute('required') === null, 'Erinnerung ist freiwillig')