Files
Webklar-Kundenbereich/test/termineVertragsunterlagen.test.js
T
ClaudeandClaude Opus 5.5 1b7aa9ebe3 Shop Review B I-8: Aktivierung des Webshops durch das Team nur mit Zustimmung in Textform
AVV Webshop, Vorbemerkung Abs. 2: Das Team darf den Webshop ohne Online-Bestellung
nur einschalten, wenn der Kunde vorher in Textform zugestimmt hat. Die Textform-Aktivierung
gilt jetzt wie beim Terminplaner auch fuer 'shop' (Modus on, Pilotadressen ausgenommen,
shopTextformNoetig): Nachweis in featureConsents mit Fassung (config.shop.fassung), Datum,
Weg, Mitarbeiter und dem Wortlaut der Erklaerungen der Bestelltexte, K16 mit
Leistungsbeschreibung und AVV als PDF. GET /api/team/funktionen/:kunde liefert zusaetzlich
shop { textformNoetig, avvVersion, links }.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 09:44:11 +00:00

830 lines
57 KiB
JavaScript

import { test, after, mock } from 'node:test'
import assert from 'node:assert/strict'
import { existsSync, readFileSync } from 'node:fs'
import { startAppwriteMock } from './support/appwriteMock.js'
import { mailSink } from './support/mailSink.js'
import { schemaKeys } from './support/schema.js'
import { useTestEnv } from './support/testEnv.js'
import { TERMINE_COLLECTIONS, TERMINE_DATETIMES } from '../server/services/termine/schema.js'
// Nachbau von Appwrite fuer den Test avv_keine_ip_in_buchung (eine echte Buchung ueber halte); config erst danach importieren
const aw = await startAppwriteMock({
schema: {
portalFeatures: schemaKeys('portalFeatures'),
termineConfig: schemaKeys('termineConfig'),
termineBookings: schemaKeys('termineBookings'),
termineClaims: schemaKeys('termineClaims'),
termineOutbox: schemaKeys('termineOutbox'),
},
datetimes: TERMINE_DATETIMES,
defaultLimit: 25,
})
after(() => aw.close())
useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' })
const { config } = await import('../server/config.js')
const { featureConfig } = await import('../server/services/featureRegistry.js')
const { LOESCHFRIST_TAGE } = await import('../server/services/termine/frist.js')
const { ENTWURF_TAGE } = await import('../server/services/termine/entwuerfe.js')
const { normalisiereEinstellung } = await import('../server/services/termine/einstellungen.js')
const { datenschutzAbsatz } = await import('../server/services/termine/rechtstexte.js')
const { setMailTransportForTests } = await import('../server/services/mailer.js')
const { resetOutboxForTests, warteAufVersand } = await import('../server/services/termine/outbox.js')
const { speichereEinstellung, resetKontextForTests } = await import('../server/services/termine/kontext.js')
const { halte } = await import('../server/services/termine/ablauf.js')
const { loeschBezugsfeld } = await import('../server/services/termine/buchungen.js')
after(() => setMailTransportForTests(null))
// Vertragsunterlagen des Terminplaners als Markdown: Leistungsbeschreibung (Task F4), AVV (Task F5).
// Die Markdown-Dateien sind die Quelle der ausgelieferten Seiten (scripts/build-legal-html.mjs, Task F6).
const lies = (rel) => readFileSync(new URL(`../${rel}`, import.meta.url), 'utf8').replace(/\r/g, '')
const norm = (text) => text.replace(/\s+/g, ' ').trim()
const V = config.termine.avvVersion
// ----------------------------------------------------------------------------------------- Aufbau eines Dokuments
/** Zerlegt eine Vertragsunterlage: h1, Kopfzeile und Abschnitte (## N. Titel) mit ihren Absätzen "(n) …". */
function lese(md) {
const zeilen = md.split('\n')
const h1 = zeilen.filter((z) => /^# /.test(z))
const abschnitte = []
let aktuell = null
for (const zeile of zeilen) {
const h2 = /^## (.+)$/.exec(zeile)
if (h2) {
aktuell = { titel: h2[1], zeilen: [] }
abschnitte.push(aktuell)
} else if (aktuell) {
aktuell.zeilen.push(zeile)
}
}
for (const a of abschnitte) {
a.nummer = /^(\d+)\. /.exec(a.titel)?.[1] ?? null
a.text = a.zeilen.join('\n')
a.absaetze = [...a.text.matchAll(/^\((\d+)\) /gm)].map((m) => Number(m[1]))
}
return { h1, kopf: zeilen[2], abschnitte, md }
}
const LB_MD = lies('docs/rechtliches/leistungsbeschreibung-terminplaner.md')
const LB = lese(LB_MD)
const abschnitt = (doc, nummer) => doc.abschnitte.find((a) => a.nummer === String(nummer))
// ----------------------------------------------------------------------------------------- Leistungsbeschreibung
test('lb_aufbau: Titel, Kopf mit Fassung und die elf Abschnitte in fester Nummerierung', () => {
assert.deepEqual(LB.h1, ['# Leistungsbeschreibung Terminplaner'])
assert.equal(LB_MD.split('\n')[0], '# Leistungsbeschreibung Terminplaner')
assert.equal(LB.kopf, `**Fassung ${V}** · Zusatzbedingungen zum Wartungs-Abo`)
assert.deepEqual(LB.abschnitte.map((a) => a.titel), [
'1. Geltung',
'2. Leistung',
'3. Nicht enthalten',
'4. Voraussetzungen und Pflichten des Kunden',
'5. Einrichtung und Preis',
'6. Verfügbarkeit und Störungen',
'7. Haftung für verpasste Termine',
'8. Ende der Leistung',
'9. Änderungen und Einstellung',
'10. Widerruf',
'11. Hinweise',
])
// Absätze sind lückenlos nummeriert: (1), (2), …
for (const a of LB.abschnitte) {
assert.ok(a.absaetze.length >= 1, `${a.titel}: keine Absätze`)
assert.deepEqual(a.absaetze, a.absaetze.map((_, i) => i + 1), `${a.titel}: Absätze nicht lückenlos`)
}
})
test('lb_sauber: keine Platzhalter, Entwurfsmarken, Kommentare oder Kanzlei-Marker', () => {
assert.doesNotMatch(LB_MD, /ENTWURF|Entwurf|Prüfpunkt|TODO|\{\{|\}\}|<!--|⚖|\bQ\d+\b/)
assert.doesNotMatch(LB_MD, /\[|\]|…\s*$/m, 'offene Klammer oder Auslassung am Zeilenende')
assert.doesNotMatch(LB_MD, /undefined|null|NaN/)
assert.ok(!/```|^>|<[a-z]/im.test(LB_MD), 'nur die Markdown-Teilmenge: kein Codeblock, Zitat oder rohes HTML')
})
test('lb_festwerte: Preis, Umsatzsteuer-Hinweis, Voraussetzung Wartungs-Abo und die Sätze von Nr. 8 wörtlich', () => {
const text = norm(LB_MD)
assert.ok(text.includes('149 €'))
assert.ok(text.includes('§ 19 UStG'))
assert.ok(text.includes(config.ustHinweis), 'Nr. 5: Hinweis wörtlich aus config.ustHinweis')
assert.ok(text.includes('ein Hosting-Abo genügt nicht'))
assert.ok(text.includes('Zusatzbedingungen zum Wartungs-Abo'))
const nr8 = norm(abschnitt(LB, 8).text)
for (const satz of [
'Mit dem Ende des Wartungs-Abos endet der Terminplaner. WEBklar schaltet ihn spätestens am folgenden Tag ab und informiert den Kunden per E-Mail. Das gilt auch bei einem Wechsel ins Hosting-Abo und beim Widerruf des Abschlusses oder eines Wechsels ins Wartungs-Abo.',
'WEBklar löscht alle Termindaten und die Einstellungen 30 Tage nach dem Abschalten.',
'Beginnt innerhalb dieser 30 Tage wieder ein Wartungs-Abo, schaltet WEBklar den Terminplaner mit den vorhandenen Daten und Einstellungen ohne weitere Kosten wieder ein. Nach der Löschung ist eine neue Einrichtung nötig (neuer Fragebogen, neuer Preis nach Nr. 5).',
'Endet das Wartungs-Abo, bevor WEBklar den Terminplaner freigeschaltet hat, erstattet WEBklar den Einrichtungspreis vollständig.',
]) {
assert.ok(nr8.includes(satz), `Nr. 8: Satz fehlt oder ist verändert: ${satz}`)
}
// Absatz 1, 3, 4, 5 stehen an der festgelegten Stelle
const absatz = (n) => norm(abschnitt(LB, 8).text.split(/^\(\d+\) /m)[n])
assert.ok(absatz(1).startsWith('Mit dem Ende des Wartungs-Abos endet der Terminplaner.'))
assert.ok(absatz(3).startsWith('WEBklar löscht alle Termindaten'))
assert.ok(absatz(4).startsWith('Beginnt innerhalb dieser 30 Tage'))
assert.ok(absatz(5).startsWith('Endet das Wartungs-Abo, bevor'))
// keine Einstellung ohne Grund
assert.ok(norm(abschnitt(LB, 9).text).includes('Eine Einstellung ohne einen Grund nach Absatz 2 ist ausgeschlossen.'))
})
test('lb_preis_gleich_register: Preis der LB = priceLabel des Registers (Nr. 5)', () => {
const nr5 = norm(abschnitt(LB, 5).text)
const preis = /kostet (\d+ €) einmalig/.exec(nr5)
assert.ok(preis, 'Nr. 5 nennt den Preis')
assert.equal(preis[1], featureConfig('termine').priceLabel)
assert.equal(config.termineSetup.amountCents, 14900)
assert.equal(`${config.termineSetup.amountCents / 100} €`, preis[1])
// der Preis steht nur einmal in der LB
assert.equal(norm(LB_MD).split('149 €').length - 1, 1)
})
test('lb_fristen_gleich_code: 30 Tage bis zur Löschung = LOESCHFRIST_TAGE (termine/frist.js)', () => {
assert.equal(LOESCHFRIST_TAGE, 30)
const nr8 = norm(abschnitt(LB, 8).text)
const loeschung = /löscht alle Termindaten und die Einstellungen (\d+) Tage nach dem Abschalten/.exec(nr8)
const rueckkehr = /Beginnt innerhalb dieser (\d+) Tage wieder/.exec(nr8)
assert.ok(loeschung && rueckkehr)
assert.equal(Number(loeschung[1]), LOESCHFRIST_TAGE)
assert.equal(Number(rueckkehr[1]), LOESCHFRIST_TAGE)
// die Abschalt-Frist "spätestens am folgenden Tag" steht nur in Nr. 8 Abs. 1
assert.equal(norm(LB_MD).split('spätestens am folgenden Tag').length - 1, 1)
})
/**
* Verweise: "Nr. N [Abs. M]" auf diese LB (ohne Zusatz), "Nummer N des AVV Terminplaner", "Leistungsbeschreibung B Nr. N der
* Abo-Bedingungen" und "§ N [Abs. M] der Abo-Bedingungen" müssen auf vorhandene Abschnitte zeigen.
*/
test('lb_verweise: jedes „Nr. N“ auf LB oder AVV und jedes „§ N der Abo-Bedingungen“ existiert', () => {
const text = norm(LB_MD)
// 1. Verweise auf diese Leistungsbeschreibung: "Nr. N", "Nr. N Abs. M", "nach Nr. N"
const eigene = []
for (const m of text.matchAll(/\bNr\. (\d+)(?: Abs\. (\d+))?(?! der Abo-Bedingungen)/g)) {
const davor = text.slice(Math.max(0, m.index - 40), m.index)
if (/Leistungsbeschreibung B $/.test(davor)) continue // Abo-Bedingungen, siehe unten
eigene.push({ nr: m[1], abs: m[2], zitat: m[0] })
}
assert.ok(eigene.length >= 8, `zu wenige interne Verweise gefunden: ${eigene.length}`)
for (const v of eigene) {
const a = abschnitt(LB, v.nr)
assert.ok(a, `„${v.zitat}“: Abschnitt ${v.nr} gibt es nicht`)
if (v.abs) assert.ok(a.absaetze.includes(Number(v.abs)), `„${v.zitat}“: Absatz ${v.abs} gibt es nicht in Nr. ${v.nr}`)
}
// 2. Verweise auf den AVV Terminplaner (Vorbemerkung, Nr. 1 bis 12, Anlage): Nummernbereich; ist der AVV schon geschrieben, auch seine Überschriften
const avvNummern = [...text.matchAll(/Nummer (\d+)(?: Abs\. (\d+))? (?:des )?AVV Terminplaner|AVV Terminplaner Nummer (\d+)(?: Abs\. (\d+))?/g)]
assert.ok(avvNummern.length >= 2)
const avvPfad = 'docs/rechtliches/avv-terminplaner.md'
const avv = existsSync(new URL(`../${avvPfad}`, import.meta.url)) ? lese(lies(avvPfad)) : null
for (const m of avvNummern) {
const n = Number(m[1] ?? m[3])
assert.ok(n >= 1 && n <= 12, `„${m[0]}“: der AVV hat Nr. 1 bis 12`)
if (avv) assert.ok(abschnitt(avv, n), `„${m[0]}“: Abschnitt ${n} fehlt im AVV`)
}
// 3. Leistungsbeschreibung B Nr. 3 der Abo-Bedingungen: "Nicht enthalten" im Wartungs-Abo
const abo = lies('public/abo-bedingungen.html')
const wartung = abo.slice(abo.indexOf('<h2>Wartungs-Abo</h2>'), abo.indexOf('<h2 id="widerruf">'))
assert.ok(wartung.includes('<h3>3. Nicht enthalten</h3>'))
assert.ok(text.includes('Leistungsbeschreibung B Nr. 3 der Abo-Bedingungen („Rechtstexte … nicht enthalten“)'))
assert.ok(/Texte, Bilder und Rechtstexte \(Impressum, Datenschutzerklärung\)/.test(wartung.replace(/<[^>]+>/g, '')))
// 4. Paragrafen der Abo-Bedingungen: § N existiert, Absatz M existiert (Aufzählungspunkte bzw. Überschriften "(M) …" des Paragrafen)
const paragrafen = [...text.matchAll(/§§? (\d+)(?: Abs\. (\d+)(?: und (\d+))?)?(?: und (\d+))? der Abo-Bedingungen/g)]
assert.ok(paragrafen.length >= 7, `zu wenige Verweise auf die Abo-Bedingungen: ${paragrafen.length}`)
const nummern = new Set()
for (const m of paragrafen) {
for (const n of [m[1], m[4]].filter(Boolean)) nummern.add(n)
const start = abo.indexOf(`<h2 id="p${m[1]}">§ ${m[1]} `)
assert.ok(start > 0, `„${m[0]}“: § ${m[1]} gibt es in den Abo-Bedingungen nicht`)
const ende = abo.indexOf('<h2', start + 10)
const stueck = abo.slice(start, ende)
const anzahl = Math.max((stueck.match(/<li>/g) || []).length, (stueck.match(/<h3>\(\d+\)/g) || []).length)
for (const abs of [m[2], m[3]].filter(Boolean)) {
assert.ok(Number(abs) <= anzahl, `„${m[0]}“: § ${m[1]} hat keinen Absatz ${abs}`)
}
}
// "§§ 12 und 13 der Abo-Bedingungen" nennt beide Paragrafen
assert.ok(nummern.has('12') && nummern.has('13'))
for (const n of ['3', '4', '5', '6', '12', '13']) assert.ok(nummern.has(n), `§ ${n} der Abo-Bedingungen wird zitiert`)
// § 327r BGB und § 3 BFSG sind Gesetzesverweise, keine Verweise in die Abo-Bedingungen
assert.ok(text.includes('§ 327r Abs. 3 BGB') && text.includes('§ 3 Abs. 3 BFSG'))
// 5. Die Anlage zur Widerrufsbelehrung heißt wie die Überschrift der Datei
const anlage = lies('docs/rechtliches/widerrufsbelehrung-funktionen.md').split('\n')[0].replace(/^# /, '')
assert.ok(text.includes(`„${anlage}“`), 'Nr. 10 nennt die Anlage mit dem Titel der Widerrufsbelehrung')
})
test('lb_ohne_du: Dritte Person und Sie-Form, keine Du-Form', () => {
assert.doesNotMatch(LB_MD, /\bdein|\bdu\b|\bdir\b|\bdich\b|\beuch\b|\beuer/i)
assert.ok(/\bder Kunde\b/.test(LB_MD))
})
test('lb_bfsg_ohne_konformitaetszusage: keine Zusage, das Widget erfülle WCAG oder EN 301 549', () => {
const nr11 = norm(abschnitt(LB, 11).text)
assert.ok(nr11.includes('Eine Konformität nach WCAG 2.1 AA oder EN 301 549 sagt WEBklar nicht zu.'))
assert.ok(nr11.includes('Ob der Kunde als Dienstleistungserbringer unter das BFSG fällt (Ausnahme für Kleinstunternehmen, § 3 Abs. 3 BFSG), entscheidet und prüft der Kunde.'))
assert.ok(nr11.includes('Das Widget ist für die Bedienung mit Tastatur und Hilfstechniken gestaltet (Beschriftungen, Fokusführung, Fehlermeldungen als Live-Region).'))
const alles = norm(LB_MD)
assert.doesNotMatch(alles, /WCAG[^.]*\b(entspricht|erfüllt|erfüllen|barrierefrei|konform ist|sind konform)\b/i)
assert.doesNotMatch(alles, /\b(entspricht|erfüllt|erfüllen)\b[^.]*(WCAG|EN 301 549|BFSG)/i)
assert.doesNotMatch(alles, /vollständig barrierefrei|barrierefrei nach/i)
})
test('lb_inhalt: Rangfolge, Gästetexte, Gesundheitsbereich, Datenschutz-Absatz, Zahlung nur vor Ort', () => {
const nr1 = norm(abschnitt(LB, 1).text)
assert.ok(nr1.includes('Bei Widersprüchen geht für den Terminplaner diese Leistungsbeschreibung vor, in Datenschutzfragen der AVV Terminplaner.'))
assert.ok(nr1.includes('Leistungsbeschreibung B Nr. 3 der Abo-Bedingungen („Rechtstexte … nicht enthalten“) gilt nicht für den Datenschutz-Absatz'))
const nr2 = norm(abschnitt(LB, 2).text)
assert.ok(nr2.includes('Bezahlt wird nur vor Ort.'))
assert.ok(nr2.includes('Mustertexte der Standardkonfiguration'))
for (const wort of ['geheimen Kalender-Link (ICS)', 'CSV-Datei', 'Missbrauchsschutz', 'Bestätigungslink', 'Erinnerung (nur mit seinem Haken)', 'Widerrufs']) {
assert.ok(nr2.includes(wort), `Nr. 2 nennt ${wort}`)
}
const nr3 = norm(abschnitt(LB, 3).text)
for (const wort of ['SMS', 'Online-Zahlung', 'Kalender-Abgleich', 'Konten für Gäste', 'Marketing-E-Mails', 'Gruppentermine', 'Mehrsprachigkeit', 'Gesundheitsbereich', 'zweites Widget', 'Sonderdesign', '§ 4 der Abo-Bedingungen']) {
assert.ok(nr3.includes(wort), `Nr. 3 nennt ${wort}`)
}
const nr4 = norm(abschnitt(LB, 4).text)
assert.ok(nr4.includes('Der Terminplaner ist nicht für Angebote im Gesundheitsbereich bestimmt'))
assert.ok(nr4.includes('WEBklar darf die Einrichtung für eine solche Website ablehnen oder den Terminplaner dort abschalten.'))
assert.ok(nr4.includes('Kinder unter 16 Jahren'))
assert.ok(nr4.includes('wird nicht auf das Stundenkontingent angerechnet'))
assert.ok(nr4.includes('Textbaustein'))
// Keine Gesundheitsangaben anfordern: der Text fordert nirgends welche an
assert.doesNotMatch(LB_MD, /Gesundheitsdaten (erfassen|speichern)|Diagnose|Patient/i)
const nr5 = norm(abschnitt(LB, 5).text)
for (const wort of ['Fragebogen', 'Einbau', 'Abnahme', 'Freischaltung', 'Stripe', 'Rechnung', 'keine verbindliche Zusage eines Termins', 'weder auf das Kontingent angerechnet noch damit bezahlt']) {
assert.ok(nr5.includes(wort), `Nr. 5 nennt ${wort}`)
}
const nr7 = norm(abschnitt(LB, 7).text)
assert.ok(nr7.includes('§ 13 Abs. 1 und 2 der Abo-Bedingungen'))
const nr10 = norm(abschnitt(LB, 10).text)
assert.ok(nr10.includes('Mit dem Widerruf endet der Terminplaner.'))
})
// ============================================================================================ AVV Terminplaner (Task F5)
const AVV_PFAD = 'docs/rechtliches/avv-terminplaner.md'
const AVV_MD = lies(AVV_PFAD)
const AVV = lese(AVV_MD)
const ABO = lies('public/abo-bedingungen.html')
const JOBS = lies('server/services/termine/jobs.js')
const OUTBOX = lies('server/services/termine/outbox.js')
const avvNr = (n) => norm(abschnitt(AVV, n).text)
const absatzTexte = (a) => a.text.split(/^\(\d+\) /m).slice(1).map(norm)
test('avv_aufbau: Titel, Kopf mit Fassung, Parteien, Vorbemerkung, Nr. 1 bis 12 und Anlage', () => {
assert.deepEqual(AVV.h1, ['# Auftragsverarbeitungsvertrag Terminplaner (Art. 28 DSGVO)'])
assert.equal(AVV_MD.split('\n')[0], '# Auftragsverarbeitungsvertrag Terminplaner (Art. 28 DSGVO)')
assert.equal(AVV.kopf, `**Fassung ${V}**`)
assert.equal(
AVV_MD.split('\n')[4],
'zwischen dem Kunden als Verantwortlichem („Kunde“) und der WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler, vertreten durch die Gesellschafter Kenso Grimm und Justin Klein, als Auftragsverarbeiter („WEBklar“).',
)
assert.deepEqual(AVV.abschnitte.map((a) => a.titel), [
'Vorbemerkung: Abschluss und Verhältnis zu anderen Verträgen',
'1. Gegenstand und Dauer',
'2. Art und Zweck der Verarbeitung',
'3. Datenarten und Betroffene',
'4. Weisungen',
'5. Vertraulichkeit',
'6. Technische und organisatorische Maßnahmen',
'7. Unterauftragsverarbeiter',
'8. Unterstützung des Kunden, Betroffenenrechte',
'9. Löschung und Rückgabe',
'10. Nachweise und Kontrollen',
'11. Pflichten des Kunden',
'12. Haftung, Änderungen, Schlussbestimmungen',
'Anlage: Technische und organisatorische Maßnahmen des Terminplaners (Art. 32 DSGVO)',
])
// Absätze sind lückenlos nummeriert; Nr. 5 und Nr. 10 bestehen aus einem Absatz ohne Nummer, die Anlage ist eine Tabelle
for (const a of AVV.abschnitte) {
if (['5', '10'].includes(a.nummer) || a.titel.startsWith('Anlage:')) {
assert.deepEqual(a.absaetze, [], `${a.titel}: kein nummerierter Absatz`)
} else {
assert.ok(a.absaetze.length >= 1, `${a.titel}: keine Absätze`)
assert.deepEqual(a.absaetze, a.absaetze.map((_, i) => i + 1), `${a.titel}: Absätze nicht lückenlos`)
}
}
// Vorbemerkung hat drei Absätze (Abschluss, Mitarbeiter, Verhältnis zu Anlage 1)
assert.deepEqual(AVV.abschnitte[0].absaetze, [1, 2, 3])
})
test('avv_sauber: keine Platzhalter, Entwurfsmarken, Kommentare oder Kanzlei-Marker', () => {
assert.doesNotMatch(AVV_MD, /ENTWURF|Entwurf|Prüfpunkt|TODO|\{\{|\}\}|<!--|⚖|\bQ\d+\b/)
assert.doesNotMatch(AVV_MD, /\[|\]|…\s*$/m, 'offene Klammer oder Auslassung am Zeilenende')
assert.doesNotMatch(AVV_MD, /undefined|null|NaN/)
assert.ok(!/```|^>|<[a-z]/im.test(AVV_MD), 'nur die Markdown-Teilmenge: kein Codeblock, Zitat oder rohes HTML')
// keine Du-Form: der AVV spricht vom Kunden in der dritten Person
assert.doesNotMatch(AVV_MD, /\bdein|\bdu\b|\bdir\b|\bdich\b|\beuch\b|\beuer/i)
assert.ok(/\bder Kunde\b/.test(AVV_MD))
// Tabelle: genau zwei Zellen je Zeile, kein weiteres | im Zelleninhalt
const zeilen = AVV_MD.split('\n').filter((z) => z.startsWith('|'))
assert.ok(zeilen.length >= 13, 'Kopf, Trennzeile und die Zeilen der Anlage')
for (const z of zeilen) assert.equal(z.split('|').length, 4, `Tabellenzeile mit mehr als zwei Zellen: ${z.slice(0, 40)}`)
assert.equal(zeilen[0], '| Bereich | Maßnahmen |')
assert.equal(zeilen[1], '|---|---|')
})
test('avv_unterauftragsverarbeiter: Nr. 7 nennt Hetzner mit Anschrift, Falkenstein und Nürnberg, kein Drittland', () => {
const nr7 = avvNr(7)
const abs1 = absatzTexte(abschnitt(AVV, 7))[0]
assert.ok(abs1.includes('Hetzner Online GmbH, Industriestraße 25, 91710 Gunzenhausen'))
assert.ok(abs1.includes('Falkenstein') && abs1.includes('Nürnberg'))
assert.match(abs1, /Betrieb des Servers[\s\S]*Sicherung[\s\S]*Falkenstein[\s\S]*Versand der Buchungs-E-Mails[\s\S]*Webhostings[\s\S]*Nürnberg/)
assert.ok(abs1.includes('Eine Übermittlung in Drittländer findet nicht statt.'))
assert.doesNotMatch(nr7, /\bUSA\b|Vereinigte(n)? Staaten|Standardvertragsklauseln|Angemessenheitsbeschluss|Data Privacy Framework|Drittland(?!länder)/)
// Muster Analyse-AVV Nr. 7: vier Wochen Vorlauf, zwei Wochen Widerspruch, Verpflichtung nach Art. 28 Abs. 4
assert.ok(nr7.includes('mindestens vier Wochen vorher in Textform'))
assert.ok(nr7.includes('innerhalb von zwei Wochen nach Zugang der Mitteilung aus wichtigem datenschutzrechtlichem Grund in Textform widersprechen'))
assert.ok(nr7.includes('Art. 28 Abs. 4 DSGVO'))
// Stripe betrifft nur den Vertrag zwischen Kunde und WEBklar
assert.ok(nr7.includes('Die Zahlung der Einrichtung über Stripe betrifft nur den Vertrag zwischen dem Kunden und WEBklar.'))
assert.ok(nr7.includes('Stripe ist deshalb kein Unterauftragsverarbeiter dieses Vertrags.'))
// dieselben Orte wie Anlage 3 der Abo-Bedingungen (Server: Falkenstein, E-Mail-Postfächer/Webhosting: Nürnberg)
assert.ok(ABO.includes('Hetzner Online GmbH, Industriestraße 25, 91710 Gunzenhausen'))
assert.ok(ABO.includes('Deutschland (Rechenzentrum Falkenstein)') && ABO.includes('Deutschland (Rechenzentrum Nürnberg)'))
})
/** Die Konstante `const NAME = N * (STUNDE|TAG)_MS` aus einem Quelltext als { n, einheit }. */
function konstante(quelle, name) {
const m = new RegExp(`const ${name} = (\\d+) \\* (STUNDE|TAG)_MS`).exec(quelle)
assert.ok(m, `${name} fehlt im Quelltext`)
return { n: Number(m[1]), einheit: m[2] === 'STUNDE' ? 'Stunden' : 'Tage' }
}
/** Eingabe einer gültigen Einstellung (wie test/termineAblaufGast.test.js, ohne aufbewahrungTage). */
const R1 = 'r_aaaaaaa1'
const S_SOFORT = 's_bbbbbbb1'
const eingabeEinstellung = (over = {}) => ({
betrieb: {
name: 'Salon Müller',
email: 'info@salon.de',
telefon: '06301 1234',
adresse: 'Hauptstr. 1, 67722 Winnweiler',
websiteUrl: 'https://salon.de',
impressumUrl: 'https://salon.de/impressum',
datenschutzUrl: 'https://salon.de/datenschutz',
},
anrede: 'sie',
bundesland: 'RP',
ressourcen: [{ id: R1, name: 'Anna', plan: { 1: [['09:00', '18:00']] } }],
leistungen: [{ id: S_SOFORT, name: 'Damenschnitt', dauer: 45, pufferNach: 5, preisArt: 'fest', preisCent: 4500, buchungsart: 'sofort', ressourcen: [R1] }],
regeln: { raster: 15, vorlaufMin: 120, horizontTage: 90, absageBisStd: 24, maxOffeneJeEmail: 3, personWaehlbar: true },
gastFelder: { telefon: 'optional', nachricht: 'aus', zusatz: [] },
erinnerung: { anbieten: true, stundenVorher: 24 },
storno: { text: 'Kostenlose Absage bis 24 Stunden vor dem Termin.' },
recht: { gaesteSind: 'verbraucher', freizeit: false },
zahlung: { modus: 'vorort', vorOrtArten: ['bar', 'ec'] },
...over,
})
const JETZT = new Date('2026-10-05T08:00:00.000Z') // Montag, 10:00 Berlin
const einstellungMit = (over = {}) => normalisiereEinstellung(eingabeEinstellung(over), { now: JETZT })
test('avv_loeschfristen_gleich_code: Nr. 9 nennt genau die Fristen, die der Löschlauf anwendet', () => {
const nr9 = avvNr(9)
const zahlen = (re) => {
const m = re.exec(nr9)
assert.ok(m, `Nr. 9: Satz fehlt oder ist verändert: ${re}`)
return m.slice(1).map(Number)
}
// (1) nicht bestätigt: 48 Stunden nach Ablauf der Bestätigungsfrist von 30 Minuten (jobs.js VERFALLEN_BEHALTEN_MS, config.termine.holdMinutes)
const [verfall, haltezeit] = zahlen(/Nicht bestätigte Buchungen löscht WEBklar (\d+) Stunden nach Ablauf der Bestätigungsfrist von (\d+) Minuten\./)
assert.deepEqual(konstante(JOBS, 'VERFALLEN_BEHALTEN_MS'), { n: verfall, einheit: 'Stunden' })
assert.equal(config.termine.holdMinutes, haltezeit)
// (2) abgesagt/abgelehnt: 30 Tage (jobs.js ABGESAGT_BEHALTEN_MS)
const [abgesagt] = zahlen(/Abgesagte und abgelehnte Termine löscht WEBklar (\d+) Tage nach der Absage oder Ablehnung\./)
assert.deepEqual(konstante(JOBS, 'ABGESAGT_BEHALTEN_MS'), { n: abgesagt, einheit: 'Tage' })
// (3) bestätigt/erledigt: Vorgabe 90, einstellbar 14 bis 365 Tage nach Terminende (jobs.js AUFBEWAHRUNG_*, einstellungen.js)
const [vorgabe, minimum, maximum] = zahlen(/die Vorgabe sind (\d+) Tage, einstellbar sind (\d+) bis (\d+) Tage\./)
const konst = (name) => Number(new RegExp(`const ${name} = (\\d+)`).exec(JOBS)?.[1])
assert.deepEqual([konst('AUFBEWAHRUNG_VORGABE'), konst('AUFBEWAHRUNG_MIN'), konst('AUFBEWAHRUNG_MAX')], [vorgabe, minimum, maximum])
assert.equal(einstellungMit().einstellung.aufbewahrungTage, vorgabe, 'Vorgabe der Einstellung')
for (const tage of [minimum, maximum]) assert.equal(einstellungMit({ aufbewahrungTage: tage }).einstellung.aufbewahrungTage, tage)
for (const tage of [minimum - 1, maximum + 1]) assert.ok(einstellungMit({ aufbewahrungTage: tage }).fehler?.aufbewahrungTage, `${tage} Tage sind nicht erlaubt`)
assert.ok(nr9.includes('gerechnet ab dem Ende des Termins'))
// (4) Versandwarteschlange: gesendet 7 Tage, nicht zustellbar 48 Stunden (outbox.js GESENDET_BEHALTEN_MS, FEHLER_BEHALTEN_MS)
const [gesendet, fehler] = zahlen(/löscht den Eintrag (\d+) Tage nach dem Versand\. E-Mails, die nicht zugestellt werden können, löscht WEBklar mit Empfängeradresse und Inhalt (\d+) Stunden nach dem endgültigen Fehlschlag\./)
assert.deepEqual(konstante(OUTBOX, 'GESENDET_BEHALTEN_MS'), { n: gesendet, einheit: 'Tage' })
assert.deepEqual(konstante(OUTBOX, 'FEHLER_BEHALTEN_MS'), { n: fehler, einheit: 'Stunden' })
assert.ok(nr9.includes('leert WEBklar die Empfängeradresse und den Inhalt einer E-Mail nach dem Versand'))
// (6) Abschalten: LOESCHFRIST_TAGE (termine/frist.js), dieselbe Zahl wie in der Leistungsbeschreibung Nr. 8
const [abschalten] = zahlen(/löscht WEBklar alle Termindaten und die Einstellungen (\d+) Tage danach\./)
assert.equal(abschalten, LOESCHFRIST_TAGE)
assert.equal(LOESCHFRIST_TAGE, 30)
assert.ok(JOBS.includes('LOESCHFRIST_TAGE * TAG_MS'), 'jobs.js rechnet mit LOESCHFRIST_TAGE')
assert.ok(nr9.includes('Beginnt innerhalb dieser Frist wieder ein Wartungs-Abo, schaltet WEBklar den Terminplaner mit den vorhandenen Daten und Einstellungen wieder ein (Nr. 8 Abs. 4 der Leistungsbeschreibung).'))
assert.ok(nr9.includes('kann der Kunde die Termine im Kundenportal als CSV-Datei exportieren'))
// (7) Entwürfe: ENTWURF_TAGE (entwuerfe.js)
const [entwurf] = zahlen(/Entwürfe aus dem Fragebogen, aus denen keine Bestellung geworden ist, löscht WEBklar (\d+) Tage nach der letzten Änderung\./)
assert.equal(ENTWURF_TAGE, entwurf)
// (8) Sicherung: bis zu sieben Tage (§ 3 Abs. 5 der Abo-Bedingungen)
assert.ok(nr9.includes('Gelöschte Daten bleiben dort bis zu sieben Tage erhalten'))
assert.ok(ABO.includes('Verfügbar sind jeweils die letzten sieben Tagessicherungen.'))
// der Löschlauf läuft täglich und löscht nie vor der Frist (Deckel: der Rest folgt am nächsten Tag)
assert.ok(nr9.includes('Der Löschlauf läuft täglich; gelöscht wird frühestens nach Ablauf der jeweiligen Frist.'))
assert.ok(lies('server/index.js').includes('setInterval(termineTagesJob, 24 * 60 * 60 * 1000)'))
})
test('avv_gleich_datenschutz_absatz: Fristen und Orte stimmen mit dem Datenschutz-Absatz überein, den der Kunde veröffentlicht', () => {
const { einstellung } = einstellungMit()
const absatz = norm(datenschutzAbsatz(einstellung, 'sie'))
assert.ok(absatz.includes(`Bestätigungslink gilt ${config.termine.holdMinutes} Minuten`))
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(`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'))
// unbeantwortete Terminanfragen: derselbe Satz im Datenschutz-Absatz des Kunden und im AVV (Nr. 9 Abs. 2), und der Code tut es
assert.ok(absatz.includes('Terminanfragen, die wir bis zum Terminbeginn nicht beantworten, behandeln wir als abgelehnt.'))
const nr9abs2 = absatzTexte(abschnitt(AVV, 9))[1]
assert.ok(nr9abs2.startsWith('Abgesagte und abgelehnte Termine löscht WEBklar 30 Tage nach der Absage oder Ablehnung.'))
assert.ok(nr9abs2.includes('Terminanfragen, die der Kunde bis zum angefragten Terminbeginn nicht beantwortet, behandelt der Terminplaner als abgelehnt; die 30 Tage laufen dann ab diesem Zeitpunkt.'))
assert.ok(JOBS.includes("angefragt mit Beginn in der Vergangenheit => abgelehnt vom System"), 'jobs.js lehnt verstrichene Anfragen ab')
assert.equal(loeschBezugsfeld('angefragt'), undefined, 'eine Anfrage wird nie unmittelbar gelöscht, erst als abgelehnte nach 30 Tagen')
assert.equal(loeschBezugsfeld('abgelehnt'), 'updatedAt', 'die 30 Tage laufen ab der letzten Änderung, also ab der Ablehnung durch das System')
// der AVV nennt dieselben Zahlen
const nr3 = avvNr(3)
assert.ok(nr3.includes('In den Server-Protokollen stehen sie 14 Tage (Anlage 2 der Abo-Bedingungen).'))
})
test('avv_keine_ip_in_buchung: weder das Schema noch eine über halte gehaltene Buchung enthält IP-Adresse oder Browser-Kennzeichen', async () => {
const nameIp = /^(ip|ipAdresse|clientIp|userAgent)$/i
for (const [collection, def] of Object.entries(TERMINE_COLLECTIONS)) {
const treffer = def.attributes.map((a) => a.key).filter((k) => nameIp.test(k))
assert.deepEqual(treffer, [], `${collection}: Attribut für IP-Adresse oder Browser-Kennzeichen`)
}
// ein loses /ip/ träfe stripeSessionId und stripePaymentIntent: das Muster oben ist absichtlich genau
assert.ok(TERMINE_COLLECTIONS.termineBookings.attributes.some((a) => a.key === 'stripeSessionId'))
// eine echte gehaltene Buchung: der Nachweis trägt keinen Schlüssel ip oder userAgent (auch nicht verschachtelt)
const KUNDE = 'c_kunde01'
const PROJEKT = 'p_projekt01'
const FEATURE = 'f_p_projekt01_termine'
const vorher = { mode: config.termine.mode, from: config.smtp.from, basis: config.publicBaseUrl }
const log = { error: mock.method(console, 'error', () => {}), warn: mock.method(console, 'warn', () => {}) }
try {
config.termine.mode = 'pilot'
config.smtp.from = 'noreply@webklar.com'
config.publicBaseUrl = 'https://project.webklar.com'
resetKontextForTests()
resetOutboxForTests()
const sink = mailSink()
setMailTransportForTests(sink)
const feature = { $id: FEATURE, customerId: KUNDE, projectId: PROJEKT, featureKey: 'termine', state: 'active', enabled: true, siteKey: 'abcdefghijklmnop' }
aw.seed('portalFeatures', feature)
aw.seed('websiteProjects', { $id: PROJEKT, customerId: KUNDE, subdomain: 'salon' })
aw.seed('customers', { $id: KUNDE, name: 'Silke Müller', email: 'silke@example.com' })
const { einstellung, fehler } = einstellungMit()
assert.equal(fehler, undefined)
await speichereEinstellung(feature, einstellung, { version: 0, now: JETZT })
const ctx = { feature, project: { $id: PROJEKT, subdomain: 'salon' }, einstellung, version: 1 }
const erg = await halte(
ctx,
{
leistung: S_SOFORT, ressource: 'egal', tag: '2026-10-26', zeit: '10:00', name: 'Max Mustermann', email: 'max@example.com',
telefon: '', nachricht: '', zusatz: {}, erinnerung: true, zustimmungen: { storno: true, vorzeitig: false },
},
JETZT,
)
await warteAufVersand()
assert.equal(erg.status, 'gehalten')
const buchungen = [...aw.docs('termineBookings').values()]
assert.equal(buchungen.length, 1)
const nachweis = JSON.parse(buchungen[0].nachweis)
assert.ok(nachweis.fassung && nachweis.datenschutz, 'der Nachweis enthält Fassung und Hash des Datenschutz-Hinweises')
// genau die Bestandteile, die AVV Nr. 3 Abs. 1 und Nr. 8 Abs. 5 nennen: Fassung, Zeitpunkt, Knopftext, Anrede, Hashes (keine weiteren Klartexte)
const erlaubt = new Set(['fassung', 'am', 'knopf', 'anrede', 'datenschutz', 'storno', 'widerruf', 'vorzeitig', 'erinnerung'])
assert.deepEqual(Object.keys(nachweis).filter((k) => !erlaubt.has(k)), [])
for (const k of ['fassung', 'am', 'knopf', 'anrede']) assert.equal(typeof nachweis[k], 'string', k)
assert.deepEqual(Object.keys(nachweis.storno), ['version', 'hash', 'haken', 'am'])
assert.deepEqual(Object.keys(nachweis.erinnerung), ['stunden', 'hash', 'am'])
const schluessel = (wert) => (wert && typeof wert === 'object' ? Object.entries(wert).flatMap(([k, v]) => [k, ...schluessel(v)]) : [])
assert.deepEqual(schluessel(nachweis).filter((k) => nameIp.test(k)), [])
// der Nachweis hält Hashes, keine Texte: jeder Hash ist 64 Hexzeichen
for (const hash of [nachweis.datenschutz, nachweis.storno?.hash, nachweis.widerruf?.hash, nachweis.erinnerung?.hash]) assert.match(hash, /^[0-9a-f]{64}$/)
// auch sonst enthält kein Feld der Buchung eine Adresse der Form n.n.n.n
assert.doesNotMatch(JSON.stringify(buchungen[0]), /\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/)
} finally {
log.error.mock.restore()
log.warn.mock.restore()
setMailTransportForTests(null)
config.termine.mode = vorher.mode
config.smtp.from = vorher.from
config.publicBaseUrl = vorher.basis
}
})
/** Jedes Attribut einer Buchung ist in AVV Nr. 3 Abs. 1 genannt (Wortlaut) oder eine technische Kennung/Eigenschaft des Dienstes. */
const BUCHUNG_IM_AVV = {
gastName: 'Name und E-Mail-Adresse', gastEmail: 'Name und E-Mail-Adresse', gastTelefon: 'Telefonnummer', gastNachricht: 'eine Nachricht', gastZusatz: 'Zusatzfelder',
leistungName: 'die gebuchte Leistung', ressourceName: 'die Person oder der Platz', startsAt: 'der Termin', endsAt: 'der Termin', blockEndsAt: 'der Termin',
status: 'der Status der Buchung mit den Zeitpunkten', heldUntil: 'der Status der Buchung mit den Zeitpunkten', bestaetigtAt: 'der Status der Buchung mit den Zeitpunkten',
angenommenAt: 'der Status der Buchung mit den Zeitpunkten', abgesagtAt: 'der Status der Buchung mit den Zeitpunkten', abgesagtVon: 'der Status der Buchung mit den Zeitpunkten',
widerrufenAt: 'der Status der Buchung mit den Zeitpunkten', createdAt: 'der Status der Buchung mit den Zeitpunkten', updatedAt: 'der Status der Buchung mit den Zeitpunkten',
absageGrund: 'Absagegrund und Vermerke zu einer Absage oder einem Widerruf', nachweis: 'der Nachweis der Zustimmungen',
preisCent: 'Preis, Zahlart, Zahlungsstatus und die Angabe, ob als Verbraucher gebucht wurde', zahlart: 'Preis, Zahlart, Zahlungsstatus und die Angabe, ob als Verbraucher gebucht wurde',
zahlStatus: 'Preis, Zahlart, Zahlungsstatus und die Angabe, ob als Verbraucher gebucht wurde', verbraucher: 'Preis, Zahlart, Zahlungsstatus und die Angabe, ob als Verbraucher gebucht wurde',
erinnerung: 'der Wunsch nach einer Erinnerung', erinnerungGesendetAt: 'der Wunsch nach einer Erinnerung',
}
const BUCHUNG_TECHNISCH = ['featureId', 'customerId', 'projectId', 'leistungId', 'ressourceId', 'preisArt', 'buchungsart', 'quelle', 'verifyHash', 'manageHash', 'stripeSessionId', 'stripePaymentIntent', 'icsSequenz']
test('avv_datenarten_decken_schema: jedes Attribut einer Buchung steht in Nr. 3 Abs. 1 oder ist eine technische Kennung', () => {
const nr3abs1 = norm(absatzTexte(abschnitt(AVV, 3))[0])
const attribute = TERMINE_COLLECTIONS.termineBookings.attributes.map((a) => a.key)
const unbekannt = attribute.filter((k) => !Object.hasOwn(BUCHUNG_IM_AVV, k) && !BUCHUNG_TECHNISCH.includes(k))
assert.deepEqual(unbekannt, [], 'neues Attribut der Buchung: in AVV Nr. 3 Abs. 1 aufnehmen oder als technisch einstufen')
for (const [k, wort] of Object.entries(BUCHUNG_IM_AVV)) {
assert.ok(attribute.includes(k), `${k} gibt es nicht mehr im Schema`)
assert.ok(nr3abs1.includes(wort), `${k}: „${wort}“ fehlt in Nr. 3 Abs. 1`)
}
for (const k of BUCHUNG_TECHNISCH) assert.ok(attribute.includes(k), `${k} gibt es nicht mehr im Schema`)
})
test('avv_verweise_auf_lb: jedes „Nr. N Abs. M der Leistungsbeschreibung“ und jedes „Nummer N“ im AVV zeigt auf Vorhandenes', () => {
const text = norm(AVV_MD)
// 1. Verweise auf die Leistungsbeschreibung Terminplaner
const lbVerweise = [...text.matchAll(/\b(?:Nr\.|Nummer) (\d+)(?: Abs\. (\d+))? der Leistungsbeschreibung/g)]
assert.ok(lbVerweise.length >= 8, `zu wenige Verweise auf die Leistungsbeschreibung: ${lbVerweise.length}`)
for (const m of lbVerweise) {
const a = abschnitt(LB, m[1])
assert.ok(a, `„${m[0]}“: Abschnitt ${m[1]} gibt es in der Leistungsbeschreibung nicht`)
if (m[2]) assert.ok(a.absaetze.includes(Number(m[2])), `„${m[0]}“: Absatz ${m[2]} gibt es in Nr. ${m[1]} nicht`)
}
// der Wortlaut der Verweise passt zum Inhalt der Stelle
const lb = (n, abs) => absatzTexte(abschnitt(LB, n))[abs - 1]
assert.ok(lb(8, 1).startsWith('Mit dem Ende des Wartungs-Abos endet der Terminplaner.'), 'Nr. 1 Abs. 2: Nr. 8 Abs. 1')
assert.ok(lb(8, 4).startsWith('Beginnt innerhalb dieser 30 Tage'), 'Nr. 9 Abs. 6: Rückkehr nach Nr. 8 Abs. 4')
assert.ok(lb(8, 6).startsWith('Auf Wunsch des Kunden in Textform schaltet WEBklar den Terminplaner jederzeit ab.'), 'Nr. 4 Abs. 2: Nr. 8 Abs. 6')
assert.ok(lb(4, 9).startsWith('Liegt die Datenschutzerklärung in der von WEBklar betreuten Website'), 'Nr. 11 Abs. 2: Datenschutz-Absatz')
assert.ok(lb(4, 7).startsWith('Er informiert seine Beschäftigten'), 'Nr. 11 Abs. 3: Beschäftigte')
assert.ok(lb(4, 3).startsWith('Der Terminplaner ist nicht für Angebote im Gesundheitsbereich bestimmt'), 'Nr. 11 Abs. 4: Gesundheitsbereich')
assert.ok(lb(4, 6).startsWith('Er lässt die Texte der Haken und Hinweise im Widget unverändert.'), 'Nr. 11 Abs. 5: Haken unverändert')
assert.ok(lb(9, 2).startsWith('WEBklar darf den Terminplaner für eine Website mit einer Frist von vier Wochen beenden'), 'Nr. 12 Abs. 3: Beenden')
assert.ok(lb(9, 3).startsWith('Ist der Weiterbetrieb aus einem Grund nach Absatz 2 schon vor Ablauf der Frist nicht zulässig'), 'Nr. 12 Abs. 3: Aussetzen')
// 2. Verweise innerhalb des AVV: „Nummer N [Abs. M]“ (ohne Zusatz „der Leistungsbeschreibung“)
const eigene = [...text.matchAll(/\bNummer (\d+)(?: Abs\. (\d+))?(?! der Leistungsbeschreibung)/g)]
assert.ok(eigene.length >= 6, `zu wenige interne Verweise: ${eigene.length}`)
for (const m of eigene) {
const a = abschnitt(AVV, m[1])
assert.ok(a, `„${m[0]}“: Abschnitt ${m[1]} gibt es nicht`)
if (m[2]) assert.ok(a.absaetze.includes(Number(m[2])), `„${m[0]}“: Absatz ${m[2]} gibt es in Nummer ${m[1]} nicht`)
}
// die Verweise der Leistungsbeschreibung auf den AVV zeigen auf Löschfristen (Nr. 9) und Änderungen (Nr. 12)
assert.match(avvNr(9), /Löschung und Rückgabe|Nicht bestätigte Buchungen/)
assert.match(avvNr(12), /neue Fassung/)
// 3. Paragrafen und Anlagen der Abo-Bedingungen
const paragrafen = [...text.matchAll(/§ (\d+)(?: Abs\. (\d+))? der Abo-Bedingungen/g)]
assert.ok(paragrafen.length >= 3)
for (const m of paragrafen) {
const start = ABO.indexOf(`<h2 id="p${m[1]}">§ ${m[1]} `)
assert.ok(start > 0, `„${m[0]}“: § ${m[1]} gibt es in den Abo-Bedingungen nicht`)
const stueck = ABO.slice(start, ABO.indexOf('<h2', start + 10))
const anzahl = Math.max((stueck.match(/<li>/g) || []).length, (stueck.match(/<h3>\(\d+\)/g) || []).length)
if (m[2]) assert.ok(Number(m[2]) <= anzahl, `„${m[0]}“: § ${m[1]} hat keinen Absatz ${m[2]}`)
}
assert.deepEqual([...new Set(paragrafen.map((m) => m[1]))].sort(), ['13', '16', '3'])
assert.ok(ABO.includes('id="avv"') && ABO.includes('id="tom"'), 'Anlage 1 und Anlage 2 der Abo-Bedingungen')
assert.ok(text.includes('Anlage 1 der Abo-Bedingungen') && text.includes('Anlage 2 der Abo-Bedingungen'))
})
test('avv_ohne_analyse_bezug: Analyse-Kunden müssen nicht neu zustimmen, der AVV der Analyse bleibt unverändert', () => {
// der Terminplaner-AVV ist ein eigenes Geschwister: kein Wort zur Analyse, kein Nachtrag
assert.doesNotMatch(AVV_MD, /Analyse|Nachtrag|Zusatz zum|Änderung des Auftragsverarbeitungsvertrags/i)
// der AVV der Analyse kennt den Terminplaner nicht und trägt weiter seine eigene Fassung
const analyse = lies('docs/rechtliches/avv-website-analyse.md')
assert.doesNotMatch(analyse, /Terminplaner|Termine/)
assert.equal(analyse.split('\n')[2], `**Fassung ${config.analyse.avvVersion}**`)
assert.notEqual(config.analyse.avvVersion, V, 'die Fassung der Analyse ändert sich nicht mit der des Terminplaners')
// die beiden Dokumentsätze sind getrennt
assert.notEqual(featureConfig('termine').avv, undefined)
assert.ok(!String(featureConfig('termine').avv).includes('website-analyse'))
})
test('avv_inhalt: Abschluss, Zwecke ohne Eigennutz, Datenarten, Weisungen, Datenpanne, Änderungen', () => {
const vorbemerkung = norm(AVV.abschnitte[0].text)
assert.ok(vorbemerkung.includes('Das Portal speichert die Fassung, den Zeitpunkt, das Kundenkonto und die angemeldete Person.'))
assert.ok(vorbemerkung.includes('Anschließend sendet WEBklar dem Kunden eine E-Mail, die beide Texte und die Widerrufsbelehrung in dieser Fassung als PDF enthält'))
assert.ok(vorbemerkung.includes('Ein Mitarbeiter von WEBklar darf die Funktion nur einschalten, wenn der Kunde vorher in Textform'))
// T1: In der Textform-Aktivierung trägt K16 nur Leistungsbeschreibung und AVV als PDF (ohne Widerrufsbelehrung, ohne Links): der Text verspricht nicht die E-Mail nach Absatz 1
assert.ok(vorbemerkung.includes('Der Kunde erhält anschließend eine E-Mail mit diesen beiden Texten als PDF.'))
assert.ok(!vorbemerkung.includes('E-Mail nach Absatz 1 anschließend'), 'der Satz verwies auf die Mail mit Belehrung und Links')
assert.match(lies('server/routes/admin/funktionenAdmin.js'), /featureLegalAttachments\(key, \{ ohne: \['widerruf'\] \}\)/, 'K16 der Textform-Aktivierung (Terminplaner und Webshop): nur Leistungsbeschreibung und AVV')
assert.ok(vorbemerkung.includes('Für den Terminplaner geht er Anlage 1 vor'))
assert.ok(vorbemerkung.includes('Anlage 1 der Abo-Bedingungen'))
const nr1 = avvNr(1)
assert.ok(nr1.includes('Er endet mit dem Abschalten, spätestens mit dem Ende des Wartungs-Abos; den Terminplaner schaltet WEBklar dann spätestens am folgenden Tag ab'))
assert.ok(nr1.includes('Die Pflichten aus diesem Vertrag gelten fort, bis alle Daten nach Nummer 9 gelöscht sind.'))
const nr2 = avvNr(2)
assert.ok(nr2.includes('WEBklar verwendet die Daten für keine eigenen Zwecke'))
assert.ok(nr2.includes('WEBklar bildet keine Profile einzelner Gäste.'))
assert.ok(nr2.includes('Missbrauch'))
const nr3 = avvNr(3)
for (const wort of [
'Name und E-Mail-Adresse', 'Telefonnummer', 'Zusatzfelder', 'die gebuchte Leistung, die Person oder der Platz und der Termin', 'der Status der Buchung mit den Zeitpunkten',
'Fassung der Gästetexte, Zeitpunkt, Text des Buchungsknopfs, Anrede und Hashes (Prüfsummen) der gezeigten Texte', 'der Wunsch nach einer Erinnerung', 'der Zustellstatus der E-Mails an den Gast',
'Absagegrund und Vermerke zu einer Absage oder einem Widerruf', 'auch mit dem Namen aus einer Widerrufserklärung', 'Preis, Zahlart, Zahlungsstatus und die Angabe, ob als Verbraucher gebucht wurde',
'Namen oder Bezeichnungen der Personen oder Plätze', 'Arbeitszeiten und Abwesenheiten', 'geheimer Kalender-Link',
]) assert.ok(nr3.includes(wort), `Nr. 3 nennt ${wort}`)
assert.ok(nr3.includes('Besondere Kategorien personenbezogener Daten (Art. 9 DSGVO) werden nicht verarbeitet.'))
assert.ok(nr3.includes('Mit der Buchung wird keine IP-Adresse und kein Browser-Kennzeichen gespeichert.'))
assert.ok(nr3.includes('nur im Arbeitsspeicher des Servers'))
assert.ok(nr3.includes('Der Terminplaner setzt keine Cookies und speichert nichts im Browser des Gastes.'))
const nr4 = avvNr(4)
assert.ok(nr4.includes('Mit der Bestellung weist der Kunde WEBklar an'))
assert.ok(nr4.includes('Sonstige Weisungen erteilt der Kunde in Textform'))
assert.ok(nr4.includes('Die Löschung auf Weisung nach Nummer 9 Abs. 6 setzt WEBklar immer um.'))
const nr8 = avvNr(8)
assert.ok(nr8.includes('Gastdaten löschen') && nr8.includes('CSV-Datei'))
assert.ok(nr8.includes('möglichst innerhalb von 24 Stunden nach Kenntnis'))
assert.ok(nr8.includes('bis drei Jahre nach dem Ende des Terminplaners'))
assert.ok(nr8.includes('die Fassung der Gästetexte, den Zeitpunkt, den Text des Buchungsknopfs, die Anrede und Hashes der übrigen Texte'))
assert.doesNotMatch(nr8, /nur den Zeitpunkt/)
const nr11 = avvNr(11)
assert.ok(nr11.includes('Art. 13 DSGVO') && nr11.includes('Datenschutz-Absatz') && nr11.includes('Beschäftigten'))
assert.ok(nr11.includes('nicht im Gesundheitsbereich'))
assert.ok(nr11.includes('Haken für die Erinnerung'))
const nr12 = avvNr(12)
assert.ok(nr12.includes('§ 13 der Abo-Bedingungen') && nr12.includes('Art. 82 DSGVO'))
assert.ok(nr12.includes('mindestens sechs Wochen vor ihrem Wirksamwerden in Textform'))
assert.ok(nr12.includes('Im Übrigen gilt § 16 der Abo-Bedingungen.'))
})
test('avv_ausnahmen_loeschung: Einrichtungs-Ticket bleibt (Nr. 9 Abs. 6, Anlage, LB Nr. 8 Abs. 3); fehlgeschlagene E-Mails behalten Adresse und Inhalt bis Nr. 9 Abs. 4 (Nr. 8 Abs. 2)', () => {
// T2: das Einrichtungs-Ticket (workorders.details = Kurzfassung der Einstellungen, ticketDetails.js) wird beim Löschlauf nicht angefasst
const ticket = 'Eine Ausnahme ist das Einrichtungs-Ticket, in dem WEBklar die Einrichtung als Arbeitsnachweis festhält: Es bleibt mit einer Kurzfassung der Einstellungen bestehen'
const nr9 = avvNr(9)
const abs6 = nr9.slice(nr9.indexOf('(6) '), nr9.indexOf('(7) '))
assert.ok(abs6.includes(ticket))
assert.ok(abs6.includes('(zum Beispiel Betriebsangaben, Namen der Leistungen, die ersten acht Namen der Personen oder Plätze und die Wünsche des Kunden zum Einbau) und enthält keine Daten von Gästen; dafür gilt die Datenschutzerklärung von WEBklar (Abschnitt 6).'))
// Reihenfolge: erst die Löschung nach 30 Tagen, dann die Ausnahme, dann der Export; „Bis dahin“ hätte sich auf die Datenschutzerklärung beziehen lassen
assert.ok(abs6.indexOf('30 Tage danach.') < abs6.indexOf(ticket) && abs6.indexOf(ticket) < abs6.indexOf('Bis zur Löschung kann der Kunde die Termine'))
assert.ok(!abs6.includes('Bis dahin'))
assert.ok(tomZeilen()['Löschung'].includes('das Einrichtungs-Ticket bleibt als Arbeitsnachweis mit einer Kurzfassung der Einstellungen bestehen und enthält keine Daten von Gästen (Nummer 9 Abs. 6)'))
assert.ok(norm(abschnitt(LB, 8).text).includes('Eine Ausnahme ist das Einrichtungs-Ticket, in dem WEBklar die Einrichtung als Arbeitsnachweis festhält: Es bleibt mit einer Kurzfassung der Einstellungen bestehen und enthält keine Daten von Gästen (Nummer 9 Abs. 6 des AVV Terminplaner).'))
// die Zahl „acht“ ist die der Kurzfassung (einbauanleitung.js KURZ_NAMEN); die Kurzfassung hat keinen Zugriff auf Buchungen (ticketDetails.js liest nur den Stand der Einstellungen)
assert.match(lies('server/services/termine/einbauanleitung.js'), /const KURZ_NAMEN = 8\b/)
assert.doesNotMatch(lies('server/services/termine/ticketDetails.js'), /termineBookings|buchungen\.js|gastName|gastEmail/)
// Datenschutzerklärung Abschnitt 6 nennt die Arbeitsnachweise und die Aufbewahrung bis zur Verjährung
const ds = norm(lies('public/datenschutz.html').replace(/<[^>]+>/g, ' '))
assert.ok(ds.includes('unsere Arbeitsnachweise') && ds.includes('bis zum Ablauf der Verjährungsfrist (in der Regel drei Jahre)'))
// T3: „Gastdaten löschen“ löscht nur Einträge im Status wartet (verwerfeWartende); Einträge im Status fehler behalten Adresse und Inhalt bis 48 Stunden nach dem Fehlschlag
const nr8 = avvNr(8)
assert.ok(nr8.includes('Dabei löscht WEBklar die E-Mails zu dieser Buchung, die noch auf den Versand warten, und die Links des Gastes werden ungültig.'))
assert.ok(nr8.includes('E-Mails, deren Versand endgültig fehlgeschlagen ist, behalten Empfängeradresse und Inhalt, bis WEBklar sie nach Nummer 9 Abs. 4 löscht (48 Stunden nach dem Fehlschlag).'))
assert.ok(!nr8.includes('Noch nicht versandte E-Mails zu dieser Buchung löscht WEBklar dabei'), 'der alte Satz versprach mehr, als verwerfeWartende löscht')
const verwerfe = /export async function verwerfeWartende[\s\S]*?\n\}\n/.exec(OUTBOX)?.[0]
assert.ok(verwerfe, 'verwerfeWartende')
assert.ok(verwerfe.includes("Query.equal('status', 'wartet')") && !verwerfe.includes("'fehler'"), 'verwerfeWartende löscht nur den Status wartet')
assert.match(OUTBOX, /const FEHLER_BEHALTEN_MS = 48 \* STUNDE_MS/)
assert.ok(avvNr(9).includes('löscht WEBklar mit Empfängeradresse und Inhalt 48 Stunden nach dem endgültigen Fehlschlag'))
})
/**
* Fundstellen der Anlage TOM: jede Zeile der Tabelle nennt hier, wo im Code, in den Tests oder in den Abo-Bedingungen ihre
* Aussage gesichert ist. { datei, hat } = die Datei enthält das Muster; { datei, nicht } = die Datei enthält es nicht.
*/
const TOM_FUNDSTELLEN = {
'Übertragung': [
{ datei: 'server/config.js', hat: /publicBaseUrl: \(process\.env\.PORTAL_PUBLIC_URL \|\| 'https:\/\/project\.webklar\.com'\)/ },
{ datei: 'server/services/termine/einbauanleitung.js', hat: /beginnen mit https:\/\// },
{ datei: 'test/termineEmbedStatic.test.js', hat: /keine festen Fremdadressen im Code/ },
],
'Speicher im Browser': [
{ datei: 'test/termineEmbedStatic.test.js', hat: /'sessionStorage', 'document\.cookie', 'indexedDB', 'document\.referrer'/ }, // termineEmbedStatic.test.js:24
{ datei: 'test/termineEmbedStatic.test.js', hat: /'resolvedOptions', 'getTimezoneOffset'/ },
{ datei: 'public/embed/termine.js', hat: /credentials: 'omit'/ },
{ datei: 'public/termin.js', nicht: /localStorage|sessionStorage|document\.cookie|indexedDB/ },
{ datei: 'public/termin.html', nicht: /localStorage|sessionStorage|document\.cookie|indexedDB/ },
{ datei: 'server/routes/publicTermine.js', nicht: /res\.cookie|Set-Cookie/i },
],
'Zugriffskontrolle': [
{ datei: 'server/routes/termine.js', hat: /assertOwnProject\(customerId, projectId\)/ },
{ datei: 'server/routes/termine.js', hat: /ctx\.feature\.customerId !== customerId/ },
{ datei: 'server/routes/publicTermine.js', hat: /timingSafeEqual\(sha256\(r\.feedToken\), gegeben\)/ },
{ datei: 'server/routes/publicTermine.js', hat: /POST und Preflight nur von Seiten des Betriebs/ },
{ datei: 'server/services/termine/token.js', hat: /timingSafeEqual\(gegeben, erwartet\)/ },
],
'Missbrauchsschutz': [
{ datei: 'server/routes/publicTermine.js', hat: /Honeypot kontakt_url und Formular-Token/ },
{ datei: 'server/routes/publicTermine.js', hat: /config\.termine\.minAntwortMs/ },
{ datei: 'server/services/termine/token.js', hat: /config\.termine\.formMinSekunden/ },
{ datei: 'server/services/termine/limits.js', hat: /const MAX_SCHLUESSEL = 10000/ },
{ datei: 'server/services/termine/limits.js', hat: /Ein Neustart des Prozesses leert alles/ },
{ datei: 'server/services/termine/outbox.js', hat: /Stundenbudget \(config\.termine\.mailsProStunde\)/ },
{ datei: 'server/services/termine/outbox.js', hat: /config\.termine\.mailsProFunktion/ },
],
'Datenminimierung': [
{ datei: 'test/termineVertragsunterlagen.test.js', hat: /avv_keine_ip_in_buchung/ },
{ datei: 'server/services/termine/ablauf.js', hat: /datenschutz: sha256\(formularHinweis\(e\)\)/ },
{ datei: 'server/services/termine/einstellungen.js', hat: /wahl\(fehl, 'gastFelder\.telefon'/ },
{ datei: 'server/services/termine/outbox.js', hat: /status: 'gesendet',[\s\S]*?an: '',\s*html: '',\s*text: '',\s*ics: ''/ },
],
'Trennung': [
{ datei: 'server/services/termine/schema.js', hat: /Eine Einstellung je Funktion; Dokument-ID = ID des portalFeatures-Dokuments/ },
{ datei: 'server/services/termine/schema.js', hat: /str\('featureId', 64\)/ },
{ datei: 'server/services/termine/buchungen.js', hat: /Query\.equal\('featureId', featureId\)/ },
// die Hintergrundläufe arbeiten über alle Funktionen (Löschlauf ohne Funktionsfilter, Aufräumen der Warteschlange nach Status) und löschen Dokument für Dokument
{ datei: 'server/services/termine/buchungen.js', hat: /featureId === null \? \[\] : \[Query\.equal\('featureId', featureId\)\]/ },
{ datei: 'server/services/termine/outbox.js', hat: /Query\.equal\('status', \['gesendet', 'fehler', 'verworfen'\]\)/ },
],
'Löschung': [
{ datei: 'server/index.js', hat: /setInterval\(termineTagesJob, 24 \* 60 \* 60 \* 1000\)/ },
{ datei: 'server/services/termine/jobs.js', hat: /Status und Bezugszeit noch einmal frisch geprueft/ },
{ datei: 'server/services/termine/jobs.js', hat: /im Zweifel wird nichts geloescht/ },
{ datei: 'server/services/termine/jobs.js', hat: /ABGESCHALTET_BEHALTEN_MS = LOESCHFRIST_TAGE \* TAG_MS/ },
{ datei: 'server/services/termine/entwuerfe.js', hat: /export const ENTWURF_TAGE = 90/ },
{ datei: 'server/services/termine/entwuerfe.js', hat: /const funktion = await holeOderNull\(config\.collections\.portalFeatures, id\)/ },
// die Warteschlange löscht die gelisteten Einträge ohne erneutes Lesen (nur Buchungen, abgeschaltete Funktionen und Entwürfe lesen frisch)
{ datei: 'server/services/termine/outbox.js', hat: /await deleteDocument\(col\(\), eintrag\.\$id\)/ },
// das Einrichtungs-Ticket (Kurzfassung der Einstellungen, ohne Daten von Gästen) bleibt als Arbeitsnachweis
{ datei: 'server/services/termine/ticketDetails.js', hat: /const \{ kurz \} = einbauanleitung\(/ },
{ datei: 'server/services/termine/einbauanleitung.js', hat: /const KURZ_NAMEN = 8 / },
],
'Protokolle': [
{ datei: 'test/termineJobs.test.js', hat: /kein Gastname und keine Gastadresse im Log/ },
{ datei: 'test/termineBetrieb.test.js', hat: /keine Gastadresse und kein Gastname im Log/ },
{ datei: 'test/termineStatus.test.js', hat: /keine Gastadresse und kein Gastname im Log/ },
{ datei: 'public/abo-bedingungen.html', hat: /Server-Protokolle werden nach 14 Tagen gelöscht/ },
],
'Datensicherung': [
{ datei: 'public/abo-bedingungen.html', hat: /Verfügbar sind jeweils die letzten sieben Tagessicherungen/ },
],
'Rechenzentrum': [
{ datei: 'public/abo-bedingungen.html', hat: /Hetzner ist nach ISO\/IEC 27001 zertifiziert/ },
{ datei: 'public/abo-bedingungen.html', hat: /E-Mail-Postfächer \(Webhosting\)<\/td><td>Deutschland \(Rechenzentrum Nürnberg\)/ },
],
'Organisation': [
{ datei: 'public/abo-bedingungen.html', hat: /Verpflichtung aller Beteiligten auf Vertraulichkeit/ },
{ datei: 'public/abo-bedingungen.html', hat: /Die Maßnahmen werden mindestens einmal im Jahr überprüft/ },
],
}
/** Die Zeilen der Anlage-Tabelle als { bereich: text }. */
function tomZeilen() {
const anlage = AVV.abschnitte.find((a) => a.nummer === null && a.titel.startsWith('Anlage:'))
assert.ok(anlage)
const zeilen = anlage.zeilen.filter((z) => z.startsWith('|')).slice(2)
return Object.fromEntries(zeilen.map((z) => {
const [, bereich, text] = z.split('|').map((t) => t.trim())
return [bereich, text]
}))
}
test('avv_tom_fundstellen: jede Zeile der Anlage hat ihre Fundstellen im Code, in den Tests oder in den Abo-Bedingungen', () => {
const zeilen = tomZeilen()
assert.deepEqual(Object.keys(zeilen), Object.keys(TOM_FUNDSTELLEN), 'Zeilen der Anlage und Fundstellen-Tabelle stimmen überein (Reihenfolge eingeschlossen)')
for (const [bereich, stellen] of Object.entries(TOM_FUNDSTELLEN)) {
assert.ok(stellen.length >= 1, `${bereich}: keine Fundstelle`)
assert.ok(zeilen[bereich].length > 40, `${bereich}: Text der Zeile fehlt`)
for (const stelle of stellen) {
const pfad = stelle.datei
assert.ok(existsSync(new URL(`../${pfad}`, import.meta.url)), `${bereich}: ${pfad} gibt es nicht`)
const quelle = lies(pfad)
if (stelle.hat) assert.match(quelle, stelle.hat, `${bereich}: Fundstelle in ${pfad} fehlt (${stelle.hat})`)
if (stelle.nicht) assert.doesNotMatch(quelle, stelle.nicht, `${bereich}: ${pfad} darf ${stelle.nicht} nicht enthalten`)
}
}
// Aussagen mit Zahlen stehen in der Zeile und in der Fundstelle gleich
assert.ok(zeilen['Protokolle'].includes('nach 14 Tagen gelöscht'))
assert.ok(zeilen['Datensicherung'].includes('die letzten sieben Tagessicherungen'))
assert.ok(zeilen['Rechenzentrum'].includes('ISO/IEC 27001') && zeilen['Rechenzentrum'].includes('Falkenstein und Nürnberg'))
assert.ok(zeilen['Löschung'].includes('30 Tage nach dem Abschalten'))
assert.ok(zeilen['Löschung'].includes('Vor der Löschung einer Buchung, einer abgeschalteten Funktion oder eines nicht bestellten Fragebogens wird der Zustand frisch gelesen'))
assert.doesNotMatch(zeilen['Löschung'], /Vor jeder Löschung/)
assert.ok(zeilen['Trennung'].includes('das Kundenportal und die öffentliche Schnittstelle lesen immer nur die Daten der jeweiligen Funktion.'))
assert.ok(zeilen['Trennung'].includes('Die Hintergrundläufe (Löschung, Versand der E-Mails, Erinnerungen) gehen die Dokumente aller Funktionen nacheinander durch und schreiben jeweils nur in das Dokument, um das es geht.'))
assert.doesNotMatch(zeilen['Trennung'], /Abfragen erfolgen nur je Funktion/)
assert.ok(zeilen['Datenminimierung'].includes('der Fassung der Gästetexte, dem Zeitpunkt, dem Text des Buchungsknopfs, der Anrede und Hashes der übrigen gezeigten Texte'))
assert.ok(zeilen['Speicher im Browser'].includes('keine Cookies'))
assert.ok(zeilen['Zugriffskontrolle'].includes('konstanter Zeit'))
assert.ok(zeilen['Organisation'].includes('Nummer 8 Abs. 3') && zeilen['Organisation'].includes('Nummer 8 Abs. 5'))
})