Terminplaner: Einbau-Skript, Demo und Einbauanleitung
public/embed/termine.js: Buchungs-Widget fuer Kundenwebsites (inline und Knopf mit dialog, Monatsraster nach APG, Haken-Texte und Knopf aus der Konfiguration, Honigtopf kontakt_url, Widerrufsfunktion, Konfiguration nach 100 min neu, Ausweichtext bei Fehlern). Rechnet nie mit Datum oder Zeitzone des Browsers. Dazu Demo-Seite, Einbauanleitung fuer Mitarbeiter, statischer Test und Playwright-Skript mit wiederverwendbarem UI-Testserver (test/ui/termineTestServer.mjs). Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
bb45d35ec7
commit
5fef7e47f0
6 files changed
+2825
No files matched your search
@@ -0,0 +1,164 @@
|
||||
# Terminplaner auf der Kundenwebsite einbauen
|
||||
|
||||
Anleitung für das WEBklar-Team. Das Widget lässt Gäste auf der Website des Kunden Termine buchen. Leistungen, Personen und Öffnungszeiten pflegt der Kunde im Portal (Tab „Funktionen“, Kachel „Terminplaner“). Den `siteKey` findest Du im Ticketsystem beim Projekt (16 Zeichen, a–z und 2–7). Ohne aktive Funktion, ohne gespeicherte Einstellung und ohne Telefonnummer des Betriebs (bei Verbrauchern auch ohne Anschrift) ist nichts buchbar: Das Widget zeigt dann den Ausweichtext der Seite.
|
||||
|
||||
## 1. Das Snippet
|
||||
|
||||
```html
|
||||
<div data-webklar-termine="SITEKEY" data-api="https://project.webklar.com" data-modus="inline">
|
||||
<p>Termine telefonisch unter 06301 1234.</p> <!-- Ausweichtext, siehe Abschnitt 4 -->
|
||||
</div>
|
||||
<script src="https://project.webklar.com/embed/termine.js" defer></script>
|
||||
```
|
||||
|
||||
Das Skript steht einmal pro Seite, vor `</body>`. Es hat keine Abhängigkeiten, setzt keine Cookies, speichert nichts im Browser und lädt nichts von Dritten. Texte der Kunden setzt es nur als Text ein, nie als HTML. Es wird 5 Minuten gecacht und darf von jeder Domain geladen werden.
|
||||
|
||||
## 2. Variablen (Attribute am `div`)
|
||||
|
||||
| Attribut | Pflicht | Bedeutung |
|
||||
| --- | --- | --- |
|
||||
| `data-webklar-termine` | ja | der `siteKey` des Projekts |
|
||||
| `data-api` | nein | Herkunft des Portals, Standard `https://project.webklar.com`. Ohne Angabe nimmt das Skript die Herkunft seiner eigenen Adresse. Das klappt nur bei einem normalen `<script src>`. Bei Modul-Import, Tag-Manager oder nachgeladenem Skript ist `data-api` Pflicht. |
|
||||
| `data-modus` | nein | `inline` (Standard): die Buchungsstrecke steht direkt in der Seite und lädt beim Aufruf der Seite. `knopf`: ein Knopf öffnet die Strecke in einem Dialog (`<dialog>`), Daten werden erst beim Klick geladen. |
|
||||
| `data-knopf` | nein | Text des Knopfes im Modus `knopf`, Standard „Termin buchen“. |
|
||||
| `data-leistung` | nein | ID einer Leistung (`s_` plus 8 Zeichen, steht im Portal bei der Leistung). Die Leistung ist vorgewählt, der Schritt „Leistung wählen“ entfällt. Praktisch für einen Knopf neben einer Leistung auf der Seite. Eine unbekannte ID wird ignoriert. |
|
||||
|
||||
Alle Klassen beginnen mit `wkt-`. Die Stile stehen einmalig in `<style id="wkt-style">` und zwar in `@layer wkt`. Das heißt: **jede** Regel der Kundenseite (ohne Layer) gewinnt gegen die Standard-Stile. Das ist gewollt, hat aber eine Folge: Hat das Theme der Kundenseite globale Regeln wie `button{…}` oder `input{…}`, greifen sie auch im Widget. Prüfe das beim Abnehmen und überschreibe bei Bedarf gezielt, z. B. `[data-webklar-termine] .wkt-btn{ … }`.
|
||||
|
||||
Das Layout richtet sich nach der Breite des `div` (Container Query, braucht aktuelle Browser), nicht nach dem Bildschirm. Das `div` muss eine Breite haben (normaler Blockcontainer; in einem Flex- oder Grid-Element ohne Breite schrumpft es auf null).
|
||||
|
||||
### Zwei Widgets auf einer Seite
|
||||
|
||||
Das geht. Jedes Widget bekommt eigene IDs, die Stile werden nur einmal eingefügt. Beispiel: zwei Knöpfe neben zwei Leistungen.
|
||||
|
||||
```html
|
||||
<div data-webklar-termine="SITEKEY" data-modus="knopf" data-knopf="Damenschnitt buchen" data-leistung="s_abcd1234"></div>
|
||||
<div data-webklar-termine="SITEKEY" data-modus="knopf" data-knopf="Färben buchen" data-leistung="s_efgh5678"></div>
|
||||
```
|
||||
|
||||
## 3. Aussehen anpassen (CSS-Variablen)
|
||||
|
||||
Setze die Variablen auf dem `div`, in der Seiten-CSS oder auf `:root`, z. B. `[data-webklar-termine]{ --wkt-akzent:#0f766e; }`. Alle sind optional.
|
||||
|
||||
| Variable | Standard |
|
||||
| --- | --- |
|
||||
| `--wkt-akzent` (Knöpfe, aktive Tage, Rahmen der freien Tage) | die Betriebsfarbe aus den Einstellungen, sonst `#1d4ed8` |
|
||||
| `--wkt-akzent-text` (Schrift auf dem Akzent) | passend zur Betriebsfarbe (hell oder dunkel), sonst `#fff` |
|
||||
| `--wkt-hintergrund` | `#fff` |
|
||||
| `--wkt-text` | `#1f2937` |
|
||||
| `--wkt-rand` (Eingabefelder, Karten) | `#858f9e` |
|
||||
| `--wkt-radius` | `10px` |
|
||||
| `--wkt-schrift` | die Schrift der Seite (`inherit`) |
|
||||
| `--wkt-schrift-titel` (Überschriften) | wie `--wkt-schrift` |
|
||||
|
||||
Die Betriebsfarbe gilt nur, solange `--wkt-akzent` nicht gesetzt ist. Setzt Du `--wkt-akzent`, setze auch `--wkt-akzent-text`. Im Modus `knopf` kennt das Widget die Betriebsfarbe erst nach dem Klick: Der Knopf selbst nimmt `--wkt-akzent` der Seite oder `#1d4ed8`. Setze `--wkt-akzent` dort also immer.
|
||||
|
||||
Kontrast (WCAG 2.1 AA): Text auf dem Hintergrund mindestens 4,5 : 1, Schrift auf dem Akzent mindestens 4,5 : 1, `--wkt-rand` gegen den Hintergrund mindestens 3 : 1 (sonst sind Eingabefelder schwer zu erkennen). Der Fokusrahmen nimmt die Textfarbe.
|
||||
|
||||
### Stil 1: hell und freundlich (Salon, Kosmetik)
|
||||
|
||||
```css
|
||||
[data-webklar-termine]{
|
||||
--wkt-akzent:#9d4b6f;
|
||||
--wkt-akzent-text:#fff;
|
||||
--wkt-hintergrund:#fff;
|
||||
--wkt-text:#3b2a33;
|
||||
--wkt-rand:#8c7580;
|
||||
--wkt-radius:14px;
|
||||
--wkt-schrift:"Nunito", system-ui, sans-serif;
|
||||
--wkt-schrift-titel:"Fredoka", system-ui, sans-serif;
|
||||
}
|
||||
```
|
||||
|
||||
### Stil 2: dunkel (Tattoo, Studio, Bar)
|
||||
|
||||
```css
|
||||
[data-webklar-termine]{
|
||||
--wkt-akzent:#f5c542;
|
||||
--wkt-akzent-text:#111;
|
||||
--wkt-hintergrund:#18181b;
|
||||
--wkt-text:#f4f4f5;
|
||||
--wkt-rand:#8b8b94;
|
||||
--wkt-radius:4px;
|
||||
--wkt-schrift:"Inter", system-ui, sans-serif;
|
||||
}
|
||||
```
|
||||
|
||||
### Stil 3: schlicht und eckig (Kanzlei, Handwerk, Beratung)
|
||||
|
||||
```css
|
||||
[data-webklar-termine]{
|
||||
--wkt-akzent:#1f3a5f;
|
||||
--wkt-akzent-text:#fff;
|
||||
--wkt-hintergrund:#fff;
|
||||
--wkt-text:#1a1a1a;
|
||||
--wkt-rand:#767676;
|
||||
--wkt-radius:0;
|
||||
--wkt-schrift:Georgia, "Times New Roman", serif;
|
||||
--wkt-schrift-titel:system-ui, sans-serif;
|
||||
}
|
||||
```
|
||||
|
||||
Die Schriften lädt das Skript nicht selbst. Sind sie auf der Kundenseite nicht eingebunden, greift die Systemschrift.
|
||||
|
||||
## 4. Ausweichtext
|
||||
|
||||
Alles, was im `div` steht, gehört der Kundenwebsite. Das Skript ersetzt es nur, wenn die Konfiguration geladen werden konnte. Bei ausgeschaltetem JavaScript, unbekanntem Schlüssel, abgeschalteter Funktion, fehlenden Pflichtangaben des Betriebs oder Netzfehler beim Laden bleibt der Text stehen. Schreib dort die Telefonnummer des Betriebs hinein und gestalte den Text im Stil der Seite.
|
||||
|
||||
Im Modus `knopf` ersetzt der Knopf den Text auf der Seite. Der Text erscheint dafür im Dialog, wenn das Laden beim Klick scheitert.
|
||||
|
||||
Bricht das Netz mitten in der Strecke ab, zeigt das Widget „Gerade nicht erreichbar. Bitte rufen Sie an: {Telefon des Betriebs}“ (bei Anrede „du“: „Bitte ruf an“).
|
||||
|
||||
## 5. Was das Widget zeigt
|
||||
|
||||
1. Leistung wählen (Name, Dauer, Preis, Kurzbeschreibung) mit der Zahlungsart („Bezahlung vor Ort: …“). Entfällt bei `data-leistung`.
|
||||
2. Person wählen („Egal“ zuerst). Nur wenn mehrere Personen infrage kommen und der Betrieb die Wahl erlaubt; bei Plätzen entfällt der Schritt.
|
||||
3. Tag (Monatsraster, nur Tage mit freien Zeiten sind wählbar) und Uhrzeit. Alle Daten und Uhrzeiten kommen vom Server in Berliner Zeit. Das Widget rechnet nie mit dem Datum oder der Zeitzone des Browsers.
|
||||
4. Angaben: Name, E-Mail, Telefon, Nachricht und Zusatzfelder nach den Einstellungen des Betriebs.
|
||||
5. Zusammenfassung mit Stornobedingungen, Widerrufsbelehrung (aufklappbar, bei Verbraucher-Betrieben), Datenschutz-Hinweis, den Haken (Storno, vorzeitiger Beginn, Erinnerung) und dem Knopf („Zahlungspflichtig buchen“, „Termin buchen“ oder „Termin anfragen“, kommt vom Server).
|
||||
6. „Fast geschafft“: Der Gast bestätigt den Termin über den Link in der E-Mail. Die Reservierung gilt 30 Minuten.
|
||||
|
||||
Im Fuß stehen „Vertrag widerrufen“ (nur wenn der Betrieb verbindliche Leistungen für Verbraucher anbietet), „Datenschutz“ (Link auf die Datenschutzerklärung des Betriebs) und klein „Terminbuchung über WEBklar“. Anrede (Sie oder Du) und Betriebsfarbe kommen aus den Einstellungen. Die rechtlich vorgeschriebenen Texte (Haken, Belehrung) stehen immer in der Sie-Form.
|
||||
|
||||
Bedienung nur mit der Tastatur: Tab und Umschalt+Tab zwischen den Elementen, im Monatsraster Pfeiltasten (Tag), Pos1 und Ende (Wochenanfang und -ende), Bild ↑ und Bild ↓ (Monat), Eingabe oder Leertaste wählt den Tag. Im Dialog schließt Esc.
|
||||
|
||||
## 6. Datenschutzerklärung des Kunden
|
||||
|
||||
Das Skript lädt beim Aufruf der Seite (Modus `inline`) bzw. beim Klick (Modus `knopf`) Termine und Texte von `project.webklar.com`. Dabei sieht der Server die IP-Adresse des Besuchers. Die Datenschutzerklärung der Website braucht deshalb den Absatz „Online-Terminbuchung“ aus `docs/rechtliches/termine-datenschutz-absatz.md` (zwei Fassungen: Sie und Du, nimm die zur Erklärung der Website passende). Der Absatz trägt den Kopf **„ENTWURF – nur Pilot, vor Einsatz bei fremden Betrieben prüfen“**. Solange der Kopf dort steht, gilt: nur beim Pilotkunden einsetzen, bei anderen Betrieben erst nach der Rechtsprüfung. Die `[[wenn …]]`-Sätze richten sich nach den Einstellungen des Betriebs (Telefon-Feld, Nachricht, Erinnerung, Verbraucher). Bau das Widget erst ein, wenn der Absatz in der Datenschutzerklärung steht. Die URL der Datenschutzerklärung (und des Impressums) muss in den Betriebsangaben im Portal stehen, sonst fehlt der Link im Fuß.
|
||||
|
||||
## 7. Abnahme-Checkliste
|
||||
|
||||
- [ ] Funktion im Ticketsystem aktiviert (Zustand „aktiv“), `TERMINE_ENABLED` auf `pilot` oder `on`, Kunde im Pilot.
|
||||
- [ ] Einstellungen im Portal gespeichert, Telefon und Anschrift des Betriebs eingetragen (das Portal weist darauf hin, solange etwas fehlt), URLs von Impressum und Datenschutz mit `https://`.
|
||||
- [ ] Datenschutz-Absatz steht in der Datenschutzerklärung der Website (Abschnitt 6).
|
||||
- [ ] Ausweichtext prüfen: JavaScript aus, Funktion im Ticketsystem ausgeschaltet oder falscher `siteKey`: Der Text steht, nichts ist kaputt.
|
||||
- [ ] Komplett durchbuchen bis „Fast geschafft“; die Bestätigungs-E-Mail kommt an, der Link bestätigt, die zweite E-Mail (mit Kalenderdatei) kommt an, im Portal steht der Termin. Danach den Testtermin absagen (Verwaltungslink aus der E-Mail oder im Portal).
|
||||
- [ ] Bei Preis und Verbraucher-Betrieb: Widerrufsbelehrung aufklappbar, Haken für vorzeitigen Beginn erscheint bei Terminen in den nächsten 14 Tagen, „Vertrag widerrufen“ steht im Fuß und das Formular funktioniert.
|
||||
- [ ] Nur mit der Tastatur bedienen: Fokus immer sichtbar, Monatsraster mit Pfeilen, Pos1/Ende und Bild ↑/↓, Esc schließt den Dialog und der Fokus geht zum Knopf zurück.
|
||||
- [ ] Handy (390 px Breite): kein seitliches Scrollen, Knöpfe gut treffbar, Dialog passt in den Bildschirm.
|
||||
- [ ] Zoom 200 % und große Schrift: nichts wird abgeschnitten.
|
||||
- [ ] Kontrast der gesetzten Variablen geprüft (Abschnitt 3), globale Regeln des Themes (`button`, `input`) stören nicht.
|
||||
- [ ] Zwei Widgets auf einer Seite (falls geplant) stören sich nicht.
|
||||
- [ ] Anderer Browser, andere Zeitzone (z. B. im Browser auf New York stellen): dieselben Tage und Uhrzeiten wie in Berlin.
|
||||
- [ ] Entwicklerwerkzeuge: keine Cookies, nichts in Local/Session Storage, keine Anfrage an fremde Hosts außer dem Portal.
|
||||
- [ ] Automatische Prüfung gegen die Testseite: `nice node test/ui/termine-widget.ui.mjs` (startet einen eigenen Testserver, Screenshots unter `/root/reports/terminplaner-ui/`).
|
||||
|
||||
## 8. API-Kurzreferenz
|
||||
|
||||
Alle Pfade unter `https://project.webklar.com/api/public/termine/v1`. Antworten sind JSON. Fehler haben die Form `{ "fehler": { "feld": "Text" }, "code": "…" }` (`_` = Fehler ohne Feld). Ist der `siteKey` unbekannt, die Funktion nicht aktiv oder der Betrieb noch nicht buchbar, antworten alle Pfade unter `/:siteKey` mit `200 { "aktiv": false }`. Appwrite nicht erreichbar: `503 { "fehler": { "_": "Gerade nicht erreichbar" } }`. Die Antworten enthalten nie interne IDs.
|
||||
|
||||
| Methode und Pfad | Zweck |
|
||||
| --- | --- |
|
||||
| `GET /:siteKey/config` | Betrieb (Name, Telefon, Adresse, URLs), Anrede, Farbe, Leistungen mit Knopftext, Personen, Regeln, Zahlungsart, Stornotext, `widerruf`, Texte der Haken und der Widerrufsbelehrung, Datenschutz-Hinweis, `formToken`. Eine Minute gecacht; das Widget lädt sie nach 100 Minuten neu. |
|
||||
| `GET /:siteKey/tage?leistung=&ressource=&monat=YYYY-MM` | Tage mit freien Zeiten plus Kalenderdaten des Monats (`monat`) |
|
||||
| `GET /:siteKey/zeiten?leistung=&ressource=&tag=YYYY-MM-DD` | freie Startzeiten, `datumLang`, `vorzeitigNoetig` (Haken für vorzeitigen Beginn an diesem Tag) |
|
||||
| `POST /:siteKey/buchungen` | Formular absenden. Antwort `201 { ok, email: "m***@gmail.com", minuten }`. Honeypot `kontakt_url` und Formular-Token (Fehlercode `neu_laden`) schützen vor Automaten. |
|
||||
| `POST /:siteKey/widerruf` | Widerruf ohne Link: `{ name, email, nummer }`, Antwort immer gleich `{ ok, eingang: { datum, uhrzeit } }` |
|
||||
| `POST /termin/vorschau`, `bestaetigen`, `absagen`, `verschieben`, `widerruf`, `tage`, `zeiten` | Seiten der Gäste (Token aus dem Link der E-Mail, `termin.html`) |
|
||||
| `GET /kalender/:siteKey/:feedToken.ics` | Kalender-Abo des Betriebs je Person (geheimer Link aus dem Portal) |
|
||||
|
||||
POST-Anfragen sind nur von Seiten des Betriebs erlaubt (Host des Projekts bzw. `liveDomain` samt `www`-Gegenstück) und vom Portal selbst; andere Herkunft: `403`. Grenzen: 120 Abrufe pro Minute und 10 Buchungen pro 10 Minuten je IP, 60 Buchungen pro Stunde je Funktion. Mehr in `server/routes/publicTermine.js`.
|
||||
|
||||
## 9. Testen
|
||||
|
||||
`/embed/termine-demo.html` auf dem Portal ist eine nicht verlinkte Testseite (Strecke inline und als Knopf). Sie braucht einen Testbetrieb mit dem `siteKey` `abcdefghijklmnop`. Automatische Prüfungen: `node --test test/termineEmbedStatic.test.js` (Quelltext) und `nice node test/ui/termine-widget.ui.mjs` (Browser, eigener Testserver mit Nachbau der Datenbank; vorher `free -m`).
|
||||
@@ -0,0 +1,37 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="robots" content="noindex">
|
||||
<title>Terminplaner Testseite</title>
|
||||
<style>
|
||||
body{margin:0;font-family:system-ui,sans-serif;background:#f6f7f9;color:#1f2937;line-height:1.5}
|
||||
header,footer{padding:16px 24px;background:#e8ebf0}
|
||||
main{max-width:760px;margin:0 auto;padding:32px 16px}
|
||||
h2{margin:32px 0 12px}
|
||||
.fallback{margin:0;padding:16px;border:2px dashed #94a3b8;border-radius:12px;background:#fff}
|
||||
/* Gestaltung ueber CSS-Variablen der Seite (alle optional, siehe docs/funktionen/termine-einbau.md):
|
||||
[data-webklar-termine]{ --wkt-akzent:#0f766e; --wkt-radius:4px; } */
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>Testseite der Kundenwebsite (intern, nicht verlinkt)</header>
|
||||
<main>
|
||||
<h1>Termin buchen</h1>
|
||||
|
||||
<h2>Strecke direkt in der Seite (inline)</h2>
|
||||
<!-- data-api weglassen = Herkunft des Skripts; der Ausweichtext bleibt ohne JavaScript und bei Fehlern stehen -->
|
||||
<div data-webklar-termine="abcdefghijklmnop" data-modus="inline">
|
||||
<p class="fallback" id="ausweich-inline">Termine telefonisch unter 06301 1234.</p>
|
||||
</div>
|
||||
|
||||
<h2>Als Knopf (Dialog, lädt erst beim Klick)</h2>
|
||||
<div data-webklar-termine="abcdefghijklmnop" data-modus="knopf" data-knopf="Termin online buchen">
|
||||
<p class="fallback" id="ausweich-knopf">Termine telefonisch unter 06301 1234.</p>
|
||||
</div>
|
||||
</main>
|
||||
<footer>Fußbereich</footer>
|
||||
<script src="/embed/termine.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,111 @@
|
||||
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)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- 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')
|
||||
})
|
||||
@@ -0,0 +1,852 @@
|
||||
// Playwright-Checks des Terminplaner-Widgets public/embed/termine.js gegen den UI-Testserver (test/ui/termineTestServer.mjs):
|
||||
// echte Router, Nachbau von Appwrite/Stripe, Mail-Senke. Nicht Teil von npm test.
|
||||
// Aufruf: nice node test/ui/termine-widget.ui.mjs (vorher free -m; es laeuft nur ein Browser)
|
||||
// Screenshots: /root/reports/terminplaner-ui/widget-*.png (bei Fehlern fehler-widget-<Pruefung>.png). Exit-Code 1, wenn eine Pruefung scheitert.
|
||||
//
|
||||
// Seiten: Das Widget laeuft auf Seiten von srv.pageOrigin (http://localhost:PORT) und spricht die Schnittstelle unter
|
||||
// srv.url (http://127.0.0.1:PORT) an: echte Cross-Origin-Anfragen mit Preflight. Die Demo-Seite (/embed/termine-demo.html)
|
||||
// laeuft ohne data-api (gleiche Herkunft). Server-Uhr: Montag 2026-10-05 10:00 Berliner Zeit (laeuft in Echtzeit weiter).
|
||||
import http from 'node:http'
|
||||
import { mkdir, readdir, readFile, rm } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import {
|
||||
startTermineUiServer, salonEingabe,
|
||||
R_ANNA, S_DAMEN, S_HERREN, S_KENNEN,
|
||||
} from './termineTestServer.mjs'
|
||||
|
||||
const PW = process.env.PLAYWRIGHT_CORE || '/root/.npm/_npx/db89d7302a373f10/node_modules/playwright-core/index.mjs'
|
||||
const { chromium } = await import(PW)
|
||||
|
||||
const SHOTS = '/root/reports/terminplaner-ui'
|
||||
await mkdir(SHOTS, { recursive: true })
|
||||
for (const datei of await readdir(SHOTS)) if (/^(widget|fehler-widget)-.*\.png$/.test(datei)) await rm(path.join(SHOTS, datei)) // alte Laeufe
|
||||
const WIDGET_JS = path.join(path.dirname(fileURLToPath(import.meta.url)), '../../public/embed/termine.js')
|
||||
|
||||
const srv = await startTermineUiServer({ crossOrigin: true })
|
||||
const KEY = srv.SITE_KEY
|
||||
const SEITE = srv.pageOrigin
|
||||
const API = '**/api/public/termine/v1/**'
|
||||
|
||||
// ---------------------------------------------------------------- Helfer
|
||||
|
||||
function assert(cond, msg) { if (!cond) throw new Error(msg) }
|
||||
function eq(ist, soll, msg) {
|
||||
const a = JSON.stringify(ist)
|
||||
const b = JSON.stringify(soll)
|
||||
if (a !== b) throw new Error(`${msg}: ${a} statt ${b}`)
|
||||
}
|
||||
|
||||
const eingebettet = (attrs = '', fallback = '<p id="ausweich">Termine telefonisch unter 06301 1234.</p>', key = KEY) =>
|
||||
`<div data-webklar-termine="${key}" data-api="${srv.url}" ${attrs}>${fallback}</div>`
|
||||
|
||||
const seitenHtml = (widgets, { head = '', style = '', skript = '<script src="/embed/termine.js" defer></script>' } = {}) => `<!doctype html>
|
||||
<html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Testseite</title>
|
||||
<style>body{margin:0;font-family:system-ui,sans-serif;background:#f6f7f9;color:#1f2937}main{max-width:760px;margin:0 auto;padding:24px 16px}${style}</style>${head}</head>
|
||||
<body><main><h1>Testseite</h1>${widgets}</main>${skript}</body></html>`
|
||||
|
||||
/** Seite von srv.pageOrigin mit eigenem Inhalt (liefert der Testserver selbst unter /ui-seiten/). */
|
||||
async function oeffne(page, html, name = 'testseite.html') {
|
||||
await page.goto(srv.seite(name, html))
|
||||
}
|
||||
|
||||
const shot = (page, name) => page.screenshot({ path: `${SHOTS}/widget-${name}.png`, fullPage: true })
|
||||
const w = (page, i = 0) => page.locator('[data-webklar-termine]').nth(i)
|
||||
const titel = (wd) => wd.locator('h2.wkt-titel').first()
|
||||
const karte = (wd, text) => wd.locator('.wkt-karte', { hasText: text })
|
||||
const tag = (wd, t) => wd.locator(`td[data-tag="${t}"]`)
|
||||
const zeit = (wd, z) => wd.locator('.wkt-zeit', { hasText: new RegExp(`^${z}$`) })
|
||||
const warteTitel = (wd, text) => wd.locator('h2.wkt-titel', { hasText: text }).waitFor()
|
||||
|
||||
/** Leistung, Person, Tag, Uhrzeit, Angaben ausfuellen bis zur Zusammenfassung. Fehlt ein Wert (null), entfaellt der Schritt. */
|
||||
async function bisZusammenfassung(page, o = {}) {
|
||||
const wd = w(page, o.widget ?? 0)
|
||||
const v = { leistung: 'Damenschnitt', person: 'Egal', tag: '2026-10-12', zeit: '10:00', name: 'Max Mustermann', email: 'max@example.com', ...o }
|
||||
if (v.leistung) await karte(wd, v.leistung).click()
|
||||
if (v.person) await karte(wd, v.person).click()
|
||||
await tag(wd, v.tag).click()
|
||||
await zeit(wd, v.zeit).click()
|
||||
await wd.locator('input[name=name]').fill(v.name)
|
||||
await wd.locator('input[name=email]').fill(v.email)
|
||||
if (v.telefon) await wd.locator('input[name=telefon]').fill(v.telefon)
|
||||
await wd.getByRole('button', { name: 'Weiter' }).click()
|
||||
await wd.locator('.wkt-zusammen').waitFor()
|
||||
return wd
|
||||
}
|
||||
|
||||
async function hakenUndBuchen(wd, knopf) {
|
||||
for (const n of ['storno', 'vorzeitig']) {
|
||||
const cb = wd.locator(`input[name=${n}]`)
|
||||
if (await cb.count()) await cb.check()
|
||||
}
|
||||
await wd.getByRole('button', { name: knopf }).click()
|
||||
await wd.getByRole('heading', { name: 'Fast geschafft' }).waitFor()
|
||||
}
|
||||
|
||||
/** Kein Streifen rechts: Seite und Widget passen in die Breite. */
|
||||
const passtInBreite = (page) => page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
|
||||
|
||||
// ---------------------------------------------------------------- Ablauf der Pruefungen
|
||||
|
||||
const results = []
|
||||
let browser = null
|
||||
const hosts = new Set()
|
||||
|
||||
async function check(name, fn, { eingabe, context = {}, initScript } = {}) {
|
||||
await srv.zuruecksetzen({ eingabe })
|
||||
const ctx = await browser.newContext({ viewport: { width: 1000, height: 900 }, ...context })
|
||||
const page = await ctx.newPage()
|
||||
page.setDefaultTimeout(8000)
|
||||
const fehler = []
|
||||
page.on('pageerror', (e) => fehler.push(e.message))
|
||||
page.on('request', (r) => { try { const u = new URL(r.url()); if (u.protocol.startsWith('http')) hosts.add(u.host) } catch { /* data: u. a. */ } })
|
||||
if (initScript) await page.addInitScript(initScript)
|
||||
try {
|
||||
await fn(page, ctx)
|
||||
assert(fehler.length === 0, `JavaScript-Fehler auf der Seite: ${fehler.join(' | ')}`)
|
||||
results.push([name, true])
|
||||
console.log(`ok ${name}`)
|
||||
} catch (e) {
|
||||
results.push([name, false])
|
||||
console.log(`FAIL ${name}: ${e.message.split('\n')[0]}`)
|
||||
await page.screenshot({ path: `${SHOTS}/fehler-widget-${name}.png`, fullPage: true }).catch(() => {})
|
||||
} finally {
|
||||
await ctx.close()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 1. Inline bis "Fast geschafft"
|
||||
|
||||
async function pruefungen() {
|
||||
await check('inline_bis_fast_geschafft', async (page, ctx) => {
|
||||
await oeffne(page, seitenHtml(eingebettet('data-modus="inline"')))
|
||||
const wd = w(page)
|
||||
await karte(wd, 'Herrenschnitt').waitFor()
|
||||
assert(await page.locator('#ausweich').count() === 0, 'Ausweichtext blieb stehen, obwohl das Widget lief')
|
||||
eq(await titel(wd).innerText(), 'Welche Leistung möchten Sie buchen?', 'Titel Schritt 1')
|
||||
assert((await wd.innerText()).includes('Bezahlung vor Ort: bar, EC-Karte, Kreditkarte'), 'Zahlungsart fehlt im ersten Schritt')
|
||||
assert((await karte(wd, 'Damenschnitt').innerText()).includes('ab 45,00 €'), 'Preis "ab" fehlt')
|
||||
assert((await karte(wd, 'Damenschnitt').innerText()).includes('45 Minuten'), 'Dauer fehlt')
|
||||
await shot(page, 'inline-1-leistung')
|
||||
|
||||
await karte(wd, 'Damenschnitt').click()
|
||||
await warteTitel(wd, 'Bei wem')
|
||||
assert(await karte(wd, 'Egal').count() === 1 && await karte(wd, 'Anna').count() === 1 && await karte(wd, 'Ben').count() === 1, 'Personenwahl: Egal, Anna, Ben')
|
||||
assert((await wd.locator('.wkt-karte').first().innerText()).startsWith('Egal'), '"Egal" muss die erste Option sein')
|
||||
await shot(page, 'inline-2-person')
|
||||
|
||||
await karte(wd, 'Egal').click()
|
||||
await warteTitel(wd, 'Wann passt es Ihnen?')
|
||||
await wd.locator('td.wkt-tag').first().waitFor()
|
||||
eq(await wd.locator('.wkt-monat').innerText(), 'Oktober 2026', 'Monat')
|
||||
eq(await tag(wd, '2026-10-11').getAttribute('aria-disabled'), 'true', 'Sonntag ist gesperrt')
|
||||
eq(await tag(wd, '2026-10-12').getAttribute('aria-disabled'), 'false', 'Montag ist frei')
|
||||
eq(await tag(wd, '2026-10-12').getAttribute('aria-selected'), 'false', 'noch nichts gewaehlt')
|
||||
await tag(wd, '2026-10-12').click()
|
||||
await wd.locator('.wkt-zeit').first().waitFor()
|
||||
eq(await tag(wd, '2026-10-12').getAttribute('aria-selected'), 'true', 'gewaehlter Tag')
|
||||
eq(await wd.locator('.wkt-zeiten-titel').innerText(), 'Freie Zeiten am Montag, 12.10.2026', 'Titel der Uhrzeiten')
|
||||
const zeiten = await wd.locator('.wkt-zeit').allInnerTexts()
|
||||
eq(zeiten.slice(0, 3), ['09:00', '09:15', '09:30'], 'erste Uhrzeiten (Berliner Wanduhr)')
|
||||
assert(!zeiten.includes('12:30') && zeiten.includes('13:30'), 'Mittagspause (12:30 bis 13:30) muss fehlen')
|
||||
await shot(page, 'inline-3-termin')
|
||||
|
||||
await zeit(wd, '10:00').click()
|
||||
await warteTitel(wd, 'Ihre Angaben')
|
||||
await wd.locator('input[name=name]').fill('Max Mustermann')
|
||||
await wd.locator('input[name=email]').fill('max@example.com')
|
||||
await wd.locator('input[name=telefon]').fill('0171 1234567')
|
||||
await shot(page, 'inline-4-daten')
|
||||
await wd.getByRole('button', { name: 'Weiter' }).click()
|
||||
|
||||
await warteTitel(wd, 'Bitte prüfen Sie Ihre Buchung')
|
||||
const zus = await wd.locator('.wkt-zusammen').innerText()
|
||||
for (const t of ['Damenschnitt (45 Minuten)', 'Montag, 12.10.2026', '10:00 Uhr', 'ab 45,00 € (inkl. USt)', 'Salon Müller, Hauptstr. 1, 67722 Winnweiler', 'Max Mustermann', 'max@example.com']) {
|
||||
assert(zus.replace(/\s+/g, ' ').includes(t), `Zusammenfassung ohne "${t}": ${zus}`)
|
||||
}
|
||||
assert((await wd.locator('.wkt-box').innerText()).includes('Danach berechnen wir 50 % des Preises.'), 'Stornoregel im Klartext fehlt')
|
||||
const det = wd.locator('details.wkt-belehrung')
|
||||
assert(await det.count() === 1 && await det.evaluate((d) => !d.open), 'Widerrufsbelehrung muss aufklappbar und zu sein')
|
||||
await det.locator('summary').click()
|
||||
assert((await det.innerText()).includes('Widerrufsbelehrung') && (await det.innerText()).includes('Muster-Widerrufsformular'), 'Belehrung nicht lesbar')
|
||||
await det.locator('summary').click()
|
||||
assert((await wd.locator('.wkt-datenschutz').innerText()).startsWith('Datenschutz. Wir verwenden Ihre Angaben'), 'Datenschutz-Hinweis')
|
||||
eq(await wd.locator('.wkt-datenschutz a').getAttribute('rel'), 'noopener noreferrer', 'rel am Datenschutz-Link')
|
||||
// Haken: Storno und vorzeitiger Beginn Pflicht (12.10. liegt in der Widerrufsfrist), Erinnerung freiwillig und nicht vorangekreuzt
|
||||
eq(await wd.locator('label[for$="h-storno"]').innerText(), 'Ich habe die Stornobedingungen gelesen.', 'Haken Storno')
|
||||
assert((await wd.locator('label[for$="h-vorzeitig"]').innerText()).startsWith('Ich verlange ausdrücklich, dass Salon Müller mit der Leistung vor Ablauf der Widerrufsfrist beginnt.'), 'Haken vorzeitig')
|
||||
eq(await wd.locator('label[for$="h-erinnerung"]').innerText(), 'Ich möchte 24 Stunden vor dem Termin eine Erinnerung per E-Mail erhalten.', 'Haken Erinnerung')
|
||||
assert(!(await wd.locator('input[name=erinnerung]').isChecked()), 'Erinnerung darf nicht vorangekreuzt sein')
|
||||
assert(await wd.locator('input[name=storno]').getAttribute('required') !== null && await wd.locator('input[name=vorzeitig]').getAttribute('required') !== null, 'Pflicht-Haken')
|
||||
assert(await wd.locator('input[name=erinnerung]').getAttribute('required') === null, 'Erinnerung ist freiwillig')
|
||||
eq(await wd.locator('.wkt-form button[type=submit]').innerText(), 'Zahlungspflichtig buchen', 'Knopftext aus der Konfiguration')
|
||||
await shot(page, 'inline-5-pruefen')
|
||||
|
||||
await hakenUndBuchen(wd, 'Zahlungspflichtig buchen')
|
||||
eq(await titel(wd).innerText(), 'Fast geschafft', 'Titel Ergebnis')
|
||||
eq(await wd.locator('.wkt-ergebnis-text').innerText(),
|
||||
'Bitte bestätigen Sie den Termin über den Link, den wir an m***@example.com geschickt haben. Der Termin ist 30 Minuten für Sie reserviert.', 'Ergebnistext')
|
||||
await shot(page, 'inline-6-fast-geschafft')
|
||||
|
||||
// Server: genau eine gehaltene Buchung, G01 an den Gast mit Bestaetigungslink
|
||||
await srv.warteAufVersand()
|
||||
const buchungen = srv.buchungen()
|
||||
eq(buchungen.length, 1, 'Anzahl Buchungen')
|
||||
eq([buchungen[0].status, buchungen[0].gastEmail, buchungen[0].leistungId, buchungen[0].gastName], ['gehalten', 'max@example.com', S_DAMEN, 'Max Mustermann'], 'Buchung')
|
||||
const g01 = srv.mails().filter((m) => m.to.some((t) => t.address === 'max@example.com'))
|
||||
eq(g01.length, 1, 'G01-Mails an den Gast')
|
||||
assert(/termin\.html#v=[A-Za-z0-9_-]{43}/.test(g01[0].text), 'G01 ohne Bestätigungslink')
|
||||
assert(!g01[0].bcc?.length, 'nie BCC')
|
||||
|
||||
// Kein Cookie, kein Speicher
|
||||
const speicher = await page.evaluate(() => ({ c: document.cookie, l: localStorage.length, s: sessionStorage.length }))
|
||||
eq(speicher, { c: '', l: 0, s: 0 }, 'Cookies/Storage')
|
||||
eq((await ctx.cookies()).length, 0, 'Cookies im Browser')
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- 2. Demo-Seite und Aufbau
|
||||
|
||||
await check('demo_seite_stile_und_honigtopf', async (page) => {
|
||||
await page.goto(`${SEITE}/embed/termine-demo.html`)
|
||||
await karte(w(page, 0), 'Herrenschnitt').waitFor()
|
||||
assert(await page.locator('#ausweich-inline').count() === 0, 'Ausweichtext (inline) muss ersetzt sein')
|
||||
eq(await page.locator('#wkt-style').count(), 1, '<style id="wkt-style"> genau einmal')
|
||||
assert((await page.locator('#wkt-style').textContent()).includes('@layer wkt'), 'Stile stehen nicht in @layer wkt')
|
||||
const knopf = page.getByRole('button', { name: 'Termin online buchen' })
|
||||
assert(await knopf.isVisible(), 'Knopf der Demo fehlt')
|
||||
const bg = await knopf.evaluate((b) => getComputedStyle(b).backgroundColor)
|
||||
assert(bg !== 'rgba(0, 0, 0, 0)', `Knopf ohne Hintergrund (${bg})`)
|
||||
const wd = w(page, 0)
|
||||
await karte(wd, 'Kinderschnitt').click()
|
||||
await karte(wd, 'Egal').click()
|
||||
await tag(wd, '2026-10-13').click()
|
||||
await zeit(wd, '09:00').click()
|
||||
await warteTitel(wd, 'Ihre Angaben')
|
||||
// Honigtopf: unsichtbar, nicht anwaehlbar, ohne Autovervollstaendigung, vor Screenreadern versteckt
|
||||
const hp = wd.locator('input[name=kontakt_url]')
|
||||
eq(await hp.getAttribute('autocomplete'), 'off', 'autocomplete')
|
||||
eq(await hp.getAttribute('tabindex'), '-1', 'tabindex')
|
||||
eq(await hp.evaluate((i) => i.closest('[aria-hidden="true"]') !== null), true, 'aria-hidden')
|
||||
const box = await hp.evaluate((i) => { const r = i.closest('[aria-hidden]').getBoundingClientRect(); return { x: r.x + r.width, w: r.width } })
|
||||
assert(box.x <= 0 || box.w <= 2, `Honigtopf im sichtbaren Bereich: ${JSON.stringify(box)}`)
|
||||
// aria-live polite fuer Meldungen
|
||||
assert(await page.locator('[aria-live="polite"]').count() >= 1, 'aria-live="polite" fehlt')
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- 3. Knopfmodus mit <dialog>
|
||||
|
||||
await check('knopf_dialog', 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 online buchen"')))
|
||||
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')
|
||||
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')
|
||||
|
||||
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')
|
||||
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')
|
||||
// Esc schliesst, Fokus zurueck auf den Knopf, Wiederoeffnen ohne neuen Abruf der Konfiguration
|
||||
await page.keyboard.press('Escape')
|
||||
assert(!(await dlg.evaluate((d) => d.open)), 'Esc schliesst den Dialog nicht')
|
||||
assert(await knopf.evaluate((b) => document.activeElement === b), 'Fokus nicht zurueck auf dem Knopf')
|
||||
await knopf.click()
|
||||
await karte(dlg, 'Herrenschnitt').waitFor()
|
||||
eq(anfragen.filter((u) => u.includes('/config')).length, 1, 'Wiederoeffnen laedt die Konfiguration nicht noch einmal')
|
||||
// Klick auf den Hintergrund schliesst ebenfalls
|
||||
await page.mouse.click(4, 4)
|
||||
assert(!(await dlg.evaluate((d) => d.open)), 'Klick auf den Hintergrund schliesst nicht')
|
||||
await knopf.click()
|
||||
|
||||
// Komplett durchbuchen im Dialog (kostenlose Leistung: "Termin buchen", ohne Haken fuer vorzeitigen Beginn)
|
||||
await karte(dlg, 'Kennenlern-Beratung').click()
|
||||
await karte(dlg, 'Ben').click()
|
||||
await tag(dlg, '2026-10-14').click()
|
||||
await shot(page, 'knopf-dialog-termin')
|
||||
await zeit(dlg, '15:00').click()
|
||||
await dlg.locator('input[name=name]').fill('Erika Muster')
|
||||
await dlg.locator('input[name=email]').fill('erika@example.com')
|
||||
await dlg.getByRole('button', { name: 'Weiter' }).click()
|
||||
await dlg.locator('.wkt-zusammen').waitFor()
|
||||
eq(await dlg.locator('input[name=vorzeitig]').count(), 0, 'kein Haken fuer vorzeitigen Beginn ohne Preis')
|
||||
eq(await dlg.locator('.wkt-form button[type=submit]').innerText(), 'Termin buchen', 'Knopftext ohne Preis')
|
||||
await dlg.locator('input[name=storno]').check()
|
||||
await dlg.getByRole('button', { name: 'Termin buchen' }).click()
|
||||
await dlg.getByRole('heading', { name: 'Fast geschafft' }).waitFor()
|
||||
await srv.warteAufVersand()
|
||||
eq(srv.buchungen().map((b) => [b.ressourceId, b.gastName]).sort(), [['r_ben00002', 'Erika Muster']], 'Buchung mit gewaehlter Person')
|
||||
})
|
||||
|
||||
await check('knopf_mit_vorgewaehlter_leistung', async (page) => {
|
||||
await oeffne(page, seitenHtml(eingebettet(`data-modus="knopf" data-knopf="Damenschnitt buchen" data-leistung="${S_DAMEN}"`)))
|
||||
await page.getByRole('button', { name: 'Damenschnitt buchen' }).click()
|
||||
const dlg = page.locator('dialog.wkt-dialog')
|
||||
await warteTitel(dlg, 'Bei wem')
|
||||
eq(await dlg.locator('.wkt-pfad').innerText(), 'Schritt 1 von 4', 'ohne Leistungsschritt')
|
||||
eq(await karte(dlg, 'Herrenschnitt').count(), 0, 'Leistungsliste darf nicht erscheinen')
|
||||
await karte(dlg, 'Egal').click()
|
||||
assert((await dlg.locator('.wkt-hinweis').first().innerText()).startsWith('Damenschnitt'), 'vorgewaehlte Leistung steht im Termin-Schritt')
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- 4. Nur Tastatur
|
||||
|
||||
await check('nur_tastatur', async (page) => {
|
||||
await oeffne(page, seitenHtml(eingebettet('data-modus="inline"')))
|
||||
await karte(w(page), 'Herrenschnitt').waitFor()
|
||||
const aktiv = (fn, arg) => page.evaluate(fn, arg)
|
||||
const tabBis = async (bedingung, max = 25) => {
|
||||
for (let i = 0; i < max; i += 1) {
|
||||
await page.keyboard.press('Tab')
|
||||
if (await page.evaluate(bedingung)) return
|
||||
}
|
||||
throw new Error(`Fokus nicht erreicht (${bedingung})`)
|
||||
}
|
||||
await tabBis(() => document.activeElement?.matches('[data-webklar-termine] .wkt-karte'))
|
||||
eq(await aktiv(() => document.activeElement.querySelector('.wkt-karte-name').textContent), 'Herrenschnitt', 'erste Karte')
|
||||
await page.keyboard.press('Enter')
|
||||
await warteTitel(w(page), 'Bei wem')
|
||||
assert(await aktiv(() => document.activeElement?.matches('h2.wkt-titel')), 'Fokus muss auf der neuen Ueberschrift liegen')
|
||||
await page.keyboard.press('Tab')
|
||||
assert((await aktiv(() => document.activeElement.textContent)).startsWith('Egal'), 'Tab von der Ueberschrift: erste Option')
|
||||
await page.keyboard.press('Enter')
|
||||
await warteTitel(w(page), 'Wann passt es Ihnen?')
|
||||
await w(page).locator('td.wkt-tag').first().waitFor()
|
||||
await tabBis(() => document.activeElement?.matches('td.wkt-tag'))
|
||||
const start = await aktiv(() => document.activeElement.dataset.tag)
|
||||
eq(start, '2026-10-05', 'Raster startet beim ersten freien Tag')
|
||||
await page.keyboard.press('ArrowDown')
|
||||
await page.keyboard.press('ArrowDown')
|
||||
eq(await aktiv(() => document.activeElement.dataset.tag), '2026-10-19', 'zwei Wochen weiter')
|
||||
await page.keyboard.press('Enter')
|
||||
await w(page).locator('.wkt-zeit').first().waitFor()
|
||||
eq(await tag(w(page), '2026-10-19').getAttribute('aria-selected'), 'true', 'Auswahl mit Enter')
|
||||
await tabBis(() => document.activeElement?.matches('.wkt-zeit'))
|
||||
eq(await aktiv(() => document.activeElement.textContent), '09:00', 'erste Uhrzeit')
|
||||
await page.keyboard.press('Enter')
|
||||
await warteTitel(w(page), 'Ihre Angaben')
|
||||
await tabBis(() => document.activeElement?.matches('input[name=name]'))
|
||||
await page.keyboard.type('Max Mustermann')
|
||||
await page.keyboard.press('Tab')
|
||||
await page.keyboard.type('max@example.com')
|
||||
await page.keyboard.press('Enter') // Absenden des Formulars per Enter
|
||||
await warteTitel(w(page), 'Bitte prüfen Sie Ihre Buchung')
|
||||
for (let i = 0; i < 20; i += 1) {
|
||||
await page.keyboard.press('Tab')
|
||||
const a = await page.evaluate(() => ({ name: document.activeElement.name || '', submit: document.activeElement.matches('.wkt-form button[type=submit]'), text: document.activeElement.textContent }))
|
||||
if (a.name === 'storno' || a.name === 'vorzeitig') await page.keyboard.press('Space')
|
||||
if (a.submit) {
|
||||
eq(a.text, 'Zahlungspflichtig buchen', 'Knopf per Tastatur erreicht')
|
||||
await page.keyboard.press('Enter')
|
||||
break
|
||||
}
|
||||
}
|
||||
await w(page).getByRole('heading', { name: 'Fast geschafft' }).waitFor()
|
||||
assert(await aktiv(() => document.activeElement?.matches('h2.wkt-titel') && document.activeElement.textContent === 'Fast geschafft'), 'Fokus auf "Fast geschafft"')
|
||||
await srv.warteAufVersand()
|
||||
eq(srv.buchungen().map((b) => [b.leistungId, b.gastName, b.status]), [[S_HERREN, 'Max Mustermann', 'gehalten']], 'Buchung per Tastatur')
|
||||
})
|
||||
|
||||
await check('monatsraster_tasten', async (page) => {
|
||||
await oeffne(page, seitenHtml(eingebettet(`data-modus="inline" data-leistung="${S_HERREN}"`)))
|
||||
const wd = w(page)
|
||||
await karte(wd, 'Egal').click()
|
||||
await wd.locator('td.wkt-tag').first().waitFor()
|
||||
const grid = wd.locator('table[role=grid]')
|
||||
eq(await grid.getAttribute('aria-labelledby') !== null, true, 'Raster ist benannt')
|
||||
eq(await wd.locator('td[role=gridcell][tabindex="0"]').count(), 1, 'genau eine Zelle mit tabindex 0 (Roving)')
|
||||
await wd.locator('td[tabindex="0"]').focus()
|
||||
const aktTag = () => page.evaluate(() => document.activeElement.dataset.tag)
|
||||
const monat = () => wd.locator('.wkt-monat').innerText()
|
||||
eq(await aktTag(), '2026-10-05', 'Start')
|
||||
await page.keyboard.press('ArrowRight'); eq(await aktTag(), '2026-10-06', 'Pfeil rechts')
|
||||
await page.keyboard.press('ArrowDown'); eq(await aktTag(), '2026-10-13', 'Pfeil runter = +7')
|
||||
await page.keyboard.press('ArrowLeft'); eq(await aktTag(), '2026-10-12', 'Pfeil links')
|
||||
await page.keyboard.press('ArrowUp'); eq(await aktTag(), '2026-10-05', 'Pfeil hoch = -7')
|
||||
await page.keyboard.press('End'); eq(await aktTag(), '2026-10-11', 'Ende = Sonntag der Woche')
|
||||
eq(await tag(wd, '2026-10-11').getAttribute('aria-disabled'), 'true', 'Sonntag ist aria-disabled, aber anwaehlbar')
|
||||
await page.keyboard.press('Enter')
|
||||
eq(await wd.locator('.wkt-zeit').count(), 0, 'Enter auf gesperrtem Tag waehlt nichts')
|
||||
eq(await tag(wd, '2026-10-11').getAttribute('aria-selected'), 'false', 'gesperrter Tag bleibt unausgewaehlt')
|
||||
await page.keyboard.press('Home'); eq(await aktTag(), '2026-10-05', 'Pos1 = Montag der Woche')
|
||||
await page.keyboard.press('PageDown')
|
||||
await wd.locator('.wkt-monat', { hasText: 'November 2026' }).waitFor()
|
||||
eq(await aktTag(), '2026-11-05', 'Bild runter = naechster Monat, gleicher Tag')
|
||||
await page.keyboard.press('Home'); eq(await aktTag(), '2026-11-02', 'Pos1 im November')
|
||||
await page.keyboard.press('ArrowUp')
|
||||
await wd.locator('.wkt-monat', { hasText: 'Oktober 2026' }).waitFor()
|
||||
eq(await aktTag(), '2026-10-26', 'Pfeil hoch ueber die Monatsgrenze')
|
||||
await page.keyboard.press('ArrowDown'); await wd.locator('.wkt-monat', { hasText: 'November 2026' }).waitFor()
|
||||
eq(await aktTag(), '2026-11-02', 'Pfeil runter zurueck in den November')
|
||||
await page.keyboard.press('ArrowLeft'); eq(await aktTag(), '2026-11-01', 'Pfeil links')
|
||||
await page.keyboard.press('ArrowLeft'); await wd.locator('.wkt-monat', { hasText: 'Oktober 2026' }).waitFor()
|
||||
eq(await aktTag(), '2026-10-31', 'Pfeil links vom 1. in den Vormonat')
|
||||
await page.keyboard.press('PageUp')
|
||||
eq(await monat(), 'Oktober 2026', 'vor dem aktuellen Monat gibt es kein Zurueck')
|
||||
eq(await wd.getByRole('button', { name: 'Vorheriger Monat' }).isDisabled(), true, 'Pfeil zurueck im aktuellen Monat gesperrt')
|
||||
await page.keyboard.press('PageDown'); await wd.locator('.wkt-monat', { hasText: 'November 2026' }).waitFor()
|
||||
await page.keyboard.press('Enter')
|
||||
await wd.locator('.wkt-zeit').first().waitFor()
|
||||
eq(await tag(wd, '2026-11-30').count(), 1, 'Tage des Novembers stehen im Raster')
|
||||
assert(await wd.locator('td[aria-selected="true"]').count() === 1, 'genau ein Tag ausgewaehlt')
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- 5. Handy, 390 px
|
||||
|
||||
await check('handy_390', async (page) => {
|
||||
await oeffne(page, seitenHtml(eingebettet('data-modus="inline"')))
|
||||
const wd = w(page)
|
||||
await karte(wd, 'Herrenschnitt').waitFor()
|
||||
const pruefe = async (schritt) => {
|
||||
assert(await passtInBreite(page), `${schritt}: seitliches Scrollen`)
|
||||
const klein = await page.evaluate(() => [...document.querySelectorAll('[data-webklar-termine] :is(button,input:not([name=kontakt_url]),textarea,select,summary,td.wkt-tag,a)')]
|
||||
.filter((e) => e.getClientRects().length && !e.closest('[aria-hidden="true"]:not(td)') && !e.closest('.wkt-datenschutz'))
|
||||
.map((e) => { const r = e.getBoundingClientRect(); return { t: e.tagName + '.' + e.className, w: Math.round(r.width), h: Math.round(r.height) } })
|
||||
.filter((r) => r.w < 24 || r.h < 24))
|
||||
assert(klein.length === 0, `${schritt}: Zielgroesse unter 24 px: ${JSON.stringify(klein)}`)
|
||||
}
|
||||
await pruefe('Leistung')
|
||||
await shot(page, '390-1-leistung')
|
||||
await karte(wd, 'Damenschnitt').click()
|
||||
await karte(wd, 'Egal').click()
|
||||
await tag(wd, '2026-10-12').click()
|
||||
await wd.locator('.wkt-zeit').first().waitFor()
|
||||
await pruefe('Termin')
|
||||
await shot(page, '390-2-termin')
|
||||
await zeit(wd, '10:00').click()
|
||||
await warteTitel(wd, 'Ihre Angaben')
|
||||
await pruefe('Angaben')
|
||||
await shot(page, '390-3-daten')
|
||||
await wd.locator('input[name=name]').fill('Max Mustermann')
|
||||
await wd.locator('input[name=email]').fill('max@example.com')
|
||||
await wd.getByRole('button', { name: 'Weiter' }).click()
|
||||
await wd.locator('.wkt-zusammen').waitFor()
|
||||
await pruefe('Zusammenfassung')
|
||||
await shot(page, '390-4-pruefen')
|
||||
await hakenUndBuchen(wd, 'Zahlungspflichtig buchen')
|
||||
await pruefe('Ergebnis')
|
||||
await shot(page, '390-5-fast-geschafft')
|
||||
const breite = await wd.evaluate((e) => e.getBoundingClientRect().width)
|
||||
assert(breite <= 390, `Widget breiter als der Bildschirm: ${breite}`)
|
||||
}, { context: { viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true } })
|
||||
|
||||
await check('handy_390_dialog', async (page) => {
|
||||
await oeffne(page, seitenHtml(eingebettet('data-modus="knopf" data-knopf="Termin buchen"')))
|
||||
await page.getByRole('button', { name: 'Termin buchen' }).click()
|
||||
const dlg = page.locator('dialog.wkt-dialog')
|
||||
await karte(dlg, 'Herrenschnitt').waitFor()
|
||||
const maße = await dlg.evaluate((d) => { const r = d.getBoundingClientRect(); return { l: r.left, r: r.right, t: r.top, b: r.bottom, w: window.innerWidth, h: window.innerHeight, scroll: d.scrollHeight > d.clientHeight } })
|
||||
assert(maße.l >= 0 && maße.r <= maße.w + 0.5 && maße.t >= 0 && maße.b <= maße.h + 0.5, `Dialog ragt aus dem Bildschirm: ${JSON.stringify(maße)}`)
|
||||
assert(maße.scroll, 'Der lange Inhalt muss im Dialog scrollen')
|
||||
await page.screenshot({ path: `${SHOTS}/widget-390-dialog.png` })
|
||||
assert(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), 'seitliches Scrollen mit offenem Dialog')
|
||||
}, { context: { viewport: { width: 390, height: 700 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true } })
|
||||
|
||||
// ---------------------------------------------------------------- 6. Zeitzone: New York zeigt dasselbe wie Berlin
|
||||
|
||||
const erfasse = async (page) => {
|
||||
await oeffne(page, seitenHtml(eingebettet('data-modus="inline"')))
|
||||
const wd = w(page)
|
||||
await karte(wd, 'Damenschnitt').click()
|
||||
await karte(wd, 'Egal').click()
|
||||
await wd.locator('td.wkt-tag').first().waitFor()
|
||||
const monat = await wd.locator('.wkt-monat').innerText()
|
||||
const frei = await wd.locator('td.wkt-tag[aria-disabled="false"]').evaluateAll((l) => l.map((e) => e.dataset.tag))
|
||||
await tag(wd, '2026-10-12').click()
|
||||
await wd.locator('.wkt-zeit').first().waitFor()
|
||||
const lang = await wd.locator('.wkt-zeiten-titel').innerText()
|
||||
const zeiten = await wd.locator('.wkt-zeit').allInnerTexts()
|
||||
await zeit(wd, '10:00').click()
|
||||
await warteTitel(wd, 'Ihre Angaben')
|
||||
await wd.locator('input[name=name]').fill('Max Mustermann')
|
||||
await wd.locator('input[name=email]').fill('max@example.com')
|
||||
await wd.getByRole('button', { name: 'Weiter' }).click()
|
||||
await wd.locator('.wkt-zusammen').waitFor()
|
||||
const zus = (await wd.locator('.wkt-zusammen').innerText()).replace(/\s+/g, ' ')
|
||||
return { monat, frei, lang, zeiten, zus }
|
||||
}
|
||||
const datumZaehler = () => {
|
||||
// zaehlt jede Benutzung von Date und Intl im Seitenskript (das Widget darf keine brauchen)
|
||||
window.__datum = 0
|
||||
const D = Date
|
||||
window.Date = new Proxy(D, {
|
||||
construct(t, a, nt) { window.__datum += 1; return Reflect.construct(t, a, nt) },
|
||||
apply(t, th, a) { window.__datum += 1; return Reflect.apply(t, th, a) },
|
||||
get(t, p, r) { if (p === 'now') window.__datum += 1; return Reflect.get(t, p, r) },
|
||||
})
|
||||
const I = Intl.DateTimeFormat
|
||||
Intl.DateTimeFormat = new Proxy(I, { construct(t, a, nt) { window.__datum += 1; return Reflect.construct(t, a, nt) }, apply(t, th, a) { window.__datum += 1; return Reflect.apply(t, th, a) } })
|
||||
}
|
||||
let berlin = null
|
||||
await check('zeitzone_berlin_referenz', async (page) => {
|
||||
berlin = await erfasse(page)
|
||||
assert(berlin.zeiten[0] === '09:00' && berlin.zus.includes('10:00 Uhr'), `Berliner Uhrzeiten: ${berlin.zeiten[0]}`)
|
||||
eq(await page.evaluate(() => window.__datum), 0, 'Date/Intl im Berliner Browser benutzt')
|
||||
}, { context: { timezoneId: 'Europe/Berlin', locale: 'de-DE' }, initScript: datumZaehler })
|
||||
|
||||
await check('zeitzone_new_york_gleiche_zeiten', async (page) => {
|
||||
const ny = await erfasse(page)
|
||||
eq(await page.evaluate(() => window.__datum), 0, 'Date/Intl in New York benutzt')
|
||||
assert(await page.evaluate(() => Intl.DateTimeFormat().resolvedOptions().timeZone) === 'America/New_York', 'Browser steht nicht auf New York')
|
||||
eq(ny, berlin, 'Tage, Wochentag und Uhrzeiten in New York weichen von Berlin ab')
|
||||
eq(ny.zeiten.slice(0, 2), ['09:00', '09:15'], 'Uhrzeiten sind Berliner Wanduhr')
|
||||
}, { context: { timezoneId: 'America/New_York', locale: 'en-US' }, initScript: datumZaehler })
|
||||
|
||||
// ---------------------------------------------------------------- 7. Anrede Du
|
||||
|
||||
await check('anrede_du', async (page) => {
|
||||
await oeffne(page, seitenHtml(eingebettet('data-modus="inline"')))
|
||||
const wd = w(page)
|
||||
await karte(wd, 'Herrenschnitt').waitFor()
|
||||
eq(await titel(wd).innerText(), 'Welche Leistung möchtest du buchen?', 'Titel Du')
|
||||
await karte(wd, 'Herrenschnitt').click()
|
||||
eq(await titel(wd).innerText(), 'Bei wem möchtest du den Termin?', 'Titel Person Du')
|
||||
await karte(wd, 'Egal').click()
|
||||
eq(await titel(wd).innerText(), 'Wann passt es dir?', 'Titel Termin Du')
|
||||
await tag(wd, '2026-10-26').click()
|
||||
await zeit(wd, '11:00').click()
|
||||
eq(await titel(wd).innerText(), 'Deine Angaben', 'Titel Angaben Du')
|
||||
// Fehlermeldungen Du
|
||||
await wd.getByRole('button', { name: 'Weiter' }).click()
|
||||
eq(await wd.locator('#' + await wd.locator('input[name=name]').getAttribute('id') + '-e').innerText(), 'Bitte gib deinen Namen an.', 'Fehlertext Du')
|
||||
await wd.locator('input[name=name]').fill('Max Mustermann')
|
||||
await wd.locator('input[name=email]').fill('max@example.com')
|
||||
await wd.getByRole('button', { name: 'Weiter' }).click()
|
||||
await wd.locator('.wkt-zusammen').waitFor()
|
||||
eq(await titel(wd).innerText(), 'Bitte prüfe deine Buchung', 'Titel Pruefen Du')
|
||||
assert((await wd.locator('.wkt-datenschutz').innerText()).startsWith('Datenschutz. Wir verwenden deine Angaben'), 'Datenschutz-Hinweis in Du')
|
||||
// Zustimmungstexte bleiben Rechtstext in der Sie-Form (26.10. liegt nach der Widerrufsfrist: nur Storno)
|
||||
eq(await wd.locator('label[for$="h-storno"]').innerText(), 'Ich habe die Stornobedingungen gelesen.', 'Haken Storno')
|
||||
eq(await wd.locator('input[name=vorzeitig]').count(), 0, 'nach der Widerrufsfrist kein Haken fuer vorzeitigen Beginn')
|
||||
await shot(page, 'du-pruefen')
|
||||
await hakenUndBuchen(wd, 'Zahlungspflichtig buchen')
|
||||
eq(await wd.locator('.wkt-ergebnis-text').innerText(),
|
||||
'Bitte bestätige den Termin über den Link, den wir an m***@example.com geschickt haben. Der Termin ist 30 Minuten für dich reserviert.', 'Ergebnistext Du')
|
||||
}, { eingabe: salonEingabe({ anrede: 'du' }) })
|
||||
|
||||
// ---------------------------------------------------------------- 8. Zwei Widgets
|
||||
|
||||
await check('zwei_widgets_stoeren_sich_nicht', async (page) => {
|
||||
const zwei = eingebettet(`data-modus="inline" data-leistung="${S_HERREN}"`, '<p id="a1">Ausweich 1</p>') + eingebettet(`data-modus="inline" data-leistung="${S_DAMEN}"`, '<p id="a2">Ausweich 2</p>')
|
||||
await oeffne(page, seitenHtml(zwei))
|
||||
const w0 = w(page, 0)
|
||||
const w1 = w(page, 1)
|
||||
await warteTitel(w0, 'Bei wem')
|
||||
await warteTitel(w1, 'Bei wem')
|
||||
eq(await page.locator('#wkt-style').count(), 1, 'Stile genau einmal')
|
||||
const ids = await page.evaluate(() => [...document.querySelectorAll('[id]')].map((e) => e.id))
|
||||
eq(new Set(ids).size, ids.length, `doppelte IDs: ${ids.filter((x, i) => ids.indexOf(x) !== i)}`)
|
||||
|
||||
await karte(w0, 'Egal').click()
|
||||
await warteTitel(w0, 'Wann passt')
|
||||
eq(await karte(w1, 'Egal').count(), 1, 'Widget 2 steht noch bei der Personenwahl')
|
||||
await tag(w0, '2026-10-12').click()
|
||||
await w0.locator('.wkt-zeit').first().waitFor()
|
||||
await karte(w1, 'Ben').click()
|
||||
await w1.locator('td.wkt-tag').first().waitFor()
|
||||
await tag(w1, '2026-10-13').click()
|
||||
await w1.locator('.wkt-zeit').first().waitFor()
|
||||
eq(await w0.locator('td[aria-selected="true"]').getAttribute('data-tag'), '2026-10-12', 'Widget 1 behaelt seinen Tag')
|
||||
eq(await w1.locator('td[aria-selected="true"]').getAttribute('data-tag'), '2026-10-13', 'Widget 2 hat seinen Tag')
|
||||
assert((await w0.locator('.wkt-hinweis').first().innerText()).startsWith('Herrenschnitt'), 'Widget 1: Herrenschnitt')
|
||||
assert((await w1.locator('.wkt-hinweis').first().innerText()).startsWith('Damenschnitt'), 'Widget 2: Damenschnitt')
|
||||
// Beschriftungen und ARIA-Bezuege zeigen innerhalb des eigenen Widgets
|
||||
await zeit(w0, '10:00').click()
|
||||
await warteTitel(w0, 'Ihre Angaben')
|
||||
const bezuege = await page.evaluate(() => [...document.querySelectorAll('[data-webklar-termine]')].map((root) => ({
|
||||
labels: [...root.querySelectorAll('label[for]')].every((l) => root.contains(document.getElementById(l.htmlFor))),
|
||||
aria: [...root.querySelectorAll('[aria-labelledby],[aria-describedby]')].every((e) => (e.getAttribute('aria-labelledby') || e.getAttribute('aria-describedby')).split(' ').every((id) => root.contains(document.getElementById(id)))),
|
||||
})))
|
||||
eq(bezuege, [{ labels: true, aria: true }, { labels: true, aria: true }], 'Bezuege innerhalb des Widgets')
|
||||
await w0.locator('input[name=name]').fill('Max Mustermann')
|
||||
eq(await w1.locator('input[name=name]').count(), 0, 'Widget 2 hat kein Namensfeld')
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- 9. Eingaben und Fehler
|
||||
|
||||
await check('pflichtfelder_und_haken', async (page) => {
|
||||
await oeffne(page, seitenHtml(eingebettet('data-modus="inline"')))
|
||||
const wd = w(page)
|
||||
await karte(wd, 'Damenschnitt').click()
|
||||
await karte(wd, 'Egal').click()
|
||||
await tag(wd, '2026-10-12').click()
|
||||
await zeit(wd, '10:00').click()
|
||||
await warteTitel(wd, 'Ihre Angaben')
|
||||
await wd.getByRole('button', { name: 'Weiter' }).click()
|
||||
const name = wd.locator('input[name=name]')
|
||||
const mail = wd.locator('input[name=email]')
|
||||
eq(await name.getAttribute('aria-invalid'), 'true', 'Name ungueltig markiert')
|
||||
eq(await mail.getAttribute('aria-invalid'), 'true', 'E-Mail ungueltig markiert')
|
||||
assert(await name.evaluate((i) => document.activeElement === i), 'Fokus auf dem ersten Fehlerfeld')
|
||||
const fehlerId = await name.getAttribute('aria-describedby')
|
||||
eq(await wd.locator(`#${fehlerId}`).innerText(), 'Bitte geben Sie Ihren Namen an.', 'Fehlertext am Feld')
|
||||
assert((await page.locator('[aria-live="polite"]').first().textContent()).includes('Bitte prüfen Sie Ihre Eingaben'), 'Fehler werden per aria-live angesagt')
|
||||
await name.fill('Max Mustermann')
|
||||
await mail.fill('keine-adresse')
|
||||
await wd.locator('input[name=telefon]').fill('abc')
|
||||
await wd.getByRole('button', { name: 'Weiter' }).click()
|
||||
assert((await wd.locator('#' + await mail.getAttribute('aria-describedby')).innerText()).includes('gültige E-Mail-Adresse'), 'E-Mail-Fehler')
|
||||
assert((await wd.locator('#' + await wd.locator('input[name=telefon]').getAttribute('aria-describedby')).innerText()).includes('gültige Telefonnummer'), 'Telefon-Fehler')
|
||||
await mail.fill('max@example.com')
|
||||
await wd.locator('input[name=telefon]').fill('')
|
||||
await wd.getByRole('button', { name: 'Weiter' }).click()
|
||||
await wd.locator('.wkt-zusammen').waitFor()
|
||||
// ohne Haken: nichts wird gesendet, Fehler stehen an den Haken
|
||||
await wd.getByRole('button', { name: 'Zahlungspflichtig buchen' }).click()
|
||||
eq(await wd.locator('input[name=storno]').getAttribute('aria-invalid'), 'true', 'Storno-Haken markiert')
|
||||
eq(await wd.locator('input[name=vorzeitig]').getAttribute('aria-invalid'), 'true', 'Haken vorzeitiger Beginn markiert')
|
||||
eq(srv.buchungen().length, 0, 'ohne Haken wird nichts gebucht')
|
||||
// Zurueck behaelt die Eingaben
|
||||
await wd.getByRole('button', { name: 'Zurück' }).click()
|
||||
eq(await wd.locator('input[name=name]').inputValue(), 'Max Mustermann', 'Eingabe bleibt nach Zurueck')
|
||||
})
|
||||
|
||||
await check('zeit_inzwischen_vergeben', async (page) => {
|
||||
await oeffne(page, seitenHtml(eingebettet('data-modus="inline"')))
|
||||
const wd = await bisZusammenfassung(page, { leistung: 'Damenschnitt', person: 'Anna', tag: '2026-10-26', zeit: '10:00' })
|
||||
// ein anderer Gast bucht Anna zur selben Zeit
|
||||
const conf = await (await fetch(`${srv.url}/api/public/termine/v1/${KEY}/config`)).json()
|
||||
const res = await fetch(`${srv.url}/api/public/termine/v1/${KEY}/buchungen`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', origin: srv.url },
|
||||
body: JSON.stringify({ leistung: S_DAMEN, ressource: R_ANNA, tag: '2026-10-26', zeit: '10:00', name: 'Anderer Gast', email: 'anderer@example.com', telefon: '', nachricht: '', zusatz: {}, erinnerung: false, zustimmungen: { storno: true, vorzeitig: false }, kontakt_url: '', formToken: conf.formToken }),
|
||||
})
|
||||
eq(res.status, 201, 'zweiter Gast kann buchen')
|
||||
await wd.locator('input[name=storno]').check()
|
||||
await wd.getByRole('button', { name: 'Zahlungspflichtig buchen' }).click()
|
||||
await warteTitel(wd, 'Wann passt es Ihnen?')
|
||||
eq(await wd.locator('.wkt-fehlertext').first().innerText(), 'Diese Zeit ist leider nicht mehr frei. Bitte eine andere Zeit wählen.', 'Hinweis auf die vergebene Zeit')
|
||||
eq(srv.buchungen().filter((b) => b.gastEmail === 'max@example.com').length, 0, 'keine zweite Buchung')
|
||||
await shot(page, 'fehler-zeit-vergeben')
|
||||
})
|
||||
|
||||
await check('honigtopf_gefuellt_keine_buchung', async (page) => {
|
||||
await oeffne(page, seitenHtml(eingebettet('data-modus="inline"')))
|
||||
const wd = await bisZusammenfassung(page, { tag: '2026-10-26' })
|
||||
await wd.locator('input[name=kontakt_url]').evaluate((i) => { i.value = 'http://spam.example'; i.dispatchEvent(new Event('input', { bubbles: true })) })
|
||||
await hakenUndBuchen(wd, 'Zahlungspflichtig buchen') // der Server antwortet gleich (kein Hinweis fuer Automaten) ...
|
||||
await srv.warteAufVersand()
|
||||
eq(srv.buchungen().length, 0, '... speichert aber nichts')
|
||||
eq(srv.mails().length, 0, '... und schickt nichts')
|
||||
})
|
||||
|
||||
await check('formular_token_abgelaufen_neu_laden', async (page) => {
|
||||
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, 'Konfiguration einmal geladen')
|
||||
await wd.locator('input[name=storno]').check()
|
||||
srv.schiebeUhr(3 * 60 * 60 * 1000) // Formular-Token ist beim Server jetzt aelter als 2 Stunden, im Browser sind erst Sekunden vergangen
|
||||
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(konfig.length, 2, 'Konfiguration neu geladen (neues Formular-Token)')
|
||||
eq(srv.buchungen().length, 0, 'noch nichts gebucht')
|
||||
eq(await wd.locator('input[name=storno]').isChecked(), true, 'Eingaben und Haken bleiben')
|
||||
await wd.getByRole('button', { name: 'Zahlungspflichtig buchen' }).click()
|
||||
await wd.getByRole('heading', { name: 'Fast geschafft' }).waitFor()
|
||||
eq(srv.buchungen().length, 1, 'zweiter Versuch bucht')
|
||||
})
|
||||
|
||||
await check('konfiguration_nach_100_minuten_neu', async (page) => {
|
||||
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 = w(page)
|
||||
await karte(wd, 'Herrenschnitt').click()
|
||||
await karte(wd, 'Egal').click()
|
||||
await tag(wd, '2026-10-26').click()
|
||||
await wd.locator('.wkt-zeit').first().waitFor()
|
||||
eq(konfig.length, 1, 'bisher eine Konfiguration')
|
||||
await page.evaluate(() => { window.__vorsprung = 99 * 60 * 1000 })
|
||||
await zeit(wd, '10:00').click()
|
||||
await warteTitel(wd, 'Ihre Angaben')
|
||||
eq(konfig.length, 1, 'nach 99 Minuten wird noch nicht neu geladen')
|
||||
await page.evaluate(() => { window.__vorsprung = 101 * 60 * 1000 })
|
||||
await wd.locator('input[name=name]').fill('Max Mustermann')
|
||||
await wd.locator('input[name=email]').fill('max@example.com')
|
||||
await wd.getByRole('button', { name: 'Weiter' }).click()
|
||||
await wd.locator('.wkt-zusammen').waitFor()
|
||||
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 } })
|
||||
|
||||
// ---------------------------------------------------------------- 10. Widerruf ohne Link
|
||||
|
||||
await check('vertrag_widerrufen_im_fuss', async (page) => {
|
||||
await oeffne(page, seitenHtml(eingebettet('data-modus="inline"')))
|
||||
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')
|
||||
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()
|
||||
await warteTitel(wd, 'Vertrag widerrufen')
|
||||
await wd.getByRole('button', { name: 'Widerruf bestätigen' }).click() // leer: Fehler
|
||||
eq(await wd.locator('input[name=name]').getAttribute('aria-invalid'), 'true', 'Name Pflicht')
|
||||
await wd.locator('input[name=name]').fill('Gerda Gast')
|
||||
await wd.locator('input[name=email]').fill('gerda@example.com')
|
||||
await wd.locator('input[name=nummer]').fill('ABCD1234')
|
||||
await shot(page, 'widerruf')
|
||||
await wd.getByRole('button', { name: 'Widerruf bestätigen' }).click()
|
||||
await wd.locator('.wkt-ergebnis-text').waitFor()
|
||||
eq(await wd.locator('.wkt-ergebnis-text').innerText(), 'Ihre Erklärung ist eingegangen.', 'Eingangsmeldung')
|
||||
assert(/Eingang: \d{2}\.\d{2}\.\d{4}, \d{2}:\d{2} Uhr/.test(await wd.innerText()), 'Datum und Uhrzeit des Eingangs')
|
||||
await srv.warteAufVersand()
|
||||
const an = srv.mails().map((m) => m.to.map((t) => t.address).join())
|
||||
assert(an.includes('gerda@example.com'), `Eingangsbestaetigung (G08) fehlt: ${an}`)
|
||||
assert(an.includes('info@salon-mueller.example'), `Weiterleitung an den Betrieb (K21) fehlt: ${an}`)
|
||||
await wd.getByRole('button', { name: 'Zurück zur Terminbuchung' }).click()
|
||||
await karte(wd, 'Herrenschnitt').waitFor()
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- 11. Stile der Seite
|
||||
|
||||
await check('seitenstile_und_betriebsfarbe', async (page, ctx) => {
|
||||
const leer = await ctx.newPage()
|
||||
await oeffne(leer, seitenHtml(eingebettet('data-modus="inline" data-leistung="' + S_HERREN + '"')), 'standard.html')
|
||||
const wdL = w(leer)
|
||||
await karte(wdL, 'Egal').click()
|
||||
await wdL.locator('td.wkt-tag[aria-disabled="false"]').first().waitFor()
|
||||
const standard = await wdL.locator('td.wkt-tag[aria-disabled="false"]').first().evaluate((e) => getComputedStyle(e).borderTopColor)
|
||||
eq(standard, 'rgb(15, 118, 110)', 'ohne Variable gilt die Betriebsfarbe als Akzent')
|
||||
await leer.close()
|
||||
|
||||
const eigen = 'style="--wkt-akzent:rgb(10, 20, 30);--wkt-akzent-text:rgb(250, 250, 250);--wkt-radius:2px"'
|
||||
await oeffne(page, seitenHtml(eingebettet(`data-modus="inline" data-leistung="${S_HERREN}" ${eigen}`), { style: '.wkt-titel{color:rgb(1, 2, 3)}' }))
|
||||
const wd = w(page)
|
||||
await karte(wd, 'Egal').click()
|
||||
await wd.locator('td.wkt-tag[aria-disabled="false"]').first().waitFor()
|
||||
const werte = await page.evaluate(() => ({
|
||||
akzent: getComputedStyle(document.querySelector('td.wkt-tag[aria-disabled="false"]')).borderTopColor,
|
||||
radius: getComputedStyle(document.querySelector('.wkt-pfeil')).borderTopLeftRadius,
|
||||
titel: getComputedStyle(document.querySelector('.wkt-titel')).color,
|
||||
}))
|
||||
eq(werte.akzent, 'rgb(10, 20, 30)', 'CSS-Variable --wkt-akzent der Seite gewinnt')
|
||||
eq(werte.radius, '2px', 'CSS-Variable --wkt-radius der Seite gewinnt')
|
||||
eq(werte.titel, 'rgb(1, 2, 3)', 'Regel der Seite (ohne Layer) schlaegt @layer wkt')
|
||||
await tag(wd, '2026-10-12').click()
|
||||
await zeit(wd, '10:00').click()
|
||||
await wd.locator('input[name=name]').fill('Max Mustermann')
|
||||
await wd.locator('input[name=email]').fill('max@example.com')
|
||||
await wd.getByRole('button', { name: 'Weiter' }).click()
|
||||
await wd.locator('.wkt-zusammen').waitFor()
|
||||
const knopfFarbe = await wd.locator('.wkt-form button[type=submit]').evaluate((b) => [getComputedStyle(b).backgroundColor, getComputedStyle(b).color])
|
||||
eq(knopfFarbe, ['rgb(10, 20, 30)', 'rgb(250, 250, 250)'], 'Akzent und Akzent-Text am Knopf')
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- 12. Fehler und Ausweichtext
|
||||
|
||||
await check('ohne_javascript_bleibt_ausweichtext', async (page) => {
|
||||
await oeffne(page, seitenHtml(eingebettet('data-modus="inline"')))
|
||||
assert(await page.locator('#ausweich').isVisible(), 'Ausweichtext fehlt')
|
||||
}, { context: { javaScriptEnabled: false } })
|
||||
|
||||
await check('funktion_aus_oder_unbekannt_ausweichtext', async (page) => {
|
||||
await srv.setzeFunktion('disabled')
|
||||
await oeffne(page, seitenHtml(eingebettet('data-modus="inline"') + eingebettet('data-modus="knopf" data-knopf="Termin buchen"', '<p id="ausweich-knopf">Termine telefonisch unter 06301 1234.</p>') + eingebettet('data-modus="inline"', '<p id="ausweich-unbekannt">Unbekannter Schluessel</p>', 'zzzzzzzzzzzzzzzz')))
|
||||
await page.waitForLoadState('networkidle')
|
||||
await page.waitForTimeout(300)
|
||||
assert(await page.locator('#ausweich').isVisible(), 'Ausweichtext (Funktion aus) fehlt')
|
||||
assert(await page.locator('#ausweich-unbekannt').isVisible(), 'Ausweichtext (unbekannter Schluessel) fehlt')
|
||||
eq(await page.locator('[data-modus="inline"].wkt-root').count(), 0, 'kein Widget (inline) bei abgeschalteter Funktion')
|
||||
eq(await page.locator('#wkt-style').count(), 1, 'nur der Knopf bringt Stile mit (inline: keine)')
|
||||
// Knopf: erst beim Klick bemerkt das Widget, dass nichts buchbar ist; im Dialog steht der Ausweichtext
|
||||
await page.getByRole('button', { name: 'Termin buchen' }).click()
|
||||
const dlg = page.locator('dialog.wkt-dialog')
|
||||
await dlg.locator('#ausweich-knopf').waitFor()
|
||||
assert(await dlg.locator('#ausweich-knopf').isVisible(), 'Ausweichtext im Dialog')
|
||||
eq(await dlg.locator('.wkt-karte').count(), 0, 'keine Leistungen')
|
||||
await shot(page, 'fehler-funktion-aus')
|
||||
})
|
||||
|
||||
await check('netzfehler_mitten_in_der_strecke', 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()
|
||||
const meldung = wd.locator('.wkt-ausfall')
|
||||
await meldung.waitFor()
|
||||
eq(await meldung.innerText(), 'Gerade nicht erreichbar. Bitte rufen Sie an: 06301 1234', 'Ausfalltext mit Telefonnummer')
|
||||
eq(await meldung.locator('a').getAttribute('href'), 'tel:063011234', 'Telefonlink')
|
||||
assert(await wd.locator('.wkt-fuss').isVisible(), 'Fuss bleibt')
|
||||
await shot(page, 'fehler-nicht-erreichbar')
|
||||
await page.unroute(API)
|
||||
await wd.getByRole('button', { name: 'Erneut versuchen' }).click()
|
||||
await wd.locator('td.wkt-tag').first().waitFor()
|
||||
await tag(wd, '2026-10-12').click()
|
||||
await wd.locator('.wkt-zeit').first().waitFor()
|
||||
})
|
||||
|
||||
await check('keine_fremden_anfragen_keine_cookies', async (page, ctx) => {
|
||||
await oeffne(page, seitenHtml(eingebettet('data-modus="inline"')))
|
||||
await bisZusammenfassung(page, { tag: '2026-10-26' })
|
||||
eq((await ctx.cookies()).length, 0, 'Cookies')
|
||||
const erlaubt = new Set([new URL(srv.url).host, new URL(SEITE).host])
|
||||
const fremd = [...hosts].filter((h) => !erlaubt.has(h))
|
||||
eq(fremd, [], 'Anfragen an fremde Hosts')
|
||||
})
|
||||
|
||||
// Zum Schluss: Server gestoppt. Seite und Skript liefert ein eigener kleiner Server, die Schnittstelle ist weg (Verbindung abgelehnt).
|
||||
await check('server_gestoppt_ausweichtext', async (page) => {
|
||||
const skript = await readFile(WIDGET_JS, 'utf8')
|
||||
const seiten = new Map()
|
||||
const kleiner = http.createServer((req, res) => {
|
||||
const pfad = new URL(req.url, 'http://x').pathname
|
||||
if (pfad === '/embed/termine.js') return res.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8' }).end(skript)
|
||||
if (seiten.has(pfad)) return res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }).end(seiten.get(pfad))
|
||||
return res.writeHead(404).end()
|
||||
})
|
||||
await new Promise((resolve) => kleiner.listen(0, '127.0.0.1', resolve))
|
||||
const eigen = `http://localhost:${kleiner.address().port}`
|
||||
const oeffneEigen = async (html, name) => { seiten.set(`/${name}`, html); await page.goto(`${eigen}/${name}`) }
|
||||
await srv.beendeApp()
|
||||
try {
|
||||
await oeffneEigen(seitenHtml(eingebettet('data-modus="inline"')), 'gestoppt.html')
|
||||
await page.waitForLoadState('networkidle')
|
||||
await page.waitForTimeout(500)
|
||||
assert(await page.locator('#ausweich').isVisible(), 'Ausweichtext fehlt bei gestopptem Server')
|
||||
eq(await page.locator('.wkt-root').count(), 0, 'kein halbes Widget')
|
||||
eq(await page.locator('#wkt-style').count(), 0, 'keine Stile ohne Widget')
|
||||
await shot(page, 'fehler-ausweichtext')
|
||||
// Knopfmodus: der Dialog zeigt "Gerade nicht erreichbar." und den Ausweichtext
|
||||
await oeffneEigen(seitenHtml(eingebettet('data-modus="knopf" data-knopf="Termin buchen"')), 'gestoppt2.html')
|
||||
await page.getByRole('button', { name: 'Termin buchen' }).click()
|
||||
const dlg = page.locator('dialog.wkt-dialog')
|
||||
await dlg.locator('#ausweich').waitFor()
|
||||
assert((await dlg.innerText()).includes('Gerade nicht erreichbar.'), 'Meldung im Dialog')
|
||||
await shot(page, 'fehler-ausweichtext-knopf')
|
||||
} finally {
|
||||
kleiner.closeAllConnections?.()
|
||||
await new Promise((resolve) => kleiner.close(resolve))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Lauf
|
||||
|
||||
let abbruch = null
|
||||
try {
|
||||
browser = await chromium.launch()
|
||||
await pruefungen()
|
||||
} catch (e) {
|
||||
abbruch = e
|
||||
console.log(`ABBRUCH: ${e.stack || e.message}`)
|
||||
} finally {
|
||||
if (browser) await browser.close().catch(() => {})
|
||||
await srv.stop().catch((e) => console.log(`Server beenden: ${e.message}`))
|
||||
}
|
||||
|
||||
const fehlgeschlagen = results.filter(([, ok]) => !ok)
|
||||
console.log(`${results.length - fehlgeschlagen.length}/${results.length} Checks bestanden`)
|
||||
process.exit(abbruch || fehlgeschlagen.length ? 1 : 0)
|
||||
@@ -0,0 +1,269 @@
|
||||
// UI-Testserver des Terminplaners (gemeinsam fuer Playwright-Skripte der Aufgaben 19 bis 21): die echten Express-Router
|
||||
// (/api/public/termine/v1, /api/termine, /api/funktionen) und public/ als statische Dateien, dahinter der Nachbau von Appwrite
|
||||
// und Stripe aus test/support/ und eine Mail-Senke. Nichts verlaesst den Rechner (useTestEnv erlaubt nur 127.0.0.1).
|
||||
//
|
||||
// Aufruf in einem Skript (KEINE Server-Module vorher importieren, config.js liest die Umgebung beim ersten Import):
|
||||
// import { startTermineUiServer } from './termineTestServer.mjs'
|
||||
// const srv = await startTermineUiServer()
|
||||
// ... srv.url, srv.pageOrigin, srv.SITE_KEY, srv.sink.sent, srv.buchungen(), srv.speichere(...) ...
|
||||
// await srv.stop() // erst den Browser schliessen, dann den Server
|
||||
//
|
||||
// Uhr: Die Server-Uhr beginnt bei jetzt (Vorgabe Montag 2026-10-05 10:00 Berliner Zeit) und laeuft dann in Echtzeit weiter,
|
||||
// damit Tage und Uhrzeiten in Screenshots gleich bleiben und Zwischenspeicher, Haltezeiten und Formular-Token trotzdem
|
||||
// ablaufen. srv.schiebeUhr(ms) verschiebt sie (z. B. +3 h: Formular-Token abgelaufen), srv.setzeUhr(date) setzt sie neu.
|
||||
//
|
||||
// Seitenherkunft: srv.url ist http://127.0.0.1:PORT, srv.pageOrigin ist http://localhost:PORT (derselbe Server, anderer Host).
|
||||
// Mit { crossOrigin: true } gilt pageOrigin als Herkunft des Portals (config.publicBaseUrl): Seiten von pageOrigin mit
|
||||
// data-api=srv.url sind dann echte Cross-Origin-Anfragen (Preflight fuer POST), die der Server erlaubt.
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import express from 'express'
|
||||
import { startAppwriteMock } from '../support/appwriteMock.js'
|
||||
import { startFakeStripe } from '../support/fakeStripe.js'
|
||||
import { mailSink } from '../support/mailSink.js'
|
||||
import { schemaKeys } from '../support/schema.js'
|
||||
import { useTestEnv } from '../support/testEnv.js'
|
||||
|
||||
const PUBLIC_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '../../public')
|
||||
|
||||
export const SITE_KEY = 'abcdefghijklmnop'
|
||||
export const KUNDE = 'c1'
|
||||
export const PROJEKT = 'p1'
|
||||
export const FEATURE = 'f_p1_termine'
|
||||
export const JETZT = '2026-10-05T08:00:00.000Z' // Montag, 10:00 Berlin (MESZ)
|
||||
|
||||
// Feste IDs (aus den Mustern r_ + 8 und s_ + 8), damit Skripte sie in Seiten schreiben koennen (data-leistung)
|
||||
export const R_ANNA = 'r_anna0001'
|
||||
export const R_BEN = 'r_ben00002'
|
||||
export const S_HERREN = 's_herren01'
|
||||
export const S_DAMEN = 's_damen001'
|
||||
export const S_KINDER = 's_kinder01'
|
||||
export const S_FAERBEN = 's_faerben1'
|
||||
export const S_KENNEN = 's_kennen01' // ohne Preis, sofort: Knopf "Termin buchen"
|
||||
export const S_ANFRAGE = 's_anfrage1' // ohne Preis, Anfrage: Knopf "Termin anfragen"
|
||||
|
||||
/**
|
||||
* Eingabe fuer normalisiereEinstellung: Salon (Leistungen der Vorlage "salon", zwei Personen), Mo bis Fr 09:00 bis 12:30 und
|
||||
* 13:30 bis 18:00, Sa 09:00 bis 13:00, So zu; keine Feiertage, kein Vorlauf, 10 offene Termine je E-Mail erlaubt.
|
||||
*/
|
||||
export function salonEingabe(ueberschreibe = {}) {
|
||||
const woche = { 1: [['09:00', '12:30'], ['13:30', '18:00']], 6: [['09:00', '13:00']] }
|
||||
for (const tag of [2, 3, 4, 5]) woche[tag] = woche[1]
|
||||
const ressourcen = [R_ANNA, R_BEN]
|
||||
const leistung = (id, name, dauer, pufferNach, preisArt, preisCent, buchungsart, beschreibung = '') => ({
|
||||
id, name, beschreibung, dauer, pufferNach, preisArt, preisCent, buchungsart, ressourcen,
|
||||
})
|
||||
return {
|
||||
vorlage: 'salon',
|
||||
betrieb: {
|
||||
name: 'Salon Müller',
|
||||
email: 'info@salon-mueller.example',
|
||||
telefon: '06301 1234',
|
||||
adresse: 'Hauptstr. 1, 67722 Winnweiler',
|
||||
kleinunternehmer: false,
|
||||
websiteUrl: 'https://salon-mueller.example',
|
||||
impressumUrl: 'https://salon-mueller.example/impressum',
|
||||
datenschutzUrl: 'https://salon-mueller.example/datenschutz',
|
||||
},
|
||||
anrede: 'sie',
|
||||
farbe: '#0f766e',
|
||||
bundesland: 'RP',
|
||||
feiertageGeschlossen: false,
|
||||
ressourcen: [
|
||||
{ id: R_ANNA, name: 'Anna', typ: 'person', plan: woche },
|
||||
{ id: R_BEN, name: 'Ben', typ: 'person', plan: woche },
|
||||
],
|
||||
leistungen: [
|
||||
leistung(S_HERREN, 'Herrenschnitt', 30, 5, 'fest', 2800, 'sofort', 'Waschen, Schneiden und Föhnen.'),
|
||||
leistung(S_DAMEN, 'Damenschnitt', 45, 5, 'ab', 4500, 'sofort', 'Mit Beratung zur Frisur.'),
|
||||
leistung(S_KINDER, 'Kinderschnitt', 30, 5, 'fest', 1800, 'sofort'),
|
||||
leistung(S_FAERBEN, 'Färben', 120, 10, 'ab', 7500, 'sofort'),
|
||||
leistung(S_KENNEN, 'Kennenlern-Beratung', 15, 0, 'keiner', 0, 'sofort', 'Kostenlos und unverbindlich.'),
|
||||
leistung(S_ANFRAGE, 'Brautfrisur', 90, 15, 'keiner', 0, 'anfrage', 'Wir melden uns und bestätigen den Termin.'),
|
||||
],
|
||||
regeln: { raster: 15, vorlaufMin: 0, horizontTage: 60, absageBisStd: 24, maxOffeneJeEmail: 10, personWaehlbar: true },
|
||||
gastFelder: { telefon: 'optional', nachricht: 'optional', zusatz: [] },
|
||||
erinnerung: { anbieten: true, stundenVorher: 24 },
|
||||
storno: { text: 'Sie können Ihren Termin bis 24 Stunden vorher kostenlos absagen oder verschieben. Danach berechnen wir 50 % des Preises.' },
|
||||
recht: { gaesteSind: 'verbraucher', freizeit: false },
|
||||
zahlung: { modus: 'vorort', vorOrtArten: ['bar', 'ec', 'kreditkarte'] },
|
||||
...ueberschreibe,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Startet Nachbauten, Router und Seiten.
|
||||
* @param {object} [opt]
|
||||
* @param {string|Date} [opt.jetzt] Startzeit der Server-Uhr
|
||||
* @param {boolean} [opt.crossOrigin] pageOrigin (localhost) gilt als Herkunft des Portals
|
||||
* @param {(eingabe: object) => object} [opt.anpassen] aendert die Eingabe der Einstellung vor dem Speichern
|
||||
*/
|
||||
export async function startTermineUiServer({ jetzt = JETZT, crossOrigin = false, anpassen = (e) => e } = {}) {
|
||||
const { TERMINE_DATETIMES } = await import('../../server/services/termine/schema.js')
|
||||
const aw = await startAppwriteMock({
|
||||
schema: {
|
||||
portalFeatures: schemaKeys('portalFeatures'),
|
||||
termineConfig: schemaKeys('termineConfig'),
|
||||
termineBookings: schemaKeys('termineBookings'),
|
||||
termineClaims: schemaKeys('termineClaims'),
|
||||
termineOutbox: schemaKeys('termineOutbox'),
|
||||
customerViewLog: schemaKeys('customerViewLog'),
|
||||
},
|
||||
datetimes: TERMINE_DATETIMES,
|
||||
defaultLimit: 25,
|
||||
})
|
||||
const st = await startFakeStripe()
|
||||
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
|
||||
|
||||
const { config } = await import('../../server/config.js')
|
||||
const { startTestApp } = await import('../support/testApp.js')
|
||||
const { setMailTransportForTests } = await import('../../server/services/mailer.js')
|
||||
const { deleteDocument } = await import('../../server/services/appwriteAdmin.js')
|
||||
const { default: publicTermineRoutes, setzeUhrFuerTests: setzeUhrPublic, resetPublicTermineForTests } = await import('../../server/routes/publicTermine.js')
|
||||
const { default: termineRoutes, setzeUhrFuerTests: setzeUhrKunde, resetTermineRoutesForTests } = await import('../../server/routes/termine.js')
|
||||
const { default: funktionenRoutes } = await import('../../server/routes/funktionen.js')
|
||||
const { normalisiereEinstellung } = await import('../../server/services/termine/einstellungen.js')
|
||||
const { speichereEinstellung, resetKontextForTests } = await import('../../server/services/termine/kontext.js')
|
||||
const { resetOutboxForTests, warteAufVersand } = await import('../../server/services/termine/outbox.js')
|
||||
const { resetTermineLimitsForTests } = await import('../../server/services/termine/limits.js')
|
||||
|
||||
// Testseiten (HTML aus dem Skript) kommen vom Server selbst: Chromium lehnt Anfragen von Seiten, die Playwright per
|
||||
// route.fulfill liefert, an 127.0.0.1 ab (Local Network Access); Seiten desselben Servers duerfen.
|
||||
const seiten = new Map()
|
||||
const seitenRouter = express.Router()
|
||||
seitenRouter.get('/:name', (req, res) => {
|
||||
const html = seiten.get(req.params.name)
|
||||
if (html === undefined) return res.status(404).type('text').send('nicht gefunden')
|
||||
return res.set('Cache-Control', 'no-store').type('text/html; charset=utf-8').send(html)
|
||||
})
|
||||
|
||||
const app = await startTestApp({
|
||||
'/api/public/termine/v1': publicTermineRoutes,
|
||||
'/api/termine': termineRoutes,
|
||||
'/api/funktionen': funktionenRoutes,
|
||||
'/ui-seiten': seitenRouter,
|
||||
'/': express.static(PUBLIC_DIR),
|
||||
})
|
||||
const pageOrigin = app.url.replace('127.0.0.1', 'localhost')
|
||||
|
||||
const startMs = new Date(jetzt).getTime()
|
||||
let versatz = startMs - Date.now()
|
||||
const uhr = () => new Date(Date.now() + versatz)
|
||||
|
||||
const vorher = {
|
||||
mode: config.termine.mode,
|
||||
pilotEmails: config.termine.pilotEmails,
|
||||
formMinSekunden: config.termine.formMinSekunden,
|
||||
minAntwortMs: config.termine.minAntwortMs,
|
||||
publicBaseUrl: config.publicBaseUrl,
|
||||
smtpFrom: config.smtp.from,
|
||||
}
|
||||
|
||||
const srv = {
|
||||
url: app.url,
|
||||
pageOrigin,
|
||||
SITE_KEY, KUNDE, PROJEKT, FEATURE,
|
||||
aw, st, config,
|
||||
sink: null,
|
||||
uhr,
|
||||
|
||||
/** Testseite unter pageOrigin/ui-seiten/<name> bereitstellen; liefert die Adresse. */
|
||||
seite(name, html) {
|
||||
seiten.set(name, html)
|
||||
return `${pageOrigin}/ui-seiten/${name}`
|
||||
},
|
||||
|
||||
/** Zeitpunkt der Server-Uhr verschieben (ms) oder neu setzen. */
|
||||
schiebeUhr(ms) { versatz += ms },
|
||||
setzeUhr(datum) { versatz = new Date(datum).getTime() - Date.now() },
|
||||
|
||||
/** Portal-Funktion des Termin-Features (portalFeatures-Dokument). */
|
||||
featureDoc(state = 'active') {
|
||||
return { $id: FEATURE, customerId: KUNDE, projectId: PROJEKT, featureKey: 'termine', state, enabled: state === 'active', siteKey: SITE_KEY, activatedAt: '2026-10-01T09:00:00.000Z' }
|
||||
},
|
||||
|
||||
/** Zustand der Funktion aendern ('active' | 'disabled' | 'off'); der Kontext-Speicher des Servers wird geleert. */
|
||||
async setzeFunktion(state) {
|
||||
aw.seed('portalFeatures', srv.featureDoc(state))
|
||||
resetKontextForTests()
|
||||
},
|
||||
|
||||
/** Einstellung speichern: Eingabe fuer normalisiereEinstellung (z. B. salonEingabe({ anrede: 'du' })). Liefert die normalisierte Einstellung. */
|
||||
async speichere(eingabe = salonEingabe()) {
|
||||
const erg = normalisiereEinstellung(eingabe, { now: uhr() })
|
||||
if (erg.fehler) throw new Error(`Einstellung ungueltig: ${JSON.stringify(erg.fehler)}`)
|
||||
resetKontextForTests()
|
||||
const doc = aw.docs('termineConfig').get(FEATURE)
|
||||
await speichereEinstellung(srv.featureDoc(), erg.einstellung, { version: doc ? doc.version : 0, now: uhr() })
|
||||
return erg.einstellung
|
||||
},
|
||||
|
||||
/** Gespeicherte Einstellung (Objekt) oder null. */
|
||||
einstellung() {
|
||||
const doc = aw.docs('termineConfig').get(FEATURE)
|
||||
return doc ? JSON.parse(doc.einstellung) : null
|
||||
},
|
||||
|
||||
/** Alle Buchungen (Appwrite-Dokumente), alle Mails der Senke (nach warteAufVersand(): die Warteschlange sendet im Hintergrund). */
|
||||
buchungen() { return [...aw.docs('termineBookings').values()] },
|
||||
mails() { return srv.sink.sent },
|
||||
warteAufVersand,
|
||||
|
||||
/** Nur Grenzen und Zwischenspeicher leeren (zwischen Pruefungen, damit die Grenzen je IP nicht greifen). */
|
||||
limitsZuruecksetzen() {
|
||||
resetTermineLimitsForTests()
|
||||
resetKontextForTests()
|
||||
},
|
||||
|
||||
/** Alles auf Anfang: Dokumente loeschen, Zaehler und Zwischenspeicher leeren, neue Senke, Einstellung und Funktion neu anlegen. */
|
||||
async zuruecksetzen({ eingabe, uhrNeu = true } = {}) {
|
||||
await warteAufVersand()
|
||||
for (const collection of ['portalFeatures', 'termineConfig', 'termineBookings', 'termineClaims', 'termineOutbox', 'customers', 'websiteProjects']) {
|
||||
for (const id of [...aw.docs(collection).keys()]) await deleteDocument(collection, id)
|
||||
}
|
||||
resetKontextForTests()
|
||||
resetOutboxForTests()
|
||||
resetTermineLimitsForTests()
|
||||
resetPublicTermineForTests()
|
||||
resetTermineRoutesForTests()
|
||||
if (uhrNeu) versatz = startMs - Date.now()
|
||||
setzeUhrPublic(uhr)
|
||||
setzeUhrKunde(uhr)
|
||||
config.termine.mode = 'pilot'
|
||||
config.termine.pilotEmails = ['*']
|
||||
config.termine.formMinSekunden = 0
|
||||
config.termine.minAntwortMs = 0
|
||||
config.smtp.from = 'noreply@webklar.com'
|
||||
config.publicBaseUrl = crossOrigin ? pageOrigin : app.url
|
||||
srv.sink = mailSink()
|
||||
setMailTransportForTests(srv.sink)
|
||||
aw.seed('websiteProjects', { $id: PROJEKT, customerId: KUNDE, projectName: 'Salon Müller', subdomain: 'salon', liveDomain: 'salon-mueller.example' })
|
||||
aw.seed('customers', { $id: KUNDE, name: 'Silke Müller', email: 'silke@salon-mueller.example' })
|
||||
aw.seed('portalFeatures', srv.featureDoc())
|
||||
await srv.speichere(anpassen(eingabe ?? salonEingabe()))
|
||||
},
|
||||
|
||||
/** Beendet Server und Nachbauten (zuerst den Browser schliessen) und stellt config wieder her. */
|
||||
async stop() {
|
||||
await warteAufVersand().catch(() => {})
|
||||
setMailTransportForTests(null)
|
||||
resetPublicTermineForTests()
|
||||
resetTermineRoutesForTests()
|
||||
Object.assign(config.termine, { mode: vorher.mode, pilotEmails: vorher.pilotEmails, formMinSekunden: vorher.formMinSekunden, minAntwortMs: vorher.minAntwortMs })
|
||||
config.publicBaseUrl = vorher.publicBaseUrl
|
||||
config.smtp.from = vorher.smtpFrom
|
||||
await Promise.all([srv.beendeApp(), aw.close(), st.close()])
|
||||
},
|
||||
|
||||
/** Nur die App (die Seiten und Schnittstellen) beenden; Appwrite-Nachbau bleibt (Test "Server gestoppt"). */
|
||||
async beendeApp() {
|
||||
if (srv.appBeendet) return
|
||||
srv.appBeendet = true
|
||||
await Promise.race([app.close(), new Promise((resolve) => setTimeout(resolve, 3000))])
|
||||
},
|
||||
appBeendet: false,
|
||||
}
|
||||
|
||||
await srv.zuruecksetzen()
|
||||
return srv
|
||||
}
|
||||
Reference in new issue
Block a user