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>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-03 08:17:44 +00:00
1 parent bd65c9dc58
commit 794a63c0d7
12 files changed
+536 -64

No files matched your search

+6
View File
@@ -660,6 +660,10 @@ test('G02: Zusammenfassung, Verwaltungslink, Preis, Person, Storno und Widerruf
assert.match(voll.text, /Absage bis 24 Stunden vorher kostenlos\./)
assert.match(voll.text, /Wert-betrieb_name hat Ihre Terminanfrage angenommen/)
assert.match(voll.text, /Vertrag widerrufen: https:\/\/project\.webklar\.com\/widerrufen\.html#m=tok/)
// Ruling R55/Abschlussrunde: der Knopf geht nur bis zum Beginn des Termins, danach Formular auf der Website oder E-Mail
const WEG = 'Bis zum Beginn des Termins geht das über den Knopf „Vertrag widerrufen“; danach über das Formular „Vertrag widerrufen“ auf der Website von Wert-betrieb_name oder per E-Mail an Wert-betrieb_email.'
assert.ok(voll.text.includes(WEG), voll.text)
assert.doesNotMatch(voll.text, /jederzeit den Knopf/)
// Belehrung wörtlich, Zeilenumbrüche bleiben, Sonderzeichen sind maskiert
assert.match(voll.html, /Widerrufsrecht: 14 Tage &lt;b&gt;ohne&lt;\/b&gt; Gründe\.<br>Zweite Zeile &amp; mehr/)
assert.ok(voll.text.includes('Widerrufsbelehrung\n\nWiderrufsrecht: 14 Tage <b>ohne</b> Gründe.\nZweite Zeile & mehr'), voll.text)
@@ -680,6 +684,8 @@ test('G02: Zusammenfassung, Verwaltungslink, Preis, Person, Storno und Widerruf
const du = renderMail('G02', values, { du: true, anfrage_angenommen: true, widerruf: true })
assert.match(du.text, /Dein Termin ist bestätigt/)
assert.match(du.text, /hat deine Terminanfrage angenommen/)
assert.ok(du.text.includes(WEG), du.text)
assert.doesNotMatch(du.text, /jederzeit/)
assert.doesNotMatch(du.text, SIE_FORM)
})
+60
View File
@@ -87,6 +87,66 @@ test('Ausweichtext wird nie verbraucht (nur Kopien zurueck auf die Seite), Link
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. */
+35
View File
@@ -110,6 +110,41 @@ test('termin.css: keine Fremdressourcen (keine Schrift- oder Bildadressen von au
assert.doesNotMatch(CSS, /https?:\/\/|@import|url\(/, 'termin.css darf nichts nachladen')
})
// ---------------------------------------------------------------- Telefonnummer (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 (dieselbe Regel wie im Widget)', () => {
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'],
['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', ''],
['', ''], [' ', ''], ['12', ''], ['+', ''], [undefined, ''], [null, ''], [1234567, ''], [{}, ''],
]
for (const [eingabe, soll] of faelle) assert.equal(telefonLink(eingabe), soll, JSON.stringify(eingabe))
})
// ---------------------------------------------------------------- Textfarbe auf der Betriebsfarbe (Kontrast)
/** Die reinen Farbfunktionen des Skripts (zwischen den Marken @farben-anfang und @farben-ende) einzeln ausfuehren. */
+8 -1
View File
@@ -570,7 +570,14 @@ async function pruefungen() {
setzeBetrieb({ telefon: '+49 (0) 6301-1234' })
await oeffne(page, 'v', b.vToken)
await page.getByRole('link', { name: '+49 (0) 6301-1234' }).waitFor()
eq(await page.getByRole('link', { name: '+49 (0) 6301-1234' }).getAttribute('href'), 'tel:+49063011234', 'bereinigter tel:-Link')
eq(await page.getByRole('link', { name: '+49 (0) 6301-1234' }).getAttribute('href'), 'tel:+4963011234', 'bereinigter tel:-Link: die nationale Null (0) entfaellt')
// freier Text mit Buchstaben ist keine Nummer, die man waehlen kann: Text ohne Link (dieselbe Regel wie im Widget)
setzeBetrieb({ telefon: '06301 1234 (ab 9 Uhr)' })
await oeffne(page, 'v', b.vToken)
await warteH1(page, 'Diese Reservierung ist abgelaufen.')
assert((await text(page)).includes('Telefon: 06301 1234 (ab 9 Uhr)'), 'Telefon als Text')
eq(await page.locator('#karte a').count(), 0, 'kein tel:-Link fuer freien Text')
}, { eingabe: salonEingabe({ betrieb: { ...salonEingabe().betrieb, name: FEIND_NAME, telefon: 'javascript:alert(1)', adresse: '<b>Hauptstr. 1</b>' } }) })
// ------------------------------------------------------------ 10. Du-Form
+39 -1
View File
@@ -1736,7 +1736,7 @@ async function pruefungen() {
// Hinweise der Spezifikation
const text = await textVon(page.locator('#tm-panel'))
// Ruling R52: Wortlaut der rechtlichen Hinweise
eq(await textVon(page.locator('#tm-panel .tm-box-info', { hasText: 'Privatpersonen können eine Buchung' })), 'Privatpersonen können eine Buchung innerhalb von 14 Tagen nach Vertragsschluss widerrufen (bei Anfragen zählt deine Annahme). Das gesetzliche Widerrufsrecht geht deiner Stornoregel vor: In dieser Zeit fällt bei einem Widerruf keine Stornogebühr an, auch wenn deine Stornofrist schon abgelaufen ist. Liegt der Termin innerhalb dieser 14 Tage, fragt der Terminplaner deine Gäste, ob du vorher beginnen darfst. Belehrung und Knopf „Vertrag widerrufen“ zeigt der Terminplaner automatisch, wenn deine Gäste Privatpersonen sind und du eine Leistung mit sofortiger Buchung anbietest.', 'Hinweis: Widerruf schlägt Stornoregel')
eq(await textVon(page.locator('#tm-panel .tm-box-info', { hasText: 'Privatpersonen können eine Buchung' })), 'Privatpersonen können eine Buchung innerhalb von 14 Tagen nach Vertragsschluss widerrufen (bei Anfragen zählt deine Annahme). Das gesetzliche Widerrufsrecht geht deiner Stornoregel vor: In dieser Zeit fällt bei einem Widerruf keine Stornogebühr an, auch wenn deine Stornofrist schon abgelaufen ist. Liegt der Termin innerhalb dieser 14 Tage, fragt der Terminplaner deine Gäste, ob du vorher beginnen darfst. Belehrung und Knopf „Vertrag widerrufen“ zeigt der Terminplaner automatisch, wenn deine Gäste Privatpersonen sind, bei sofort buchbaren Leistungen ebenso wie bei Anfragen.', 'Hinweis: Widerruf schlägt Stornoregel (R55: auch Anfragen)')
eq(await textVon(page.locator('#tm-panel label.tm-haken small', { hasText: 'Nur ankreuzen, wenn' })), 'Nur ankreuzen, wenn deine Termine Freizeitveranstaltungen zu einem festen Termin sind (§ 312g Abs. 2 Nr. 9 BGB), zum Beispiel ein einzelner Tanzabend oder ein Escape Room. Friseur, Kosmetik, Massage, Tattoo, Beratung, Fotoshootings und Weiterbildungen sind in der Regel keine Freizeitleistung. Kreuzt du es an, zeigt der Terminplaner keine Widerrufsbelehrung. Kreuzt du es zu Unrecht an, können deine Gäste noch bis zu 12 Monate und 14 Tage widerrufen. Im Zweifel nicht ankreuzen oder dich rechtlich beraten lassen.', 'Hinweis: Freizeit')
assert(text.includes('kein fertiger Rechtstext') && await page.locator('.tm-muster .tm-pille', { hasText: 'Muster' }).count() === 1, 'Stornotext-Muster als Muster gekennzeichnet')
assert(text.includes('Bitte keine Gesundheitsangaben.'), 'Hinweis am Nachrichtenfeld')
@@ -1860,6 +1860,43 @@ async function pruefungen() {
}, { reset: { ohneEinstellung: true } })
// ------------------------------------------------------------ Abgeschaltet: alle neuen Reiter nur lesen
// ------------------------------------------------------------ Einbau-Code der Website (Abschlussrunde C4)
await check('echt_einbau_code_sichtbar_und_kopierbar', async (page, ctx) => {
await ctx.grantPermissions(['clipboard-read', 'clipboard-write'], { origin: srv.url })
await oeffneReiter(page, 'einstellungen')
const box = page.locator('#tm-panel section', { has: page.getByRole('heading', { name: 'Einbau auf deiner Website' }) })
await box.waitFor()
const code = box.locator('textarea.tm-code-feld')
const antwort = await page.evaluate(async () => (await (await fetch('/api/termine/einstellungen?projectId=p1')).json()).einbau.snippet)
eq(await code.inputValue(), antwort, 'der Code ist der Einbau-Code des Servers')
assert(antwort.includes(`data-webklar-termine="${KEY}"`) && antwort.includes('/embed/termine.js'), `Code mit siteKey und Skript: ${antwort}`)
eq(await code.getAttribute('readonly'), '', 'nur lesbar')
eq(await code.getAttribute('aria-label'), 'Einbau-Code für deine Website', 'Name des Feldes')
const hoehe = await knopf(box, 'Code kopieren').boundingBox()
assert(hoehe.height >= 44 || (await page.viewportSize()).width > 880, `Knopf gut treffbar: ${hoehe.height}`)
await shot(page, 'einstellungen-einbau')
await knopf(box, 'Code kopieren').click()
await box.getByText('Der Code ist kopiert.').waitFor()
eq(await page.evaluate(() => navigator.clipboard.readText()), antwort, 'Zwischenablage')
assert(await passtInBreite(page), 'kein seitliches Scrollen')
})
await check('echt_einbau_code_ohne_zwischenablage', async (page, ctx) => {
await ctx.addInitScript(() => Object.defineProperty(navigator, 'clipboard', { value: undefined, configurable: true })) // kein Zugriff auf die Zwischenablage
await oeffneReiter(page, 'einstellungen')
const box = page.locator('#tm-panel section', { has: page.getByRole('heading', { name: 'Einbau auf deiner Website' }) })
await knopf(box, 'Code kopieren').click()
const status = box.locator('[role="status"]')
await status.filter({ hasText: 'Das Kopieren hat nicht geklappt.' }).waitFor()
assert((await textVon(status)).includes('markiert'), 'Hinweis: Code ist markiert')
const auswahl = await page.evaluate(() => {
const el = document.activeElement
return { code: el?.classList.contains('tm-code-feld'), von: el?.selectionStart, bis: el?.selectionEnd, laenge: el?.value.length }
})
eq(auswahl.code, true, 'Fokus im Code-Feld')
eq([auswahl.von, auswahl.bis], [0, auswahl.laenge], 'der ganze Code ist markiert')
})
await check('echt_abgeschaltet_reiter_nur_lesen', async (page) => {
await srv.setzeFunktion('disabled')
const v0 = version()
@@ -1895,6 +1932,7 @@ async function pruefungen() {
eq(await alleGesperrt(form), true, 'Einstellungen: alle Felder gesperrt')
eq(await knopf(page, 'Einstellungen speichern').count(), 0, 'Einstellungen: kein Speichern')
eq(await knopf(form, 'Muster in das Textfeld übernehmen').count() + await knopf(form, '+ Zusatzfeld').count(), 0, 'Einstellungen: keine Änderungsknöpfe')
eq(await page.locator('#tm-panel [data-aktion="einbau-kopieren"], #tm-panel .tm-code-feld').count(), 0, 'Einstellungen: kein Einbau-Code bei abgeschalteter Funktion')
eq(await page.getByRole('link', { name: /CSV/ }).count(), 1, 'Einstellungen: Export bleibt')
assert((await textVon(page.locator('#tm-panel .tm-status'))).includes('Abgeschaltet'), 'Einstellungen: Status')
await shot(page, 'abgeschaltet-einstellungen')
+188 -2
View File
@@ -249,7 +249,8 @@ async function pruefungen() {
const knopf = page.getByRole('button', { name: 'Termin online buchen' })
await knopf.waitFor()
await page.waitForTimeout(400)
eq(anfragen.length, 0, 'Vor dem Klick darf nichts geladen werden')
// R54: beim Laden der Seite genau ein Abruf, die Konfiguration (fuer den Knopf "Vertrag widerrufen"), sonst nichts
eq(anfragen.map((u) => new URL(u).pathname.replace(/^.*\/v1\/[^/]+/, '')), ['/config'], 'Vor dem Klick nur die Konfiguration')
assert(await page.locator('#ausweich').count() === 0 || !(await page.locator('#ausweich').isVisible()), 'Ausweichtext darf neben dem Knopf nicht stehen')
eq(await page.locator('dialog.wkt-dialog').count(), 1, 'ein <dialog>')
assert(!(await page.locator('dialog.wkt-dialog').evaluate((d) => d.open)), 'Dialog ist zu')
@@ -257,7 +258,7 @@ async function pruefungen() {
await knopf.click()
const dlg = page.locator('dialog.wkt-dialog')
await karte(dlg, 'Herrenschnitt').waitFor()
assert(anfragen.some((u) => u.includes('/config')), 'Konfiguration wird beim Klick geladen')
eq(anfragen.filter((u) => u.includes('/config')).length, 1, 'der Klick laedt die Konfiguration nicht noch einmal')
assert(await dlg.evaluate((d) => d.open && d.matches(':modal')), 'Dialog muss modal offen sein')
eq(await dlg.getAttribute('aria-label'), 'Termin online buchen', 'Name des Dialogs')
await shot(page, 'knopf-dialog')
@@ -303,6 +304,81 @@ async function pruefungen() {
assert((await dlg.locator('.wkt-hinweis').first().innerText()).startsWith('Damenschnitt'), 'vorgewaehlte Leistung steht im Termin-Schritt')
})
// Ruling R54: die Widerrufsfunktion steht im Knopfmodus dauerhaft neben dem Buchungsknopf und oeffnet den Dialog gleich beim Formular
await check('knopf_widerruf_knopf_dauerhaft', async (page) => {
const anfragen = []
page.on('request', (r) => { if (r.url().includes('/api/public/termine/')) anfragen.push(new URL(r.url()).pathname.replace(/^.*\/v1\/[^/]+/, '')) })
await oeffne(page, seitenHtml(eingebettet('data-modus="knopf" data-knopf="Termin online buchen"')))
const haupt = page.getByRole('button', { name: 'Termin online buchen' })
const widerruf = page.locator('.wkt-knopf-box > button', { hasText: 'Vertrag widerrufen' })
await widerruf.waitFor()
await page.waitForTimeout(300)
eq(anfragen, ['/config'], 'nur ein Abruf beim Laden der Seite')
assert(!(await page.locator('dialog.wkt-dialog').evaluate((d) => d.open)), 'Dialog ist zu')
const boxen = await page.evaluate(() => {
const [a, b] = [...document.querySelectorAll('.wkt-knopf-box > button')].map((e) => e.getBoundingClientRect())
return { gleicheZeile: Math.abs(a.top - b.top) < 4, rechtsDavon: b.left >= a.right - 1, hoeheB: b.height, hoeheA: a.height }
})
assert(boxen.gleicheZeile && boxen.rechtsDavon, `Widerrufsknopf neben dem Buchungsknopf: ${JSON.stringify(boxen)}`)
assert(boxen.hoeheB >= 44, `Widerrufsknopf mindestens 44 px hoch: ${boxen.hoeheB}`)
const stil = await widerruf.evaluate((b) => ({ grund: getComputedStyle(b).backgroundColor, rand: getComputedStyle(b).borderTopColor }))
assert(stil.grund === 'rgba(0, 0, 0, 0)' || stil.grund === 'transparent', `sekundaer (ohne Fuellung): ${stil.grund}`)
await shot(page, 'knopf-mit-widerruf')
await widerruf.click()
const dlg = page.locator('dialog.wkt-dialog')
await warteTitel(dlg, 'Vertrag widerrufen')
assert(await dlg.evaluate((d) => d.open && d.matches(':modal')), 'Dialog modal offen')
eq(await dlg.locator('input[name=name]').count(), 1, 'Widerrufsformular')
eq(anfragen, ['/config'], 'auch das Oeffnen laedt nichts nach')
eq(await dlg.getByRole('button', { name: 'Zurück zur Terminbuchung' }).count(), 1, 'Weg zurueck zur Buchung')
// Fuss im Dialog: ebenfalls ein Knopf (aber auf der Widerrufsseite zeigt er dieselbe Seite)
await dlg.getByRole('button', { name: 'Zurück zur Terminbuchung' }).click()
await karte(dlg, 'Herrenschnitt').waitFor()
eq(anfragen, ['/config'], 'Zurueck zur Buchung laedt nichts nach')
// Esc: der Fokus kehrt zu dem Knopf zurueck, der den Dialog geoeffnet hat
await page.keyboard.press('Escape')
assert(await widerruf.evaluate((b) => document.activeElement === b), 'Fokus nicht zurueck auf dem Widerrufsknopf')
// Der Buchungsknopf zeigt wieder die Buchung, nicht das Widerrufsformular
await widerruf.click()
await warteTitel(dlg, 'Vertrag widerrufen')
await page.keyboard.press('Escape')
await haupt.click()
await karte(dlg, 'Herrenschnitt').waitFor()
eq(await dlg.locator('input[name=name]').count(), 0, 'Buchungsknopf: kein Widerrufsformular')
assert(await haupt.evaluate((b) => b !== document.activeElement), 'Dialog offen: Fokus im Dialog')
// Widerruf aus dem Fuss des laufenden Dialogs, dann zurueck (der Stand bleibt)
await karte(dlg, 'Herrenschnitt').click()
await dlg.locator('.wkt-fuss').getByRole('button', { name: 'Vertrag widerrufen' }).click()
await warteTitel(dlg, 'Vertrag widerrufen')
await dlg.getByRole('button', { name: 'Zurück zur Terminbuchung' }).click()
await karte(dlg, 'Egal').waitFor()
})
// Widerrufsformular gleich beim Oeffnen, danach "Zurueck zur Terminbuchung" beginnt die Buchung mit der vorgewaehlten Leistung
await check('knopf_widerruf_mit_vorgewaehlter_leistung', async (page) => {
await oeffne(page, seitenHtml(eingebettet(`data-modus="knopf" data-knopf="Damenschnitt buchen" data-leistung="${S_DAMEN}"`)))
await page.locator('.wkt-knopf-box > button', { hasText: 'Vertrag widerrufen' }).click()
const dlg = page.locator('dialog.wkt-dialog')
await warteTitel(dlg, 'Vertrag widerrufen')
await dlg.getByRole('button', { name: 'Zurück zur Terminbuchung' }).click()
await warteTitel(dlg, 'Bei wem')
eq(await karte(dlg, 'Herrenschnitt').count(), 0, 'Leistungsliste darf nicht erscheinen')
})
await check('knopf_ohne_widerrufsrecht_kein_widerrufsknopf', async (page) => {
const anfragen = []
page.on('request', (r) => { if (r.url().includes('/api/public/termine/')) anfragen.push(r.url()) })
await oeffne(page, seitenHtml(eingebettet('data-modus="knopf" data-knopf="Termin buchen"')))
await page.getByRole('button', { name: 'Termin buchen' }).waitFor()
await page.waitForTimeout(500)
eq(await page.locator('.wkt-knopf-box > button').count(), 1, 'nur der Buchungsknopf (Gaeste sind Unternehmen)')
eq(anfragen.length, 1, 'ein Abruf beim Laden')
await page.getByRole('button', { name: 'Termin buchen' }).click()
await karte(page.locator('dialog.wkt-dialog'), 'Herrenschnitt').waitFor()
eq(await page.locator('dialog.wkt-dialog .wkt-fuss').getByRole('button', { name: 'Vertrag widerrufen' }).count(), 0, 'auch im Fuss kein Widerruf')
}, { eingabe: salonEingabe({ recht: { gaesteSind: 'unternehmer', freizeit: false } }) })
// ---------------------------------------------------------------- 4. Nur Tastatur
await check('nur_tastatur', async (page) => {
@@ -687,6 +763,69 @@ async function pruefungen() {
eq(konfig.length, 2, 'nach 101 Minuten wird neu geladen')
}, { initScript: () => { const echt = performance.now.bind(performance); window.__vorsprung = 0; performance.now = () => echt() + window.__vorsprung } })
// Ruling R53: ein frisch geladenes Formular-Token ist dem Server zu jung (formMinSekunden): das Widget wartet, statt eine Buchung
// abzusenden, die der Server mit der normalen Erfolgsantwort verwirft
const MIN_SEKUNDEN = 3
const mitVorsprung = () => { const echt = performance.now.bind(performance); window.__vorsprung = 0; performance.now = () => echt() + window.__vorsprung }
await check('formular_token_nach_neuladen_wartet_und_bucht', async (page) => {
srv.config.termine.formMinSekunden = MIN_SEKUNDEN
const konfig = []
page.on('request', (r) => { if (r.url().includes('/config')) konfig.push(r.url()) })
await oeffne(page, seitenHtml(eingebettet('data-modus="inline"')))
const wd = await bisZusammenfassung(page, { tag: '2026-10-26' })
eq(konfig.length, 1, 'bisher eine Konfiguration')
// die Konfiguration ist im Widget aelter als 100 Minuten: sende() laedt sie neu und das Token ist beim Absenden nur Millisekunden alt
await page.evaluate(() => { window.__vorsprung = 101 * 60 * 1000 })
await wd.locator('input[name=storno]').check()
const knopf = wd.getByRole('button', { name: 'Zahlungspflichtig buchen' })
const start = Date.now()
await knopf.click()
await wd.getByRole('button', { name: 'Einen Moment …' }).waitFor()
eq(konfig.length, 2, 'Konfiguration neu geladen')
eq(await wd.getByRole('button', { name: 'Einen Moment …' }).isDisabled(), true, 'Knopf bleibt gesperrt')
eq(srv.buchungen().length, 0, 'waehrend des Wartens noch nichts gesendet')
await wd.getByRole('heading', { name: 'Fast geschafft' }).waitFor()
const dauer = Date.now() - start
assert(dauer >= (MIN_SEKUNDEN + 0.4) * 1000, `das Widget wartete nur ${dauer} ms`)
await srv.warteAufVersand()
eq(srv.buchungen().map((b) => [b.gastEmail, b.status]), [['max@example.com', 'gehalten']], 'die Buchung wurde wirklich angelegt (kein stilles 201)')
eq(srv.mails().map((m) => m.to.map((t) => t.address).join()), ['max@example.com'], 'G01 an den Gast')
}, { initScript: mitVorsprung })
await check('formular_token_neu_laden_und_gleich_nochmal_bucht', async (page) => {
srv.config.termine.formMinSekunden = MIN_SEKUNDEN
await oeffne(page, seitenHtml(eingebettet('data-modus="inline"')))
const wd = await bisZusammenfassung(page, { tag: '2026-10-26' })
await wd.locator('input[name=storno]').check()
srv.schiebeUhr(3 * 60 * 60 * 1000) // beim Server ist das Token jetzt aelter als 2 Stunden
await wd.getByRole('button', { name: 'Zahlungspflichtig buchen' }).click()
await wd.locator('.wkt-meldung').waitFor()
eq(await wd.locator('.wkt-meldung').innerText(), 'Das Formular wurde erneuert. Bitte versuchen Sie es noch einmal.', 'Hinweis nach neu_laden')
eq(srv.buchungen().length, 0, 'noch nichts gebucht')
// sofort noch einmal: das neue Token ist erst Sekundenbruchteile alt
await wd.getByRole('button', { name: 'Zahlungspflichtig buchen' }).click()
await wd.getByRole('button', { name: 'Einen Moment …' }).waitFor()
await wd.getByRole('heading', { name: 'Fast geschafft' }).waitFor()
await srv.warteAufVersand()
eq(srv.buchungen().length, 1, 'zweiter Versuch bucht trotz junger Token')
eq(srv.mails().map((m) => m.to.map((t) => t.address).join()), ['max@example.com'], 'G01 an den Gast')
})
await check('formular_token_wartezeit_zurueck_bricht_ab', async (page) => {
srv.config.termine.formMinSekunden = MIN_SEKUNDEN
await oeffne(page, seitenHtml(eingebettet('data-modus="inline"')))
const wd = await bisZusammenfassung(page, { tag: '2026-10-26' })
await wd.locator('input[name=storno]').check()
await page.evaluate(() => { window.__vorsprung = 101 * 60 * 1000 })
await wd.getByRole('button', { name: 'Zahlungspflichtig buchen' }).click()
await wd.getByRole('button', { name: 'Einen Moment …' }).waitFor()
await wd.getByRole('button', { name: 'Zurück' }).click() // der Gast geht waehrend des Wartens einen Schritt zurueck
await warteTitel(wd, 'Ihre Angaben')
await page.waitForTimeout((MIN_SEKUNDEN + 1.2) * 1000)
eq(srv.buchungen().length, 0, 'es wird nichts mehr gesendet, wenn die Ansicht gewechselt hat')
eq(await titel(wd).innerText(), 'Ihre Angaben', 'die Ansicht bleibt, wo der Gast ist')
}, { initScript: mitVorsprung })
// ---------------------------------------------------------------- 10. Widerruf ohne Link
await check('vertrag_widerrufen_im_fuss', async (page) => {
@@ -694,6 +833,12 @@ async function pruefungen() {
const wd = w(page)
await karte(wd, 'Herrenschnitt').waitFor()
eq((await wd.locator('.wkt-fuss').innerText()).replace(/\s+/g, ' '), 'Vertrag widerrufen Datenschutz Terminbuchung über WEBklar', 'Fuss')
// Ruling R54: ein zweiter Knopf (wkt-btn wkt-btn-sek), kein Textlink; mindestens 44 px hoch
const fussKnopf = wd.locator('.wkt-fuss button')
eq(await fussKnopf.getAttribute('class'), 'wkt-btn wkt-btn-sek', 'Klassen des Widerrufsknopfs')
const fussBox = await fussKnopf.boundingBox()
assert(fussBox.height >= 44, `Widerrufsknopf im Fuss mindestens 44 px hoch: ${fussBox.height}`)
eq(await fussKnopf.evaluate((b) => getComputedStyle(b).textDecorationLine), 'none', 'kein unterstrichener Textlink')
const ds = wd.locator('.wkt-fuss a')
eq([await ds.getAttribute('href'), await ds.getAttribute('rel'), await ds.getAttribute('target')], ['https://salon-mueller.example/datenschutz', 'noopener noreferrer', '_blank'], 'Datenschutz-Link')
await wd.getByRole('button', { name: 'Vertrag widerrufen' }).click()
@@ -716,6 +861,47 @@ async function pruefungen() {
await karte(wd, 'Herrenschnitt').waitFor()
})
// Ruling R55: auch ein Betrieb mit nur Anfrage-Leistungen (Privatpersonen) zeigt Belehrung und Widerrufsfunktion
await check('nur_anfragen_zeigen_belehrung_und_widerruf', async (page) => {
await oeffne(page, seitenHtml(eingebettet('data-modus="inline"')))
const wd = w(page)
await karte(wd, 'Brautfrisur').waitFor()
eq(await wd.locator('.wkt-karte').count(), 1, 'nur die Anfrage-Leistung')
eq((await wd.locator('.wkt-fuss').innerText()).replace(/\s+/g, ' '), 'Vertrag widerrufen Datenschutz Terminbuchung über WEBklar', 'Fuss mit Widerruf')
await bisZusammenfassung(page, { leistung: 'Brautfrisur', tag: '2026-10-26' })
await wd.locator('details.wkt-belehrung').waitFor()
assert((await wd.locator('details.wkt-belehrung').innerText()).includes('Widerrufsbelehrung'), 'Belehrung vor dem Absenden')
await hakenUndBuchen(wd, 'Termin anfragen')
await srv.warteAufVersand()
const buchungen = srv.buchungen()
eq(buchungen.map((b) => [b.buchungsart, b.verbraucher]), [['anfrage', true]], 'Anfrage von einer Privatperson')
assert(/^[0-9a-f]{64}$/.test(JSON.parse(buchungen[0].nachweis).widerruf?.hash || ''), 'Nachweis haelt die gezeigte Belehrung fest')
}, { eingabe: (() => { const e = salonEingabe(); e.leistungen = e.leistungen.filter((l) => l.buchungsart === 'anfrage'); return e })() })
// Telefonnummer im Ausfalltext: Nummer wird zum Link, freier Text bleibt Text (kein Link auf eine falsche Nummer)
const ausfallOeffnen = async (page) => {
await oeffne(page, seitenHtml(eingebettet('data-modus="inline"')))
const wd = w(page)
await karte(wd, 'Herrenschnitt').click()
await karte(wd, 'Egal').click()
await wd.locator('td.wkt-tag').first().waitFor()
await page.route(API, (route) => route.abort())
await tag(wd, '2026-10-12').click()
await wd.locator('.wkt-ausfall').waitFor()
return wd.locator('.wkt-ausfall')
}
const mitTelefon = (telefon) => { const e = salonEingabe(); e.betrieb.telefon = telefon; return e }
await check('ausfall_telefon_freitext_bleibt_text', async (page) => {
const meldung = await ausfallOeffnen(page)
eq(await meldung.innerText(), 'Gerade nicht erreichbar. Bitte rufen Sie an: 06301 1234 (ab 9 Uhr)', 'Text mit Nummer und Zusatz')
eq(await meldung.locator('a').count(), 0, 'kein tel:-Link fuer freien Text')
}, { eingabe: mitTelefon('06301 1234 (ab 9 Uhr)') })
await check('ausfall_telefon_landesvorwahl_ohne_null', async (page) => {
const meldung = await ausfallOeffnen(page)
eq(await meldung.locator('a').getAttribute('href'), 'tel:+4963011234', '(0) hinter der Landesvorwahl entfaellt')
eq(await meldung.locator('a').innerText(), '+49 (0) 6301-1234', 'angezeigt wird, was der Betrieb eingetragen hat')
}, { eingabe: mitTelefon('+49 (0) 6301-1234') })
// ---------------------------------------------------------------- 11. Stile der Seite
await check('seitenstile_und_betriebsfarbe', async (page, ctx) => {