Files
Webklar-Kundenbereich/test/termineEmbedStatic.test.js
T
ClaudeandClaude Sonnet 5.5 794a63c0d7 Terminplaner: Abschluss – Client (Formular-Token, Widerruf prominent, Telefonlink, Einbau-Code, G02)
- R53: Das Widget wartet vor jedem Absenden, bis das Formular-Token formMinSekunden plus 0,5 s alt
  ist ("Einen Moment …", Knopf gesperrt); gilt nach dem Neuladen der Konfiguration und beim Wiederholen
  nach "neu_laden". Kein stilles Erfolgs-201 ohne Buchung mehr.
- R54: "Vertrag widerrufen" im Fuß ist ein zweiter Knopf (44 px). Im Knopfmodus lädt das Widget die
  Konfiguration beim Laden der Seite (ein Abruf) und zeigt bei Widerrufsrecht dauerhaft einen zweiten
  Knopf "Vertrag widerrufen" neben dem Buchungsknopf, der den Dialog beim Widerrufsformular öffnet.
- Telefonlink im Widget und auf der Gästeseite: Text mit Buchstaben oder Mehrfachangaben wird nie zum
  tel:-Link, "(0)" hinter der Landesvorwahl entfällt.
- Portal, Einstellungen: Kasten "Einbau auf deiner Website" mit Einbau-Code und Kopierknopf; Hinweistexte
  zum Widerruf passen zu R55 (auch Anfragen).
- G02: Widerrufssatz nennt, dass der Knopf bis zum Beginn des Termins gilt und danach das Formular auf der
  Website oder die E-Mail an den Betrieb.
- Einbau-Anleitung angepasst (siteKey und Code im Portal, Knopfmodus, Checkliste); Tests und Playwright.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 08:17:44 +00:00

228 lines
13 KiB
JavaScript

