Files
ClaudeandClaude Opus 5.5 50f854ef1c Öffnungszeiten: Datenschutz-Abschnitt heißt 6e (6d ist seit dem Webshop vergeben); Härtungspunkt N13
Entwurf umbenannt (zeiten-datenschutz-6e-entwurf.md), Leistungsbeschreibung, Kanzlei-Fragen,
Livegang-Notiz und Test-Pins ziehen mit. N13: Rückgabe-Marker nach dem Löschen fehlt (vor on).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 06:30:03 +00:00

369 lines
19 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 } from 'node:test'
import assert from 'node:assert/strict'
import { existsSync, readFileSync } from 'node:fs'
import http from 'node:http'
import express from 'express'
import { useTestEnv } from './support/testEnv.js'
// Rechtstext-Entwürfe und Einbau-Anleitung von "Öffnungszeiten live" (Task 33, Spec 9, 4.6, Z19, Z20).
// Alles hier ist Entwurf für die Rechtsrunde: die Leistungsbeschreibung steht als statische Seite unter
// public/vertragsunterlagen/ (wie die der News-Pinnwand, Register-Adresse seit Task 5 festgelegt); die Texte für
// Datenschutzerklärung, K02/K03 und die Kanzlei-Fragen sind Markdown-Entwürfe, die keinen Code und keine
// veröffentlichte Seite ändern.
useTestEnv({ appwriteUrl: 'http://127.0.0.1:1', stripeUrl: 'http://127.0.0.1:1' })
const { config } = await import('../server/config.js')
const { featureConfig, featureHaken } = await import('../server/services/featureRegistry.js')
const root = (rel) => new URL(`../${rel}`, import.meta.url)
const lies = (rel) => readFileSync(root(rel), 'utf8').replace(/\r/g, '')
/** Sichtbarer Text einer HTML-Seite: ohne Kommentare, Skripte, Stile und Tags, Leerraum zusammengefasst. */
const sichtbar = (html) =>
html
.replace(/<!--[\s\S]*?-->/g, ' ')
.replace(/<(script|style)[\s\S]*?<\/\1>/g, ' ')
.replace(/<[^>]+>/g, ' ')
.replace(/&nbsp;/g, ' ')
.replace(/\s+/g, ' ')
.trim()
const norm = (text) => text.replace(/\s+/g, ' ').trim()
const LB_PFAD = 'public/vertragsunterlagen/zeiten-leistungsbeschreibung.html'
const LB_URL = '/vertragsunterlagen/zeiten-leistungsbeschreibung.html'
const lb = () => lies(LB_PFAD)
// ---------------------------------------------------------------------------------------------- Leistungsbeschreibung
// Dieselbe Auslieferung wie in server/index.js: express.static(public) (die Adresse ist das Register des Terminplaners nicht, sondern
// der "vertragsunterlagen"-Weg der News-Pinnwand; die versionierten /rechtliches-Seiten samt PDF gibt es nur für die Terminplaner-Sätze)
const app = express()
app.use(express.static(new URL('../public/', import.meta.url).pathname))
const server = await new Promise((resolve) => {
const s = http.createServer(app).listen(0, '127.0.0.1', () => resolve(s))
})
after(() => new Promise((resolve) => server.close(resolve)))
const baseUrl = `http://127.0.0.1:${server.address().port}`
test('Leistungsbeschreibung ist erreichbar und als Entwurf markiert', async () => {
const antwort = await fetch(baseUrl + LB_URL)
assert.equal(antwort.status, 200)
assert.match(antwort.headers.get('content-type') || '', /^text\/html/)
const html = await antwort.text()
assert.equal(html, lb(), 'die ausgelieferte Seite ist die Datei im Repo')
// Entwurf: nur als HTML-Kommentar (der Kunde sieht nichts davon); der Test "Bestelltexte ohne ENTWURF" prüft das Register, nicht die Seite
assert.ok(html.includes('<!-- ENTWURF – vor Livegang Rechtsrunde -->'), 'Kopf-Kommentar fehlt')
assert.ok(html.indexOf('<!-- ENTWURF') < html.indexOf('<head>'), 'der Kommentar steht vor dem Kopf der Seite')
const text = sichtbar(html)
assert.doesNotMatch(text, /ENTWURF|Entwurf/, 'im sichtbaren Text steht kein Entwurfsvermerk')
assert.doesNotMatch(JSON.stringify(featureConfig('zeiten').leistungsbeschreibung), /ENTWURF/)
// Preis und Einbau
assert.ok(text.includes('§ 19 UStG'))
assert.ok(text.includes(`30 € einmalig (${config.ustHinweis})`), 'Preis mit dem Umsatzsteuer-Hinweis des Registers')
assert.ok(text.includes('ein Einbau je Website'))
assert.match(html, /<title>Leistungsbeschreibung Öffnungszeiten live – WEBklar<\/title>/)
assert.match(html, /<h1>Leistungsbeschreibung: Öffnungszeiten live<\/h1>/)
})
test('Leistungsbeschreibung: Stand ist die Fassung des Registers', () => {
const [j, m, t] = featureConfig('zeiten').leistungsbeschreibung.version.split('-')
assert.ok(lb().includes(`<p class="meta">Stand ${t}.${m}.${j}</p>`), 'Stand = Fassung der Leistungsbeschreibung im Register')
})
test('Leistungsbeschreibung folgt der Gliederung der Spec 9.1', () => {
const titel = [...lb().matchAll(/<h2 id="([a-z-]+)">(\d+)\. ([^<]+)<\/h2>/g)].map((m) => [Number(m[2]), m[3]])
assert.deepEqual(titel, [
[1, 'Geltung'],
[2, 'Leistung'],
[3, 'Einbau und Freischaltung'],
[4, 'Preis'],
[5, 'Nicht enthalten'],
[6, 'Pflichten des Kunden'],
[7, 'Verfügbarkeit und Aktualität'],
[8, 'Haftung'],
[9, 'Ende der Leistung'],
[10, 'Änderungen und Einstellung'],
[11, 'Widerruf'],
[12, 'Hinweise'],
])
})
test('Leistungsbeschreibung grenzt das Abo ab und nennt Einbau und Ausschlüsse', () => {
const text = sichtbar(lb())
for (const satz of [
// Z19: freiwilliges Extra, Abo-Bedingungen bleiben unverändert
'freiwilliges Zusatzangebot',
'gehört nicht zum Leistungsumfang der Abo-Bedingungen',
'Leistungsbeschreibung B Nr. 3 der Abo-Bedingungen',
'Der Preis des Wartungs-Abos ändert sich nicht',
// Z4, Z20: ohne Aufpreis, ohne Anrechnung, ein Einbau je Website inkl. Ersatz-Tabelle beim Abschalten, Sonderwünsche
'ohne Aufpreis',
'weder auf das Stundenkontingent angerechnet noch damit bezahlt',
'Aktualisierung der Ersatz-Tabelle',
'Sonderwünsche',
'nach Angebot',
// Einbau: automatisch nur bei WEBklar-Hosting, sonst durch das Team
'nur bei Websites, die WEBklar hostet',
'setzt das Team',
// nicht enthalten
'Übertragung der Öffnungszeiten zu Google',
'Feiertage einer Gemeinde',
// Pflichten des Kunden
'Zeiten, Feiertage und Hinweistexte',
'Preise und Rabatte',
'Datenschutz-Absatz',
'WhatsApp',
// Technik: nichts im Browser, Fremddienste erst nach Klick
'keine Cookies',
'erst nach dem Klick',
'Google Maps',
]) {
assert.ok(text.includes(satz), `fehlt: ${satz}`)
}
})
test('Leistungsbeschreibung nennt Haftung, Ende nach Abo und Einstellung', () => {
const text = sichtbar(lb())
for (const satz of [
// Haftung: Hilfsmittel, Angaben vom Kunden, Softwarefehler, Feiertagsdaten
'Die Live-Anzeige ist ein Hilfsmittel',
'Angaben des Kunden',
'Feiertagsdaten',
'entgangenen Gewinn',
// Ende der Leistung nach Z11: 30 Tage zum Behalten für 30 €, danach abgeschaltet, Ersatz-Tabelle bleibt, Daten 30 Tage
'Endet das Wartungs-Abo',
'noch 30 Tage',
'für einmalig 30 € behalten',
'schaltet WEBklar sie ab',
'Ersatz-Tabelle',
'weitere 30 Tage',
'Wartungs-Abo beginnt',
// Einstellung/Änderung nach Vorbild Analyse Nr. 8
'Technik von Öffnungszeiten live verbessern',
'mit einer Frist von vier Wochen beenden',
'mit einer Frist von drei Monaten einstellen',
'Sicherheitslücke',
'§ 327r Abs. 3 BGB',
'ändert ihre Einstellung den Preis des Wartungs-Abos nicht',
]) {
assert.ok(text.includes(satz), `fehlt: ${satz}`)
}
})
test('Haftung spiegelt § 13 Abs. 1 und 2 der Abo-Bedingungen wörtlich (auch für Kunden ohne Abo)', () => {
const abo = norm(sichtbar(lies('public/abo-bedingungen.html')))
const text = norm(sichtbar(lb()))
for (const bruchstueck of [
'unbeschränkt bei Vorsatz und grober Fahrlässigkeit, bei Verletzung von Leben, Körper oder Gesundheit, nach dem Produkthaftungsgesetz und soweit',
'nur für den vertragstypischen, vorhersehbaren Schaden',
'Wesentlich sind Pflichten, deren Erfüllung die ordnungsgemäße Durchführung des Vertrags überhaupt erst ermöglicht und auf deren Einhaltung der Kunde regelmäßig vertrauen darf',
'Im Übrigen ist die Haftung für leichte Fahrlässigkeit ausgeschlossen',
]) {
assert.ok(abo.includes(bruchstueck), `Abo-Bedingungen § 13 hat sich geändert: ${bruchstueck}`)
assert.ok(text.includes(bruchstueck), `die Leistungsbeschreibung muss § 13 spiegeln: ${bruchstueck}`)
}
// ohne Abo gibt es kein § 13 zum Verweisen: die Klausel steht ausgeschrieben im Abschnitt Haftung
const haftung = lb().split('<h2 id="haftung">')[1].split('<h2 id=')[0]
assert.match(sichtbar(haftung), /Dieser Abschnitt gilt auch für Kunden ohne Wartungs-Abo/)
})
test('Verweise innerhalb der Leistungsbeschreibung treffen vorhandene Nummern und Absätze', () => {
const html = lb()
const teile = html.split(/<h2 id="[a-z-]+">/).slice(1)
const absaetze = teile.map((teil) => [...teil.matchAll(/<p>\((\d+)\) /g)].map((m) => Number(m[1])))
assert.equal(teile.length, 12)
for (const [i, nummern] of absaetze.entries()) {
assert.ok(nummern.length > 0, `Abschnitt ${i + 1} hat keine Absätze`)
assert.deepEqual(nummern, nummern.map((_, k) => k + 1), `Abschnitt ${i + 1}: Absätze (1), (2), … ohne Lücke`)
}
// fremde Verweise (Abo-Bedingungen, Gesetz) herausnehmen, übrig bleiben "Nr. n [Abs. m]" dieser Leistungsbeschreibung
const text = sichtbar(html)
.replace(/Leistungsbeschreibung B Nr\. \d+/g, '')
.replace(/§ \d+[a-z]? Abs\. \d+ Nr\. \d+ BGB/g, '')
const verweise = [...text.matchAll(/Nr\. (\d+)(?: Abs\. (\d+))?/g)]
assert.ok(verweise.length >= 6, 'die Leistungsbeschreibung verweist an mehreren Stellen auf sich selbst')
for (const [roh, nr, abs] of verweise) {
assert.ok(absaetze[Number(nr) - 1], `${roh}: Abschnitt fehlt`)
if (abs) assert.ok(absaetze[Number(nr) - 1].includes(Number(abs)), `${roh}: Absatz fehlt`)
}
})
test('Leistungsbeschreibung: Sie-Form, Schreibweise WEBklar, nie „24 Uhr“', () => {
const text = sichtbar(lb())
assert.doesNotMatch(text, /\b(Du|Dein\w*|dein\w*|Dich|dich|Dir)\b/, 'Rechtstext in Sie-Form')
assert.match(text, /\bSie\b/)
assert.doesNotMatch(text, /24 Uhr|24:00|24 h\b/)
assert.doesNotMatch(lb(), /Webklar|WebKlar|WEBKlar|webKlar/, 'Schreibweise ist WEBklar')
assert.ok(text.includes('WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler'), 'Anbieter genannt')
// keine Rechtsberatung, keine Platzhalter
assert.ok(text.includes('Keine Rechtsberatung'))
assert.doesNotMatch(text, /TODO|XXX|\[Prüfen|⚖/)
})
test('Register verlinkt die Leistungsbeschreibung', () => {
const f = featureConfig('zeiten')
assert.equal(f.leistungsbeschreibung.url, LB_URL)
assert.ok(existsSync(new URL(`../public${f.leistungsbeschreibung.url}`, import.meta.url)), 'die Datei zur Register-Adresse existiert')
// beide Haken (bezahlt und gratis) nennen genau diese Leistungsbeschreibung
for (const consent of [f.consent, f.consentFrei]) {
assert.ok(consent.items[0].text.includes('Leistungsbeschreibung „Öffnungszeiten live“'))
}
assert.ok(sichtbar(lb()).includes('„Öffnungszeiten live“'))
// dieselbe Adresse nennen die Bestellseite und die Rechtliches-Karte (Spec 8)
assert.equal(new URL(`https://project.webklar.com${f.leistungsbeschreibung.url}`).pathname, LB_URL)
})
test('unterlagenBereit für zeiten ist erfüllt', async () => {
const haken = featureHaken('zeiten')
assert.equal(typeof haken.unterlagenBereit, 'function')
assert.equal(await haken.unterlagenBereit(), true)
// und die Unterlage, auf die das Register zeigt, ist wirklich lesbar (nicht leer)
const html = readFileSync(new URL(`../public${featureConfig('zeiten').leistungsbeschreibung.url}`, import.meta.url), 'utf8')
assert.ok(html.length > 5000)
})
// ---------------------------------------------------------------------------------------------- Datenschutz § 6e
test('Datenschutz-Entwurf § 6e und Ergänzung § 6 liegen als Entwurf vor, datenschutz.html bleibt unberührt', () => {
const md = lies('docs/rechtliches/zeiten-datenschutz-6e-entwurf.md')
assert.ok(md.includes('<h2 id="zeiten">6e. Öffnungszeiten live auf Websites unserer Kunden</h2>'))
for (const satz of [
'Entwurf',
'Art. 6 Abs. 1 lit. b DSGVO',
'Art. 6 Abs. 1 lit. f DSGVO',
'Erinnerung',
'höchstens eine',
'keine Cookies',
'nichts im Browser',
'erst',
'Klick',
'WhatsApp',
'Google Maps',
'30 Tage',
'Abschnitt 3', // Server-Protokolle (14 Tage) stehen dort
'Abschnitt 7', // Nachweis der Zustimmung
]) {
assert.ok(md.includes(satz), `fehlt: ${satz}`)
}
assert.doesNotMatch(md, /24 Uhr|Webklar|WebKlar/)
// § 6: der heutige Satz steht wörtlich da (Drift-Wächter gegen public/datenschutz.html), der neue nennt Öffnungszeiten live
const html = lies('public/datenschutz.html')
const heute = html.slice(html.indexOf('Um Sie zu unterstützen'), html.indexOf('ansehen und ändern.', html.indexOf('Um Sie zu unterstützen')) + 'ansehen und ändern.'.length)
assert.ok(heute.startsWith('Um Sie zu unterstützen') && heute.endsWith('ansehen und ändern.'))
assert.ok(md.includes(heute), 'der heutige § 6-Satz ist wörtlich zitiert')
assert.match(md, /wenn Sie Öffnungszeiten live nutzen, Ihre Öffnungszeiten, Hinweise und Kontaktangaben ansehen und ändern/)
// § 7 (Nachweis): der heutige Satz steht wörtlich da, der Vorschlag nennt Öffnungszeiten live und das Einschalten
const anfang7 = html.indexOf('Bestellen Sie eine Funktion (News-Pinnwand, Terminplaner)')
const heute7 = html.slice(anfang7, html.indexOf('wenn Sie nichts bestellt haben.', anfang7) + 'wenn Sie nichts bestellt haben.'.length)
assert.ok(anfang7 > 0 && heute7.endsWith('wenn Sie nichts bestellt haben.'))
assert.ok(md.includes(heute7), 'der heutige § 7-Satz ist wörtlich zitiert')
assert.match(md, /Bestellen Sie eine Funktion \(News-Pinnwand, Terminplaner, Öffnungszeiten live\) oder schalten Sie Öffnungszeiten live im Wartungs-Abo ein/)
assert.ok(md.includes('legal_datenschutz_kundenansicht'), 'nennt den Test, der den § 6-Satz pinnt')
// Wächter: die Entwurfsdatei ist nicht in die veröffentlichte Erklärung gerutscht
assert.doesNotMatch(html, /6e\. Öffnungszeiten live/)
})
// ---------------------------------------------------------------------------------------------- K02/K03
test('K02/K03-Entwurf zitiert den heutigen Satz und schlägt den neuen vor', () => {
const md = lies('docs/rechtliches/zeiten-k02-k03-entwurf.md')
const heute =
'Die Website-Analyse und der Terminplaner gehören zum Wartungs-Abo und enden mit ihm. Die Termindaten löschen wir 30 Tage nach dem Abschalten; vorher können Sie Ihre Termine im Kundenportal ansehen und exportieren.'
for (const vorlage of ['K02 Kuendigungsbestaetigung', 'K03 Eingang Kuendigung']) {
assert.ok(lies(`server/mail-templates/${vorlage}.html`).includes(heute), `${vorlage}: der Satz im Entwurf muss der heutigen Vorlage entsprechen`)
}
assert.ok(md.includes(heute), 'der heutige Satz steht wörtlich im Entwurf')
// die Pins bleiben mit dem Vorschlag grün: der alte Satz bleibt unverändert der Anfang des neuen
const vorschlag = md.split('## Vorschlag')[1]
assert.ok(vorschlag.includes(heute), 'der Vorschlag beginnt mit dem heutigen Satz')
for (const satz of [
'Haben Sie Öffnungszeiten live im Wartungs-Abo eingeschaltet',
'noch 30 Tage',
'für einmalig 30 € behalten',
'feste Öffnungszeiten',
'30 Tage später',
]) {
assert.ok(vorschlag.includes(satz), `fehlt im Vorschlag: ${satz}`)
}
assert.ok(md.includes('test/mailTemplates.test.js') && md.includes('test/publicForms.test.js'), 'nennt die Pins')
assert.ok(md.includes('Keine Codeänderung'))
assert.doesNotMatch(md, /24 Uhr|Webklar/)
assert.doesNotMatch(vorschlag, /\b(Du|Dein\w*|dein\w*|Dich|dich|Dir)\b/, 'Sie-Form')
})
// ---------------------------------------------------------------------------------------------- Einbau-Anleitung
test('Einbau-Anleitung enthält Einbau-Zeile, Platzhalter, Ersatz-HTML, Header-Selektor und Prüfliste', async () => {
const md = lies('docs/funktionen/zeiten-einbau.md')
const zeile = '<script data-wk-zeiten src="https://project.webklar.com/embed/zeiten.js" data-site="SITEKEY" defer></script>'
assert.ok(md.includes(zeile), 'Einbau-Zeile wie im Ticket')
// dieselbe Zeile erzeugt das Einrichtungs-Ticket (Wächter gegen Abweichung)
const { zeitenTicketDetails } = await import('../server/services/zeiten/ticketDetails.js')
const ticket = await zeitenTicketDetails({ feature: featureConfig('zeiten'), doc: { siteKey: 'SITEKEY' }, project: {}, customer: {} })
assert.ok(ticket.includes(zeile), 'das Ticket nennt dieselbe Einbau-Zeile')
for (const modus of ['tabelle', 'status', 'badge']) {
assert.ok(md.includes(`data-modus="${modus}"`), `Platzhalter ${modus}`)
}
for (const satz of [
'data-plan',
'Ersatz-HTML kopieren',
'WOMS → Funktionen',
'navSelektor',
'kontaktleisteAus',
'hinweisleisteAus',
'ZEITEN_KEIN_NEUBAU',
'ZEITEN_PUBLIC_BASE',
'einbau: manuell',
'Prüfliste',
'grep -ril',
'JSON-LD',
'Datenschutz-Absatz',
'website-datenschutz-absatz.md',
'Ersatz-Tabelle aktualisieren',
'Seite liegt woanders',
'keine Cookies',
]) {
assert.ok(md.includes(satz), `fehlt: ${satz}`)
}
// die Vorgabe der Sperrliste steht in der Config
for (const subdomain of config.zeiten.keinNeubau) assert.ok(md.includes(subdomain), `Sperrlisten-Vorgabe ${subdomain} fehlt`)
// die fünf Prüfpunkte des Tickets stehen in der Anleitung (Stichworte)
for (const stichwort of ['öffnungszeit', 'Footer', 'Header', 'Anruf-Leiste', 'JSON-LD', 'Datenschutz-Absatz']) {
assert.ok(md.toLowerCase().includes(stichwort.toLowerCase()), `Prüfliste: ${stichwort}`)
}
assert.doesNotMatch(md, /24 Uhr|Webklar/)
})
// ---------------------------------------------------------------------------------------------- Website-Absatz
test('Website-Absatz „Öffnungszeiten live“ in Variante A und B, WhatsApp und Google Maps erst nach Klick', () => {
const md = lies('docs/funktionen/website-datenschutz-absatz.md')
const a = md.slice(md.indexOf('## Variante A'), md.indexOf('## Variante B'))
const b = md.slice(md.indexOf('## Variante B'))
for (const [name, teil] of [['A', a], ['B', b]]) {
assert.ok(teil.includes('### Öffnungszeiten auf dieser Website'), `Variante ${name}: Überschrift`)
const absatz = teil.slice(teil.indexOf('### Öffnungszeiten auf dieser Website'))
for (const satz of ['IP-Adresse', 'Server-Protokollen', 'keine Cookies', 'nichts in Ihrem Browser', 'Art. 6 Abs. 1 lit. f DSGVO', 'nach 14 Tagen gelöscht', 'WhatsApp', 'Google Maps', 'Erst wenn Sie darauf klicken', 'Vorher werden keine Daten']) {
assert.ok(absatz.includes(satz), `Variante ${name}: fehlt ${satz}`)
}
assert.match(absatz, /nach dem Klick/)
assert.match(absatz, /Seitencode/, 'strukturierte Daten für Suchmaschinen')
}
assert.ok(b.slice(b.indexOf('### Öffnungszeiten auf dieser Website')).includes('[Prüfen: AVV mit WEBklar erforderlich]'), 'Variante B: AVV-Marker wie bei den News')
// bestehende Wächter aus legalTexts.test.js gelten weiter: keine Gedankenstriche im Kundentext, Marker der News bleibt
assert.ok(!/[–—]/.test(md.slice(md.indexOf('## Variante A'))), 'Kundentext ohne Gedankenstriche')
assert.ok(md.includes('[Prüfen: AVV mit WEBklar erforderlich]'))
assert.doesNotMatch(md, /24 Uhr/)
})
// ---------------------------------------------------------------------------------------------- Kanzlei-Fragen
test('Kanzlei-Fragen: AVV Funktionen per Haken und Behalten-Zustimmung stehen im Paket', () => {
const md = lies('docs/rechtliches/zeiten-kanzlei-fragen.md')
assert.ok(md.includes('⚖ AVV Funktionen'))
assert.match(md, /Haken/)
assert.ok(md.includes('Behalten') && md.includes('Einbau beginnt'), 'Behalten-Zustimmung nutzt den Haken „Einbau beginnt“')
const fragen = md.split(/^### ZQ\d+: /m).slice(1)
assert.ok(fragen.length >= 6, 'mehrere Fragen')
for (const frage of fragen) {
for (const teil of ['**Frage.**', '**Fundstelle im Text.**', '**Unsere Annahme.**', '**Alternative.**']) {
assert.ok(frage.includes(teil), `${frage.slice(0, 50)}: ${teil}`)
}
}
assert.doesNotMatch(md, /Webklar|WebKlar/)
})