import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import vm from 'node:vm'
// Statische Pruefung des Einbau-Skripts public/embed/termine.js (Aufgabe 19, Rulings R3 und R4):
// - Das Widget rechnet nie mit dem Datum oder der Zeitzone des Browsers (Kalender, Wochentage, Uhrzeiten und Datumstexte kommen
// vom Server: monat, tage, zeiten, datumLang). Darum kein Date, getDay, toLocale*, Intl.
// - Kein Browser-Speicher, keine Cookies, kein Auslesen von Referrer oder Zeitzone, keine Fremdadressen im Code.
// - Kundentext nur ueber textContent/Attribute: innerHTML nur mit festen Zeichenketten.
// Die Pruefung der Seiten drumherum (Demo, Einbauanleitung) steht am Ende.
const lies = (rel) => readFileSync(new URL(`../${rel}`, import.meta.url), 'utf8')
const QUELLE = lies('public/embed/termine.js')
/** Quelltext ohne Kommentare (die Kopfzeile darf Beispiele mit Adressen enthalten, der Code nicht). */
const OHNE_KOMMENTARE = QUELLE.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|\s)\/\/.*$/gm, '$1')
test('Quelltext ist gueltiges JavaScript (kompiliert, wird nicht ausgefuehrt)', () => {
assert.doesNotThrow(() => new vm.Script(QUELLE, { filename: 'termine.js' }))
})
test('kein Browser-Speicher, keine Cookies, kein Referrer, keine Zeitzone', () => {
for (const verboten of ['localStorage', 'sessionStorage', 'document.cookie', 'indexedDB', 'document.referrer', 'sendBeacon', 'navigator.userAgent', 'resolvedOptions', 'getTimezoneOffset']) {
assert.ok(!OHNE_KOMMENTARE.includes(verboten), `${verboten} darf im Widget nicht vorkommen`)
}
})
test('keine Datums- und Zeitzonenrechnung im Browser (Date, getDay, toLocale, Intl.)', () => {
assert.ok(!/\bDate\b/.test(OHNE_KOMMENTARE), 'Date darf nicht vorkommen')
for (const verboten of ['getDay', 'toLocale', 'Intl.', 'getFullYear', 'getMonth', 'getHours', 'getTime(', 'Date.now']) {
assert.ok(!OHNE_KOMMENTARE.includes(verboten), `${verboten} darf im Widget nicht vorkommen`)
}
})
test('innerHTML nur mit festen Zeichenketten; kein outerHTML, insertAdjacentHTML, document.write, eval', () => {
const treffer = [...OHNE_KOMMENTARE.matchAll(/innerHTML/g)]
for (const t of treffer) {
const rest = OHNE_KOMMENTARE.slice(t.index + 'innerHTML'.length)
assert.match(rest, /^\s*=\s*(?:'[^'\\+]*'|"[^"\\+]*")\s*(?:;|\n|\))/, 'innerHTML darf nur eine feste Zeichenkette bekommen')
}
for (const verboten of ['outerHTML', 'insertAdjacentHTML', 'document.write', 'eval(', 'new Function', 'setTimeout("', "setTimeout('", 'createContextualFragment', 'srcdoc']) {
assert.ok(!OHNE_KOMMENTARE.includes(verboten), `${verboten} darf im Widget nicht vorkommen`)
}
})
test('keine festen Fremdadressen im Code (nur die Adresse aus data-api oder dem Skript)', () => {
assert.doesNotMatch(OHNE_KOMMENTARE, /https?:\/\//, 'Adressen gehoeren in data-api, nicht in den Code')
assert.doesNotMatch(OHNE_KOMMENTARE, /\bimport\s*\(|\bimportScripts\b|\bXMLHttpRequest\b|\bWebSocket\b/)
})
test('Anfragen ohne Cookies und nur an die Termine-Schnittstelle', () => {
assert.ok(OHNE_KOMMENTARE.includes("credentials: 'omit'"), "fetch braucht credentials: 'omit'")
assert.ok(OHNE_KOMMENTARE.includes('/api/public/termine/v1/'))
assert.equal([...OHNE_KOMMENTARE.matchAll(/\bfetch\(/g)].length, 1, 'genau ein fetch (eine Stelle fuer alle Anfragen)')
})
test('Pflichtbausteine der Spezifikation (Einbau, Stile, Honeypot, Fuss, Texte)', () => {
for (const noetig of [
'data-webklar-termine', 'data-api', 'data-modus', 'data-knopf', 'data-leistung',
"'wkt-style'", '@layer wkt', 'wkt-',
'--wkt-akzent', '--wkt-akzent-text', '--wkt-hintergrund', '--wkt-text', '--wkt-rand', '--wkt-radius', '--wkt-schrift', '--wkt-schrift-titel',
'kontakt_url', 'autocomplete', 'tabindex', 'aria-hidden', 'aria-live', 'polite', 'aria-selected', 'aria-disabled', 'role', 'dialog',
'performance.now', '100 * 60 * 1000', 'neu_laden', '/widerruf',
'Vertrag widerrufen', 'Datenschutz', 'Terminbuchung über WEBklar', 'Widerruf bestätigen',
'Gerade nicht erreichbar. Bitte rufen Sie an: ',
'Fast geschafft', 'widerrufsbelehrung', 'details',
'ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End', 'PageUp', 'PageDown',
]) {
assert.ok(QUELLE.includes(noetig), `${noetig} fehlt im Skript`)
}
})
test('Stile stehen einmal in <style id="wkt-style">, alle Klassen beginnen mit wkt-', () => {
const css = /var CSS = \[([\s\S]*?)\]\.join/.exec(QUELLE)
assert.ok(css, 'CSS-Block fehlt')
const klassen = [...css[1].matchAll(/\.([a-zA-Z][\w-]*)/g)].map((m) => m[1]).filter((k) => !/^\d/.test(k))
assert.ok(klassen.length > 20)
for (const k of klassen) assert.ok(k.startsWith('wkt-'), `Klasse ${k} ohne Praefix wkt-`)
assert.equal([...QUELLE.matchAll(/getElementById\(STYLE_ID\)/g)].length, 1)
})
test('Ausweichtext wird nie verbraucht (nur Kopien zurueck auf die Seite), Link auf die AGB nur als https-Link', () => {
assert.ok(!/appendChild\(ausweich\)/.test(OHNE_KOMMENTARE), 'der Ausweichtext darf nur als cloneNode(true) zurueckgesetzt werden')
assert.ok(OHNE_KOMMENTARE.includes('ausweich.cloneNode(true)'))
assert.ok(QUELLE.includes('agbUrl') && QUELLE.includes('AGB des Betriebs'))
assert.match(OHNE_KOMMENTARE, /httpsUrl\(c\.betrieb\.agbUrl\)/)
})
// ---------------------------------------------------------------- Telefonnummer, Formular-Token, Widerrufsknoepfe (Abschlussrunde)
/** Die reine Funktion telefonLink des Skripts (zwischen den Marken @telefon-anfang und @telefon-ende) einzeln ausfuehren. */
function telefonFunktion() {
const m = /\/\/ @telefon-anfang[^\n]*\n([\s\S]*?)\/\/ @telefon-ende/.exec(QUELLE)
assert.ok(m, 'Marken @telefon-anfang/@telefon-ende fehlen')
return vm.runInNewContext(`${m[1]}; telefonLink`, {})
}
test('telefonLink: Nummern werden gewaehlt, freier Text nie (Buchstaben, Listen), "(0)" hinter der Landesvorwahl entfaellt', () => {
const telefonLink = telefonFunktion()
const faelle = [
['06301 1234', 'tel:063011234'],
['+49 6301 1234', 'tel:+4963011234'],
['+49 (0) 6301-1234', 'tel:+4963011234'],
['+49(0)6301 1234', 'tel:+4963011234'],
['+49 (0)6301/12 34 5', 'tel:+49630112345'],
['0049 (0) 6301 1234', 'tel:004963011234'],
['(06301) 1234', 'tel:063011234'],
['06301/1234-0', 'tel:0630112340'],
['06301.1234', 'tel:063011234'],
[' +49 160 1234567 ', 'tel:+491601234567'],
['(0)6301 1234', 'tel:063011234'],
// Text mit Buchstaben oder Mehrfachangaben: kein Link
['06301 1234 (ab 9 Uhr)', ''],
['Tel. 06301 1234', ''],
['06301 1234 oder 0171 555', ''],
['0171 1, 0171 2', ''],
['06301 1234; 0171 2', ''],
['ruf an', ''],
['+49 (0) 6301 1234 ext. 5', ''],
// keine Nummer
['', ''], [' ', ''], ['12', ''], ['+', ''], [undefined, ''], [null, ''], [1234567, ''], [{}, ''],
]
for (const [eingabe, soll] of faelle) assert.equal(telefonLink(eingabe), soll, JSON.stringify(eingabe))
})
test('R53: das Widget wartet vor dem Absenden, bis das Formular-Token formMinSekunden plus Puffer alt ist (Einen Moment …), Messung mit performance.now', () => {
for (const noetig of ['formMinSekunden', 'FORM_PUFFER_MS', 'FORM_MIN_VORGABE_S', 'warteAufFormToken', 'Einen Moment …']) {
assert.ok(QUELLE.includes(noetig), `${noetig} fehlt im Skript`)
}
assert.match(OHNE_KOMMENTARE, /FORM_PUFFER_MS\s*=\s*500\b/, 'Puffer 0,5 s')
assert.match(OHNE_KOMMENTARE, /FORM_MIN_VORGABE_S\s*=\s*3\b/, 'Vorgabe 3 s wie der Server')
// der Abruf der Buchung steht erst nach dem Warten
const sende = OHNE_KOMMENTARE.slice(OHNE_KOMMENTARE.indexOf('function sende ('))
assert.ok(sende.indexOf('warteAufFormToken(senden)') > 0 && sende.indexOf("request('POST', '/buchungen'") > sende.indexOf('warteAufFormToken(senden)'), 'POST /buchungen nur nach warteAufFormToken')
assert.equal([...OHNE_KOMMENTARE.matchAll(/request\('POST', '\/buchungen'/g)].length, 1, 'genau eine Stelle sendet Buchungen')
})
test('R54: Widerruf im Fuss ist ein zweiter Knopf (wkt-btn wkt-btn-sek, 44 px), kein Textlink; im Knopfmodus ein Abruf beim Laden und ein dauerhafter Knopf', () => {
assert.ok(OHNE_KOMMENTARE.includes("knopf('wkt-btn wkt-btn-sek', 'Vertrag widerrufen'"), 'Fuss: sekundaerer Knopf')
assert.doesNotMatch(OHNE_KOMMENTARE, /wkt-link/, 'kein Textlink-Knopf')
assert.match(QUELLE, /\.wkt-btn,\.wkt-knopf\{[^}]*min-height:44px/, 'Knoepfe mindestens 44 px hoch')
// Knopfmodus: neben dem Buchungsknopf, oeffnet den Dialog gleich beim Widerruf
assert.ok(OHNE_KOMMENTARE.includes("knopf('wkt-knopf wkt-btn-sek', 'Vertrag widerrufen'"), 'dauerhafter Knopf im Knopfmodus')
assert.ok(OHNE_KOMMENTARE.includes('oeffne(true)'), 'der Knopf oeffnet den Dialog beim Widerrufsformular')
const start = OHNE_KOMMENTARE.slice(OHNE_KOMMENTARE.indexOf('function startKnopf'))
assert.ok(start.includes('lade()') && start.indexOf('lade()') < start.indexOf('widerrufKnopf = knopf'), 'startKnopf holt die Konfiguration beim Laden')
})
// ---------------------------------------------------------------- Textfarbe auf der Betriebsfarbe (Kontrast)
/** Die reinen Farbfunktionen des Skripts (zwischen den Marken @farben-anfang und @farben-ende) einzeln ausfuehren. */
function farbFunktionen() {
const m = /\/\/ @farben-anfang[^\n]*\n([\s\S]*?)\/\/ @farben-ende/.exec(QUELLE)
assert.ok(m, 'Marken @farben-anfang/@farben-ende fehlen')
return vm.runInNewContext(`${m[1]}; ({ luminanz, kontrast, textFarbeAuf })`, {})
}
// Unabhaengige Gegenrechnung (WCAG 2.1: relative Leuchtdichte und Kontrastverhaeltnis)
function wcagKontrast(hexA, hexB) {
const l = (hex) => {
const [r, g, b] = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255).map((c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4))
return 0.2126 * r + 0.7152 * g + 0.0722 * b
}
const [x, y] = [l(hexA), l(hexB)]
return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05)
}
test('Textfarbe auf der Betriebsfarbe erreicht mindestens 4,5 : 1 (auch fuer mittlere Toene)', () => {
const { textFarbeAuf } = farbFunktionen()
// Mitteltoene, bei denen Weiss allein nur 2,5 bis 3,6 : 1 haette
for (const farbe of ['#ea580c', '#16a34a', '#0ea5e9', '#14b8a6', '#ec4899']) {
const text = textFarbeAuf(farbe)
assert.ok(wcagKontrast(farbe, text) >= 4.5, `${farbe}: ${text} nur ${wcagKontrast(farbe, text).toFixed(2)} : 1`)
assert.ok(wcagKontrast(farbe, text) >= wcagKontrast(farbe, '#ffffff') - 1e-9, `${farbe}: Weiss waere besser gewesen`)
}
// dunkle und helle Farben behalten Weiss bzw. das dunkle Grau
assert.equal(textFarbeAuf('#0f766e'), '#ffffff')
assert.equal(textFarbeAuf('#1d4ed8'), '#ffffff')
assert.equal(textFarbeAuf('#fde68a'), '#111827')
assert.equal(textFarbeAuf('#ffffff'), '#111827')
assert.equal(textFarbeAuf('#000000'), '#ffffff')
})
test('Textfarbe: kein Farbton faellt unter 4,5 : 1 (alle Grautoene und ein Raster ueber den Farbraum)', () => {
const { textFarbeAuf, luminanz, kontrast } = farbFunktionen()
const hex = (r, g, b) => `#${[r, g, b].map((c) => c.toString(16).padStart(2, '0')).join('')}`
const proben = []
for (let g = 0; g < 256; g += 1) proben.push(hex(g, g, g))
for (let r = 0; r < 256; r += 15) for (let g = 0; g < 256; g += 15) for (let b = 0; b < 256; b += 15) proben.push(hex(r, g, b))
for (const farbe of proben) {
const text = textFarbeAuf(farbe)
assert.ok(['#ffffff', '#111827', '#000000'].includes(text), `${farbe}: unerwartete Textfarbe ${text}`)
assert.ok(wcagKontrast(farbe, text) >= 4.5, `${farbe}: ${text} nur ${wcagKontrast(farbe, text).toFixed(2)} : 1`)
assert.ok(Math.abs(kontrast(luminanz(farbe), luminanz(text)) - wcagKontrast(farbe, text)) < 1e-9, `${farbe}: Skript und Gegenrechnung weichen ab`)
}
})
// ---------------------------------------------------------------- Demo und Einbauanleitung
test('Demo-Seite: noindex, Ausweichtext, Skript per defer, beide Modi', () => {
const demo = lies('public/embed/termine-demo.html')
assert.match(demo, /<meta name="robots" content="noindex"/)
assert.match(demo, /<script src="\/embed\/termine\.js" defer><\/script>/)
assert.match(demo, /data-webklar-termine="abcdefghijklmnop"/)
assert.match(demo, /data-modus="inline"/)
assert.match(demo, /data-modus="knopf"/)
assert.match(demo, /data-knopf="/)
assert.match(demo, /Termine telefonisch/, 'Ausweichtext fehlt')
})
test('Einbauanleitung: Snippet, Variablen, drei Stile, Datenschutz-Absatz, Abnahme-Checkliste, API-Kurzreferenz', () => {
const doc = lies('docs/funktionen/termine-einbau.md')
for (const noetig of [
'<div data-webklar-termine="SITEKEY"', '<script src="https://project.webklar.com/embed/termine.js" defer></script>',
'data-modus', 'data-api', 'data-knopf', 'data-leistung',
'--wkt-akzent', '--wkt-akzent-text', '--wkt-hintergrund', '--wkt-text', '--wkt-rand', '--wkt-radius', '--wkt-schrift', '--wkt-schrift-titel',
'@layer wkt', 'wkt-style',
'docs/rechtliches/termine-datenschutz-absatz.md', 'ENTWURF',
'Abnahme', '/api/public/termine/v1', '/config', '/tage', '/zeiten', '/buchungen', '/widerruf', '/kalender/',
'termine-demo.html', 'termine-widget.ui.mjs',
]) {
assert.ok(doc.includes(noetig), `${noetig} fehlt in der Einbauanleitung`)
}
assert.equal([...doc.matchAll(/^### Stil \d/gm)].length, 3, 'drei Stilbeispiele')
assert.ok([...doc.matchAll(/^- \[ \] /gm)].length >= 8, 'Abnahme-Checkliste mit Punkten')
})