Terminplaner TP2: Ansicht nach Zustand und Rueckkehr von Stripe

Die Ansicht #termine folgt dem Zustand aus GET /api/termine/einstellungen:
off mit laufendem Abo zeigt den Fragebogen samt Bestellseite, ohne Abo den
Hinweis des Servers (termine.hinweis, R111) und bei kein_abo den Link zu
Vertraege aus einer gemeinsamen Quelle (vertraege-link.js); ordered und setup
zeigen die Statuskarte und die Reiter Leistungen, Team & Plaetze und
Einstellungen nur zum Lesen (ohne Reiter Termine); active und disabled
bleiben wie bisher. Rueckkehr von Stripe: #termine?bestellt=1&projekt=<id>
und #termine?projekt=<id> waehlen das Projekt, "Danke!" nur mit bestellt=1,
die Adresse wird einmal gelesen und bereinigt.

R110: GET /einstellungen liefert bestellung und offen auch in ordered (ohne
Abo-Punkt, abo bleibt null); scheitert "Zahlung fortsetzen", zeichnet die
Huelle Liste und Daten neu (bei 409 zustimmung_fehlt mit dem
Bestellformular, sonst nach dem neuen Zustand, die Meldung bleibt am Knopf,
solange die Bestellung offen ist).

Dazu: "null" als Text im lesbaren Reiter Leistungen entfernt, Export und
Status der Einstellungen erst ab der Freischaltung.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-04 23:10:54 +00:00
1 parent 52456dac76
commit 87d267e51a
13 files changed
+1077 -71

No files matched your search

+7 -3
View File
@@ -5,12 +5,13 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
// public/funktionen.js wirklich laden (die absoluten Importe /analyse-view.js und /geraete.js werden auf die Dateien umgeleitet)
// public/funktionen.js wirklich laden (die absoluten Importe /analyse-view.js und /geraete.js und der relative ./vertraege-link.js werden auf die Dateien umgeleitet)
const dir = mkdtempSync(join(tmpdir(), 'fn-tiles-'))
const fileUrl = (name) => pathToFileURL(new URL(`../public/${name}`, import.meta.url).pathname).href
const src = readFileSync(new URL('../public/funktionen.js', import.meta.url), 'utf8')
.replace("'/analyse-view.js'", `'${fileUrl('analyse-view.js')}'`)
.replace("'/geraete.js'", `'${fileUrl('geraete.js')}'`)
.replace("'./vertraege-link.js'", `'${fileUrl('vertraege-link.js')}'`)
const file = join(dir, 'funktionen.mjs')
writeFileSync(file, src)
const { initFunktionen, initNews, initAnalyse, renderAnalyseOff } = await import(pathToFileURL(file).href)
@@ -265,9 +266,12 @@ test('Terminplaner-Kachel: mit Abo (bestellbar true) steht der Knopf da und ein
assert.doesNotMatch(kachel, /Serverhinweis|fn-count/)
})
test('Der Browser hat keine Kopie der Abo-Hinweise; der Link zu Verträge steht einmal im Quelltext (Analyse und Terminplaner teilen ihn)', async () => {
test('Der Browser hat keine Kopie der Abo-Hinweise; der Link zu Verträge hat eine einzige Quelle (vertraege-link.js: Analyse, Terminplaner-Kachel und Ansicht #termine teilen sie)', async () => {
assert.doesNotMatch(src, /TERMINE_ABO_TEXTE|termineAboHinweis|KEIN_ABO_HINWEIS|ist gekündigt\. Für|gerade nicht aktiv|nicht geprüft werden/)
assert.equal(src.split('data-view="vertraege"').length - 1, 1)
const quelle = readFileSync(new URL('../public/vertraege-link.js', import.meta.url), 'utf8')
assert.equal(quelle.split("view: 'vertraege'").length - 1, 1, 'Ziel, Text und Klasse stehen genau einmal in vertraege-link.js')
assert.equal(src.split('data-view="vertraege"').length - 1, 0, 'funktionen.js baut den Link aus ZU_VERTRAEGE, ohne eigenes Ziel')
assert.equal(src.split('ZU_VERTRAEGE_LINK =').length - 1, 1, 'ein Baustein für alle Stellen')
// Analyse-Kachel und -Seite (unveraendert) und Terminplaner-Kachel zeigen genau diesen Link
const analyse = tile(await render([project('a', { eligible: false, reason: 'kein_abo', reasonText: 'Kein Abo' })]), 'analyse')
assert.ok(analyse.includes(`<p class="fn-count">Im Wartungs-Abo enthalten. ${ZU_VERTRAEGE}</p>`), analyse)
+2 -2
View File
@@ -3,7 +3,7 @@
// const fx = installFakeDom({ antworten: [{ status: 400, body: { fehler: {...} } }] })
// try { ...Client-Code bauen, fx.klick(knopf), fx.fetchLog pruefen... } finally { fx.restore() }
//
// installFakeDom stellt `document`, `window` (nur location und history.replaceState), `CSS.escape` und `fetch` auf globalThis, restore() stellt den
// installFakeDom stellt `document`, `window` (nur location mit href, pathname, search, hash und history.replaceState), `CSS.escape` und `fetch` auf globalThis, restore() stellt den
// alten Zustand wieder her. `h` aus public/termine-dom.js liest `document` bei jedem Aufruf: der Client braucht keine Einhaengepunkte.
// Wurzeln, die `isConnected` brauchen (neuerSpeicher.nochHier), haengt der Test selbst an `document.body`.
//
@@ -479,7 +479,7 @@ const GLOBALE = ['document', 'window', 'CSS', 'fetch']
export function installFakeDom({ antworten = [] } = {}) {
const vorher = Object.fromEntries(GLOBALE.map((name) => [name, Object.getOwnPropertyDescriptor(globalThis, name)]))
const document = neuesDokument()
const location = { href: 'http://localhost/', hash: '' }
const location = { href: 'http://localhost/', pathname: '/dashboard.html', search: '', hash: '' }
const window = {
document,
location,
+51 -5
View File
@@ -322,8 +322,8 @@ test('einbau.snippet und feeds nur in active (feeds) und active/disabled (snippe
}
})
test('abo, offen und bestellung gibt es nur in off; sonst null', async () => {
for (const zustand of ['ordered', 'setup', 'active', 'disabled']) {
test('abo gibt es nur in off; offen und bestellung nur in off und ordered (R110); in setup, active und disabled null', async () => {
for (const zustand of ['setup', 'active', 'disabled']) {
await deleteDocument('portalFeatures', FEATURE).catch(() => {})
await deleteDocument('termineConfig', FEATURE).catch(() => {})
setzeZustand(zustand)
@@ -332,11 +332,57 @@ test('abo, offen und bestellung gibt es nur in off; sonst null', async () => {
assert.equal(body.offen, null, zustand)
assert.equal(body.bestellung, null, zustand)
}
setzeZustand('off')
for (const zustand of ['off', 'ordered']) {
await deleteDocument('portalFeatures', FEATURE).catch(() => {})
await deleteDocument('termineConfig', FEATURE).catch(() => {})
setzeZustand(zustand)
const { body } = await send('GET', einstellungenUrl(), { cookie })
assert.notEqual(body.offen, null, zustand)
assert.notEqual(body.bestellung, null, zustand)
assert.equal(body.abo === null, zustand === 'ordered', `${zustand}: das Abo liest die Route nur in off`)
}
})
// ---------------------------------------------------------------- ordered: bestellung und offen nur lesend (R110)
// "Zahlung fortsetzen" ohne Nachweis (409 zustimmung_fehlt) zeigt im Zustand ordered wieder das Bestellformular; dafuer braucht der
// Client bestellung und offen auch dort. Das Abo gehoert nicht dazu: es wird in ordered nicht gelesen, offen hat keinen Abo-Punkt (h).
test('ordered: bestellung ist die Sicht des Registers wie in off, offen die Bestellprüfung ohne Abo-Punkt, abo null, nichts wird geschrieben', async () => {
const stand = setzeZustand('ordered')
aw.resetListCalls()
const vorher = groessen()
const { status, body } = await send('GET', einstellungenUrl(), { cookie })
assert.equal(status, 200)
assert.equal(body.state, 'ordered')
assert.deepEqual(body.bestellung, JSON.parse(JSON.stringify(await bestellungAnsicht(T))))
assert.equal(body.bestellung.consent.knopf, T.consent.knopf)
assert.deepEqual(body.offen, [], 'bestellbarer Entwurf: keine offenen Punkte, auch ohne Abo-Punkt')
assert.deepEqual(body.offen, bestellpruefung({ einstellung: stand.einstellung, fragebogen: stand.fragebogen, feature: T, mitAbo: false }))
assert.equal(body.abo, null)
assert.deepEqual(aw.listCalls('subscriptions'), [], 'in ordered wird das Abo nicht gelesen')
assert.deepEqual(groessen(), vorher, 'read-only: nichts angelegt, nichts protokolliert')
})
test('ordered: ein unvollständiger Entwurf zeigt seine offenen Punkte (nie den Abo-Punkt), ohne Einstellung den Punkt „Branche“', async () => {
setzeZustand('ordered', { stand: { ...bestellbarerStand({ now: JETZT }), fragebogen: leerFragebogen() } })
const { body } = await send('GET', einstellungenUrl(), { cookie })
assert.notEqual(body.abo, null)
assert.notEqual(body.offen, null)
assert.ok(body.offen.length > 0)
assert.ok(body.offen.some((p) => p.schritt === 'leistungen'))
assert.ok(body.offen.every((p) => p.schritt !== 'bestellen'), 'kein Abo-Punkt (h)')
await deleteDocument('termineConfig', FEATURE)
const ohne = await send('GET', einstellungenUrl(), { cookie })
assert.equal(ohne.status, 200)
assert.deepEqual(ohne.body.offen, [{ schritt: 'branche', text: 'Bitte starte den Fragebogen mit der Wahl deiner Branche.' }])
assert.notEqual(ohne.body.bestellung, null)
})
test('ordered in der Kundenansicht: bestellung und offen ebenfalls da, kundenansicht true', async () => {
setzeZustand('ordered')
const { status, body } = await send('GET', einstellungenUrl(), kv())
assert.equal(status, 200)
assert.equal(body.kundenansicht, true)
assert.notEqual(body.bestellung, null)
assert.deepEqual(body.offen, [])
})
test('E11 bleibt: disabled liefert das Feld abgeschaltet mit Grund, Zeitpunkt und Löschtag; Schreiben ist weiter 409 "abgeschaltet"', async () => {
+564 -11
View File
@@ -1443,35 +1443,72 @@ test('statusKarte ordered: „Zahlung fortsetzen“ sendet fortsetzenKoerper und
assert.equal(fx.window.location.href, KNOPF_URL)
}))
test('statusKarte ordered: 409 zustimmung_fehlt ⇒ weiter() lädt neu und zeigt das Bestellformular, keine Navigation', mitDom({
test('statusKarte ordered: 409 zustimmung_fehlt ⇒ weiter({ bestellen: true, meldung }) lädt neu und zeigt das Bestellformular, keine Navigation', mitDom({
antworten: [{ status: 409, body: { error: 'Bitte stimme der Erklärung zu, um zu bestellen.', code: 'zustimmung_fehlt' } }],
}, async (fx) => {
let weiter = 0
const wurzel = karte({}, {}, { weiter: async () => { weiter += 1 } })
const aufrufe = []
const wurzel = karte({}, {}, { weiter: async (fall) => { aufrufe.push(fall) } })
await fx.klick(fx.finde(wurzel, '[data-aktion="fortsetzen"]'))
assert.equal(weiter, 1)
assert.deepEqual(aufrufe, [{ bestellen: true, meldung: 'Bitte stimme der Erklärung zu, um zu bestellen.' }])
assert.equal(fx.window.location.href, 'http://localhost/')
}))
test('statusKarte ordered: ohne weiter() oder bei anderem Fehler steht die Meldung am Knopf, der Knopf ist wieder frei', mitDom({
antworten: [{ status: 409, body: { error: 'Bitte stimme der Erklärung zu, um zu bestellen.', code: 'zustimmung_fehlt' } }, { status: 409, body: { error: 'Der Terminplaner ist für dieses Projekt schon bestellt.' } }, { body: { url: 'http://unsicher.example/x' } }],
test('statusKarte ordered: jeder andere Fehler ruft weiter({ bestellen: false, meldung }) (R110), die Karte bleibt nie veraltet stehen', mitDom({
antworten: [
{ status: 400, body: { error: 'Bitte stimme der Erklärung zu, um zu bestellen.', code: 'zustimmung_fehlt' } },
{ status: 409, body: { error: 'Der Terminplaner ist für dieses Projekt schon bestellt.' } },
{ status: 500, body: {} },
],
}, async (fx) => {
let weiter = 0
const aufrufe = []
// wie im Browser zeichnet sich die Ansicht nach weiter() neu: der Knopf der alten Karte bleibt gesperrt, jeder Fall hat seine Karte
for (let i = 0; i < 3; i += 1) await fx.klick(fx.finde(karte({}, {}, { weiter: (fall) => { aufrufe.push(fall) } }), '[data-aktion="fortsetzen"]'))
assert.deepEqual(aufrufe, [
{ bestellen: false, meldung: 'Bitte stimme der Erklärung zu, um zu bestellen.' },
{ bestellen: false, meldung: 'Der Terminplaner ist für dieses Projekt schon bestellt.' },
{ bestellen: false, meldung: 'Fehler 500' },
], 'auch bei 400 (Zustand inzwischen setup oder active) und 409 ohne zustimmung_fehlt')
}))
test('statusKarte ordered: scheitert weiter() (Neuladen nicht möglich), bleibt die Karte mit dem Fehler am Knopf; ohne weiter() steht die Meldung des Servers dort', mitDom({
antworten: [
{ status: 409, body: { error: 'Bitte stimme der Erklärung zu, um zu bestellen.', code: 'zustimmung_fehlt' } },
{ status: 409, body: { error: 'Der Terminplaner ist für dieses Projekt schon bestellt.' } },
{ status: 409, body: { error: 'Bitte stimme der Erklärung zu, um zu bestellen.', code: 'zustimmung_fehlt' } },
{ status: 409, body: { error: 'Der Terminplaner ist für dieses Projekt schon bestellt.' } },
{ body: { url: 'http://unsicher.example/x' } },
],
}, async (fx) => {
const kaputt = () => { throw new Error('Keine Verbindung. Bitte versuch es gleich noch einmal.') }
const mit = karte({}, {}, { weiter: kaputt })
const meldungMit = fx.finde(mit, '[data-meldung="fortsetzen"]')
const knopfMit = fx.finde(mit, '[data-aktion="fortsetzen"]')
await fx.klick(knopfMit)
assert.equal(fx.text(meldungMit), 'Keine Verbindung. Bitte versuch es gleich noch einmal.', 'bei fehlender Zustimmung der Grund des Neuladens')
assert.equal(knopfMit.disabled, false)
await fx.klick(knopfMit)
assert.equal(fx.text(meldungMit), 'Der Terminplaner ist für dieses Projekt schon bestellt.', 'sonst der Fehler des Versuchs')
const ohne = karte()
const knopf = fx.finde(ohne, '[data-aktion="fortsetzen"]')
await fx.klick(knopf)
assert.equal(fx.text(fx.finde(ohne, '[data-meldung="fortsetzen"]')), 'Bitte stimme der Erklärung zu, um zu bestellen.')
assert.equal(knopf.disabled, false)
const mit = karte({}, {}, { weiter: () => { weiter += 1 } })
await fx.klick(fx.finde(mit, '[data-aktion="fortsetzen"]'))
assert.equal(weiter, 0, 'weiter() nur bei zustimmung_fehlt')
assert.equal(fx.text(fx.finde(mit, '[data-meldung="fortsetzen"]')), 'Der Terminplaner ist für dieses Projekt schon bestellt.')
const ohneZwei = karte()
await fx.klick(fx.finde(ohneZwei, '[data-aktion="fortsetzen"]'))
assert.equal(fx.text(fx.finde(ohneZwei, '[data-meldung="fortsetzen"]')), 'Der Terminplaner ist für dieses Projekt schon bestellt.')
const unsicher = karte()
await fx.klick(fx.finde(unsicher, '[data-aktion="fortsetzen"]'))
assert.equal(fx.window.location.href, 'http://localhost/', 'keine http-Navigation')
assert.match(fx.text(fx.finde(unsicher, '[data-meldung="fortsetzen"]')), /Zahlungsseite/)
}))
test('statusKarte: meldung steht beim Zeichnen am Knopf „Zahlung fortsetzen“ (als Text)', mitDom(undefined, (fx) => {
const wurzel = karte({}, { meldung: 'Deine Zahlung läuft schon. <b>x</b>' })
assert.equal(fx.text(fx.finde(wurzel, '[data-meldung="fortsetzen"]')), 'Deine Zahlung läuft schon. <b>x</b>')
assert.equal(fx.alle(wurzel, 'b').length, 0)
assert.equal(fx.text(fx.finde(karte(), '[data-meldung="fortsetzen"]')), '')
}))
test('statusKarte ordered: in der Kundenansicht ist „Zahlung fortsetzen“ gesperrt, mit Hinweis und ohne Anfrage', mitDom(undefined, async (fx) => {
const wurzel = karte({}, { kundenansicht: true })
const knopf = fx.finde(wurzel, '[data-aktion="fortsetzen"]')
@@ -2965,3 +3002,519 @@ test('termine.css: Klassen der Schritte 4 bis 8 (Person, Ausnahmen, Sammelknopf,
for (const klasse of ['tm-fb-person', 'tm-fb-ausnahmen', 'tm-fb-sammel', 'tm-fb-einbau']) assert.match(css, new RegExp(`\\.${klasse}[\\s{,.:\\[]`), `.${klasse} ist gestaltet`)
assert.match(css.match(/\.tm-fb-sammel\s*\{[^}]*\}/)?.[0] ?? '', /flex-wrap:\s*wrap/, 'die Knöpfe brechen am Handy um')
})
// ------------------------------------------------------- Ansicht #termine nach Zustand und Rückkehr von Stripe (TP2, Task C10b)
// initTermine in public/termine.js. Der Fake-fetch antwortet hier nach Adresse statt der Reihe nach: Antworten von GET /api/funktionen (dort
// steht der Abo-Hinweis, R111) und GET /api/termine/einstellungen liefert der Test. Ein Array in der Tabelle sind aufeinanderfolgende Antworten
// (die letzte gilt weiter). Der Zustand der Huelle (Auswahl des Projekts und des Reiters) bleibt zwischen den Tests im Modul: jeder Test nennt
// sein Projekt (projectId), und kein Test verlangt einen bestimmten Reiter, ohne ihn zu waehlen.
const AN_FUNKTIONEN = 'GET /api/funktionen'
const AN_CHECKOUT = 'POST /api/funktionen/termine/checkout'
const an = (projectId) => `GET /api/termine/einstellungen?projectId=${projectId}`
const SERVER_HINWEIS = 'Dieser Satz steht nur beim Server, so wie der Server ihn schreibt.'
const AUFRUFE = (fx) => fx.fetchLog.map((a) => `${a.method} ${a.url}`)
function routen(fx, tabelle) {
globalThis.fetch = async (url, optionen = {}) => {
const aufruf = { method: optionen.method || 'GET', url: String(url), body: optionen.body ? JSON.parse(optionen.body) : undefined }
fx.fetchLog.push(aufruf)
let eintrag = tabelle[`${aufruf.method} ${aufruf.url}`]
if (eintrag === undefined && aufruf.method === 'GET' && aufruf.url.startsWith('/api/termine/buchungen')) eintrag = { body: { buchungen: [] } }
if (Array.isArray(eintrag)) eintrag = eintrag.length > 1 ? eintrag.shift() : eintrag[0]
if (typeof eintrag === 'function') eintrag = eintrag(aufruf)
const { status = 200, body = {} } = eintrag ?? { status: 404, body: { error: `nicht vorgesehen: ${aufruf.method} ${aufruf.url}` } }
return { ok: status >= 200 && status < 300, status, json: async () => structuredClone(body) }
}
}
/** Ansicht öffnen (Adresse mit hash, Tabelle der Antworten) und warten, bis auch die Reiter gezeichnet sind. => Wurzel der Ansicht. */
async function oeffneAnsicht(fx, { projectId = 'p1', hash = '', tabelle }) {
routen(fx, tabelle)
fx.window.location.hash = hash
const wurzel = h('div')
document.body.append(wurzel)
await huelle.initTermine(wurzel, { projectId })
await fx.ruhe()
return wurzel
}
const projektEintrag = (id, state, extra = {}) => ({ projectId: id, projectName: `Website ${id}`, liveUrl: '', features: { news: { state: 'off' }, termine: { state, ...extra } } })
const GEORDERT = { orderedAt: '2026-10-05T10:00:00.000Z', ticketWoid: null, invoiceUrl: null }
const funktionenAntwort = (projekte, termine = {}) => ({
body: { projects: projekte, termine: { verfuegbar: true, bestellbar: true, grund: null, preis: '149 €', einrichtung: EINRICHTUNG, hinweis: null, ...termine } },
})
const ohneAbo = (grund, hinweis = SERVER_HINWEIS) => ({ bestellbar: false, grund, hinweis })
const ABGESCHALTET = { grund: 'abo_ende', seit: '2026-10-04T10:00:00.000Z', loeschungAm: '2026-11-03' }
/** Antwort von GET /api/termine/einstellungen im jeweiligen Zustand (off: Entwurf mit Abo, ordered: dazu die Sicht der Bestellung ohne Abo). */
async function ansichtDaten(state, extra = {}) {
const basis = { ...fbDaten({ state }), abo: null, offen: null, bestellung: null, feeds: {}, anfragenOffen: 0, aktivSeit: null, abgeschaltet: null }
if (state === 'off') return { ...basis, ...fbDaten(), ...extra }
if (state === 'ordered') return { ...basis, offen: [], bestellung: await bestellungTest(), ...extra }
if (state === 'active') return { ...basis, aktivSeit: '2026-10-01T09:00:00.000Z', einbau: { snippet: '<script></script>' }, ...extra }
if (state === 'disabled') return { ...basis, aktivSeit: '2026-10-01T09:00:00.000Z', abgeschaltet: ABGESCHALTET, ...extra }
return { ...basis, ...extra }
}
const reiterTexte = (fx, wurzel) => fx.alle(wurzel, '[role="tab"]').map((tab) => fx.text(tab))
const meldungDerHuelle = (fx, wurzel) => fx.text(fx.finde(wurzel, '.tm-meldung'))
/** Antwort-Tabelle für ein Projekt p1 im Zustand state mit den Daten daten. */
async function einProjekt(state, { eintrag = {}, daten = {}, termine = {}, tabelle = {} } = {}) {
return {
[AN_FUNKTIONEN]: funktionenAntwort([projektEintrag('p1', state, eintrag)], termine),
[an('p1')]: { body: await ansichtDaten(state, daten) },
...tabelle,
}
}
test('Ansicht off mit laufendem Abo: der Fragebogen (Fortschrittsleiste im Baum), weder Hinweis noch Reiter', mitDom(undefined, async (fx) => {
const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('off', { daten: { fragebogen: fbBis(1) } }) })
assert.ok(fx.finde(wurzel, 'nav.tm-fortschritt ol.tm-schritte'), 'renderFragebogen hat gezeichnet')
assert.equal(fbSchritt(fx, wurzel), 'wer', 'Start beim ersten offenen Schritt des Entwurfs')
assert.ok(fx.finde(wurzel, '[data-hinweis="abo"]') === null)
assert.ok(fx.finde(wurzel, '.tm-reiterleiste') === null)
assert.ok(fx.finde(wurzel, '.tm-statuskarte') === null)
assert.deepEqual(AUFRUFE(fx), [AN_FUNKTIONEN, an('p1')])
}))
test('Ansicht off: ohne Entwurf die Branchenwahl; die Meldung der Hülle (ctx.meldung) steht über dem Fragebogen', mitDom(undefined, async (fx) => {
const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('off', {
daten: { einstellung: null, version: 0, fragebogen: leerFragebogen(), betriebVorschlag: BETRIEB_KONTO },
tabelle: {
'POST /api/termine/einstellungen/vorlage': { status: 201, body: { einstellung: salon(), version: 1, fehlt: [], fragebogen: fbBis(0) } },
[an('p1')]: [{ body: fbStart() }, { body: fbDaten({ version: 1, fragebogen: fbBis(0) }) }],
'PUT /api/termine/fragebogen': fbAntwort(1),
},
}) })
assert.equal(fbSchritt(fx, wurzel), 'branche')
assert.equal(meldungDerHuelle(fx, wurzel), '')
await fx.klick(fbRadio(fx, wurzel, 'salon'))
await fx.klick(fx.finde(wurzel, '[data-aktion="branche-starten"]'))
assert.equal(fbSchritt(fx, wurzel), 'wer')
assert.match(meldungDerHuelle(fx, wurzel), /^Die Branche „Friseur und Salon“ ist angelegt\./, 'ohne meldungEl bliebe die Meldung stumm')
assert.equal(fx.finde(wurzel, '.tm-meldung').getAttribute('role'), 'status')
}))
test('Ansicht off ohne laufendes Abo: der Hinweis vom Server (kein_abo mit Link zu Verträge), kein Fragebogen, keine weitere Abfrage', mitDom(undefined, async (fx) => {
const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('off', { daten: { abo: { ok: false, grund: 'kein_abo' } }, termine: ohneAbo('kein_abo') }) })
const karteEl = fx.finde(wurzel, '[data-hinweis="abo"]')
assert.ok(karteEl)
assert.ok(fx.text(karteEl).includes(SERVER_HINWEIS), 'der Text des Servers, unverändert')
const link = fx.alle(karteEl, 'button')
assert.deepEqual(link.map((b) => [fx.text(b), b.getAttribute('data-view'), b.getAttribute('class'), b.getAttribute('type')]), [['Zu Verträge', 'vertraege', 'kb-link', 'button']])
assert.equal(fx.text(fx.finde(karteEl, 'p')), `${SERVER_HINWEIS} Zu Verträge`, 'Text, Leerzeichen, Link wie in der Kachel')
assert.ok(fx.finde(wurzel, 'nav.tm-fortschritt') === null, 'kein Fragebogen')
assert.equal(fx.alle(wurzel, 'input').length, 0)
assert.ok(fx.finde(wurzel, '.tm-reiterleiste') === null)
assert.deepEqual(AUFRUFE(fx), [AN_FUNKTIONEN, an('p1')])
}))
test('Ansicht off ohne Abo: gekündigt, nicht aktiv, nicht lesbar und fehlendes abo zeigen nur den Text des Servers, ohne Link', mitDom(undefined, async (fx) => {
const faelle = [
['gekuendigt', { ok: false, grund: 'gekuendigt' }, ohneAbo('gekuendigt', 'Server: Dein Wartungs-Abo ist gekündigt.')],
['nicht_aktiv', { ok: false, grund: 'nicht_aktiv' }, ohneAbo('nicht_aktiv', 'Server: Dein Wartungs-Abo ist gerade nicht aktiv.')],
['Lesefehler (abo null)', null, { bestellbar: null, grund: null, hinweis: 'Server: Das Abo konnte gerade nicht geprüft werden.' }],
['abo fehlt in der Antwort', undefined, ohneAbo('gekuendigt', 'Server: ohne abo.')],
]
for (const [name, abo, termine] of faelle) {
const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('off', { daten: { abo }, termine }) })
const karteEl = fx.finde(wurzel, '[data-hinweis="abo"]')
assert.ok(karteEl, name)
assert.equal(fx.text(fx.finde(karteEl, 'p')), termine.hinweis, name)
assert.equal(fx.alle(karteEl, 'button').length, 0, `${name}: kein Link`)
assert.ok(fx.finde(wurzel, 'nav.tm-fortschritt') === null, name)
}
}))
test('Ansicht off ohne Abo: der Hinweis kommt maskiert in die Seite und der Browser kennt keinen eigenen Text', mitDom(undefined, async (fx) => {
const bose = '<img src=x onerror=alert(1)> & "Anführungszeichen"'
const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('off', { daten: { abo: { ok: false, grund: 'gekuendigt' } }, termine: ohneAbo('gekuendigt', bose) }) })
assert.equal(fx.alle(wurzel, 'img').length, 0)
assert.equal(fx.text(fx.finde(wurzel, '[data-hinweis="abo"] p')), bose)
}))
test('Ansicht off ohne Abo: fehlt der Text in der Liste (das Abo hat sich seit dem Laden geändert), wird die Liste noch einmal geholt', mitDom(undefined, async (fx) => {
const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('off', {
daten: { abo: { ok: false, grund: 'kein_abo' } },
tabelle: { [AN_FUNKTIONEN]: [funktionenAntwort([projektEintrag('p1', 'off')]), funktionenAntwort([projektEintrag('p1', 'off')], ohneAbo('kein_abo'))] },
}) })
assert.deepEqual(AUFRUFE(fx), [AN_FUNKTIONEN, an('p1'), AN_FUNKTIONEN])
assert.equal(fx.text(fx.finde(wurzel, '[data-hinweis="abo"] p')), `${SERVER_HINWEIS} Zu Verträge`)
// bleibt der Text auch dann aus, steht nur die Überschrift (kein erfundener Ersatztext)
const leer = await oeffneAnsicht(fx, { tabelle: await einProjekt('off', { daten: { abo: { ok: false, grund: 'gekuendigt' } } }) })
assert.ok(fx.finde(leer, '[data-hinweis="abo"] p') === null)
assert.equal(fx.text(fx.finde(leer, '[data-hinweis="abo"] h3')), 'Terminplaner einrichten')
}))
test('Ansicht off: in der Kundenansicht gilt dasselbe (Fragebogen mit Hinweis der Kundenansicht, ohne Abo der Hinweis)', mitDom(undefined, async (fx) => {
const mit = await oeffneAnsicht(fx, { tabelle: await einProjekt('off', { daten: { kundenansicht: true } }) })
assert.match(fx.text(fx.finde(mit, '.tm-box[role="note"]')), /Kundenansicht/)
const ohne = await oeffneAnsicht(fx, { tabelle: await einProjekt('off', { daten: { kundenansicht: true, abo: { ok: false, grund: 'gekuendigt' } }, termine: ohneAbo('gekuendigt') }) })
assert.ok(fx.finde(ohne, '[data-hinweis="abo"]'))
assert.ok(fx.finde(ohne, 'nav.tm-fortschritt') === null)
}))
test('Ansicht active ohne Einstellung: weiter die Vorlagenauswahl von TP1 (kein Fragebogen), mit Einstellung die vier Reiter', mitDom(undefined, async (fx) => {
const ohne = await oeffneAnsicht(fx, { tabelle: await einProjekt('active', { daten: { einstellung: null, version: 0, fragebogen: null } }) })
assert.equal(fx.alle(ohne, 'input[name="termine-vorlage"]').length, 1)
assert.ok(fx.text(ohne).includes('Womit möchtest du starten?'))
assert.ok(fx.finde(ohne, 'nav.tm-fortschritt') === null)
assert.ok(fx.finde(ohne, '.tm-reiterleiste') === null)
const mit = await oeffneAnsicht(fx, { tabelle: await einProjekt('active') })
assert.deepEqual(reiterTexte(fx, mit), ['Termine', 'Leistungen', 'Team & Plätze', 'Einstellungen'])
assert.ok(fx.finde(mit, '.tm-statuskarte') === null)
assert.ok(fx.finde(mit, '.tm-hinweise .tm-box-warn') === null, 'weder Abschaltung noch fehlende Angaben')
}))
test('Ansicht active: fehlende Pflichtangaben zeigen den Hinweis „Noch nicht buchbar“ weiter wie bisher', mitDom(undefined, async (fx) => {
const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('active', { daten: { fehlt: ['betrieb.telefon'] } }) })
assert.match(fx.text(fx.finde(wurzel, '.tm-hinweise .tm-box-warn')), /Noch nicht buchbar/)
}))
for (const [state, pille, ticket] of [['ordered', 'Zahlung offen', null], ['setup', 'In Einrichtung', 100342]]) {
test(`Ansicht ${state}: Statuskarte „${pille}“ und die Reiter Leistungen, Team & Plätze, Einstellungen nur zum Lesen, ohne Reiter „Termine“`, mitDom(undefined, async (fx) => {
const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt(state, {
eintrag: { ...GEORDERT, ticketWoid: ticket, invoiceUrl: state === 'setup' ? 'https://invoice.stripe.test/i/in_1' : null },
daten: { fehlt: ['betrieb.telefon'], aktivSeit: null },
}) })
const karteEl = fx.finde(wurzel, '.tm-statuskarte')
assert.ok(karteEl)
assert.equal(karteEl.getAttribute('data-state'), state)
assert.equal(fx.text(fx.finde(karteEl, '.tm-pille')), pille)
assert.match(fx.text(karteEl), /Bestellt am\s*05\.10\.2026/, 'Bestelldatum aus dem Eintrag der Liste')
if (state === 'setup') {
assert.match(fx.text(karteEl), /Ticketnummer\s*#100342/)
assert.match(fx.text(karteEl), new RegExp(`Zeiteinschätzung\\s*${EINRICHTUNG}`), 'Einrichtungsdauer aus GET /api/funktionen')
}
assert.ok(fx.finde(wurzel, 'nav.tm-fortschritt') === null, 'kein Fragebogen')
assert.deepEqual(reiterTexte(fx, wurzel), ['Leistungen', 'Team & Plätze', 'Einstellungen'])
assert.ok(fx.finde(wurzel, '#tm-tab-termine') === null)
assert.ok(fx.finde(wurzel, '.tm-hinweise .tm-box-warn') === null, 'weder Banner der Abschaltung noch „Noch nicht buchbar“')
// jeder Reiter ist schreibgeschützt: Felder gesperrt, keine Knöpfe zum Ändern
for (const [key, ohneKnopf] of [['leistungen', '[data-werkzeug="leistung-neu"]'], ['team', '[data-werkzeug="ressource-neu"]'], ['einstellungen', 'button[type="submit"]']]) {
await fx.klick(fx.finde(wurzel, `#tm-tab-${key}`))
await fx.ruhe()
const panel = fx.finde(wurzel, '#tm-panel')
assert.ok(fx.finde(panel, ohneKnopf) === null, `${key}: nichts zum Ändern`)
assert.ok(fx.finde(panel, 'fieldset[disabled]') || key !== 'einstellungen', `${key}: gesperrt`)
assert.ok(!fx.text(panel).includes('null'), `${key}: kein „null“ als Text (replaceChildren mit null)`)
}
// „Export und Status“ des Reiters Einstellungen sagt vor der Freischaltung nicht „Aktiv“
assert.ok(fx.finde(fx.finde(wurzel, '#tm-panel'), '.tm-status') === null)
assert.equal(fx.alle(fx.finde(wurzel, '#tm-panel'), 'a').length, 0, 'noch nichts zum Exportieren')
assert.deepEqual(AUFRUFE(fx), [AN_FUNKTIONEN, an('p1')], 'der Reiter „Termine“ wird nicht gelesen (der Server antwortete mit 409)')
}))
}
test('Ansicht ordered/setup: die Pfeiltasten wandern nur durch die drei Reiter, ein gemerkter Reiter „Termine“ fällt auf den ersten zurück und bleibt für andere Websites gemerkt', mitDom(undefined, async (fx) => {
const tabelle = {
[AN_FUNKTIONEN]: funktionenAntwort([projektEintrag('p1', 'active'), projektEintrag('p2', 'ordered', GEORDERT)]),
[an('p1')]: { body: await ansichtDaten('active') },
[an('p2')]: { body: await ansichtDaten('ordered') },
}
const wurzel = await oeffneAnsicht(fx, { projectId: 'p1', tabelle })
await fx.klick(fx.finde(wurzel, '#tm-tab-termine'))
await fx.setze(fx.finde(wurzel, '#termine-project'), 'p2')
await fx.ruhe()
assert.ok(fx.finde(wurzel, '.tm-statuskarte'))
assert.deepEqual(reiterTexte(fx, wurzel), ['Leistungen', 'Team & Plätze', 'Einstellungen'])
assert.equal(fx.finde(wurzel, '[role="tab"][aria-selected="true"]').dataset.reiter, 'leistungen', 'der erste Reiter, den es gibt')
await fx.sende(fx.finde(wurzel, '.tm-reiterleiste'), 'keydown', { key: 'ArrowLeft' })
assert.equal(fx.finde(wurzel, '[role="tab"][aria-selected="true"]').dataset.reiter, 'einstellungen', 'Pfeil links vom ersten Reiter springt zum letzten')
await fx.sende(fx.finde(wurzel, '.tm-reiterleiste'), 'keydown', { key: 'ArrowRight' })
assert.equal(fx.finde(wurzel, '[role="tab"][aria-selected="true"]').dataset.reiter, 'leistungen')
// zurück zur aktiven Website: der zuvor gewählte Reiter „Termine“ ist wieder da
await fx.setze(fx.finde(wurzel, '#termine-project'), 'p1')
await fx.ruhe()
assert.equal(fx.finde(wurzel, '[role="tab"][aria-selected="true"]').dataset.reiter, 'leistungen', 'die Wahl in p2 gilt ab jetzt')
}))
test('Ansicht ordered in der Kundenansicht: „Zahlung fortsetzen“ ist gesperrt', mitDom(undefined, async (fx) => {
const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('ordered', { eintrag: GEORDERT, daten: { kundenansicht: true } }) })
assert.equal(fx.finde(wurzel, '[data-aktion="fortsetzen"]').disabled, true)
}))
test('Ansicht ordered/setup ohne Einstellung: nur die Statuskarte, keine Reiter', mitDom(undefined, async (fx) => {
const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('setup', { eintrag: GEORDERT, daten: { einstellung: null, version: 0 } }) })
assert.ok(fx.finde(wurzel, '.tm-statuskarte'))
assert.ok(fx.finde(wurzel, '.tm-reiterleiste') === null)
assert.ok(!fx.text(wurzel).includes('null'))
}))
test('Ansicht disabled: Banner der Abschaltung (hinweisAbgeschaltet) mit Löschdatum aus abgeschaltet.loeschungAm und Export-Link, alle vier Reiter, unverändert (E11)', mitDom(undefined, async (fx) => {
const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('disabled') })
const text = ansicht.abschaltText(ABGESCHALTET)
const banner = fx.finde(wurzel, '.tm-hinweise .tm-box-warn')
assert.ok(banner)
assert.equal(fx.text(banner), `${text.davor}exportieren${text.danach}`)
assert.match(fx.text(banner), /03\.11\.2026|2026-11-03/, 'das Löschdatum aus loeschungAm')
assert.deepEqual(fx.alle(banner, 'a').map((a) => [fx.text(a), a.getAttribute('href')]), [['exportieren', '/api/termine/export.csv?projectId=p1']])
assert.deepEqual(reiterTexte(fx, wurzel), ['Termine', 'Leistungen', 'Team & Plätze', 'Einstellungen'])
assert.ok(fx.finde(wurzel, '.tm-statuskarte') === null)
assert.ok(fx.finde(wurzel, 'nav.tm-fortschritt') === null)
await fx.klick(fx.finde(wurzel, '#tm-tab-einstellungen'))
await fx.ruhe()
assert.match(fx.text(fx.finde(wurzel, '.tm-status')), /Abgeschaltet/, 'Export und Status bleiben in disabled')
}))
test('Ansicht disabled ohne Einstellung: Banner und „Keine Termine eingerichtet“ wie bisher', mitDom(undefined, async (fx) => {
const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('disabled', { daten: { einstellung: null, version: 0 } }) })
assert.ok(fx.finde(wurzel, '.tm-box-warn[role="note"]'))
assert.ok(fx.text(wurzel).includes('Keine Termine eingerichtet'))
assert.ok(fx.finde(wurzel, '.tm-reiterleiste') === null)
}))
test('Ansicht: der Zustand der Daten gilt, auch wenn die Liste einen anderen kennt (Liste: off, Daten: active)', mitDom(undefined, async (fx) => {
const wurzel = await oeffneAnsicht(fx, { tabelle: { [AN_FUNKTIONEN]: funktionenAntwort([projektEintrag('p1', 'off')]), [an('p1')]: { body: await ansichtDaten('active') } } })
assert.deepEqual(reiterTexte(fx, wurzel), ['Termine', 'Leistungen', 'Team & Plätze', 'Einstellungen'])
}))
test('Ansicht ordered: kennt die Liste einen älteren Zustand als die Daten (nach der Zahlung), wird sie neu geholt und die Karte zeigt die Ticketnummer', mitDom(undefined, async (fx) => {
const wurzel = await oeffneAnsicht(fx, { tabelle: {
[AN_FUNKTIONEN]: [funktionenAntwort([projektEintrag('p1', 'ordered', GEORDERT)]), funktionenAntwort([projektEintrag('p1', 'setup', { ...GEORDERT, ticketWoid: 100342 })])],
[an('p1')]: { body: await ansichtDaten('setup') },
} })
assert.deepEqual(AUFRUFE(fx), [AN_FUNKTIONEN, an('p1'), AN_FUNKTIONEN])
assert.equal(fx.text(fx.finde(wurzel, '.tm-statuskarte .tm-pille')), 'In Einrichtung')
assert.match(fx.text(fx.finde(wurzel, '.tm-statuskarte')), /Ticketnummer\s*#100342/)
// lässt sich die Liste nicht holen, gilt der Zustand der Daten, ohne Ticket
const ohne = await oeffneAnsicht(fx, { tabelle: {
[AN_FUNKTIONEN]: [funktionenAntwort([projektEintrag('p1', 'ordered', GEORDERT)]), { status: 500, body: {} }],
[an('p1')]: { body: await ansichtDaten('setup') },
} })
assert.equal(fx.text(fx.finde(ohne, '.tm-statuskarte .tm-pille')), 'In Einrichtung')
assert.match(fx.text(fx.finde(ohne, '.tm-statuskarte')), /Wir legen dein Ticket gerade an\./)
assert.ok(fx.finde(ohne, '[data-aktion="fortsetzen"]') === null, 'kein „Zahlung fortsetzen“ für eine bezahlte Bestellung')
}))
test('Ansicht: ein Fehler beim Laden zeigt die Fehlerkarte mit „Noch einmal versuchen“, das lädt neu', mitDom(undefined, async (fx) => {
const tabelle = await einProjekt('off', { tabelle: { [an('p1')]: [{ status: 500, body: { error: 'Der Server hat gerade Pause.' } }, { body: await ansichtDaten('off') }] } })
const wurzel = await oeffneAnsicht(fx, { tabelle })
assert.equal(fx.text(fx.finde(wurzel, '[role="alert"] p')), 'Der Server hat gerade Pause.')
await fx.klick(fx.finde(wurzel, '[role="alert"] button'))
await fx.ruhe()
assert.ok(fx.finde(wurzel, 'nav.tm-fortschritt'))
}))
// ------------------------------------------------------- Rückkehr von Stripe
const zweiProjekte = async (zustandP2 = 'ordered', extraP2 = GEORDERT) => ({
[AN_FUNKTIONEN]: funktionenAntwort([projektEintrag('p1', 'off'), projektEintrag('p2', zustandP2, extraP2)]),
[an('p1')]: { body: await ansichtDaten('off') },
[an('p2')]: { body: await ansichtDaten(zustandP2) },
})
/** Aufrufe von history.replaceState mitschreiben. */
function merkeAdressen(fx) {
const adressen = []
const echt = fx.window.history.replaceState
fx.window.history.replaceState = (zustand, titel, url) => { adressen.push(url); echt(zustand, titel, url) }
return adressen
}
test('Rückkehr nach der Zahlung: #termine?bestellt=1&projekt=p2 wählt p2, zeigt „Danke!“, bereinigt die Adresse (einmal gelesen)', mitDom(undefined, async (fx) => {
const adressen = merkeAdressen(fx)
const wurzel = await oeffneAnsicht(fx, { projectId: 'p1', hash: '#termine?bestellt=1&projekt=p2', tabelle: await zweiProjekte() })
assert.equal(fx.finde(wurzel, '#termine-project').value, 'p2', 'das Projekt der Rückkehr (vor dem gemerkten p1)')
assert.equal(fx.text(fx.finde(wurzel, '[data-danke]')), 'Danke! Sobald die Zahlung da ist, legen wir los.')
assert.equal(fx.text(fx.finde(wurzel, '.tm-statuskarte .tm-pille')), 'Zahlung offen')
assert.deepEqual(adressen, ['/dashboard.html#termine'], 'Pfad und Query bleiben, der Hash bis auf #termine weg')
assert.equal(fx.window.location.hash, '#termine')
assert.deepEqual(AUFRUFE(fx), [AN_FUNKTIONEN, an('p2')], 'nur das Projekt der Rückkehr wird geladen')
// ein zweiter Aufruf (Hash ist bereinigt) kennt weder Rückkehr noch Danke, behält aber die Auswahl
const wieder = await oeffneAnsicht(fx, { projectId: '', hash: fx.window.location.hash, tabelle: await zweiProjekte() })
assert.ok(fx.finde(wieder, '[data-danke]') === null)
assert.equal(fx.finde(wieder, '#termine-project').value, 'p2')
}))
test('Rückkehr nach der Zahlung, wenn der Webhook schon durch ist (setup): „Danke!“ auch dann', mitDom(undefined, async (fx) => {
const wurzel = await oeffneAnsicht(fx, { projectId: 'p1', hash: '#termine?bestellt=1&projekt=p2', tabelle: await zweiProjekte('setup', { ...GEORDERT, ticketWoid: 100342 }) })
assert.ok(fx.finde(wurzel, '[data-danke]'))
assert.equal(fx.text(fx.finde(wurzel, '.tm-statuskarte .tm-pille')), 'In Einrichtung')
}))
test('Rückkehr nach dem Abbruch: #termine?projekt=p2 wählt p2 ohne „Danke!“, die Adresse ist bereinigt; „Zahlung fortsetzen“ ist da', mitDom(undefined, async (fx) => {
const adressen = merkeAdressen(fx)
const wurzel = await oeffneAnsicht(fx, { projectId: 'p1', hash: '#termine?projekt=p2', tabelle: await zweiProjekte() })
assert.equal(fx.finde(wurzel, '#termine-project').value, 'p2')
assert.ok(fx.finde(wurzel, '[data-danke]') === null)
assert.ok(!fx.text(wurzel).includes('Danke!'))
assert.ok(fx.finde(wurzel, '[data-aktion="fortsetzen"]'))
assert.deepEqual(adressen, ['/dashboard.html#termine'])
assert.equal(fx.window.location.hash, '#termine')
}))
test('Rückkehr: bestellt=1 ohne Projekt gilt dem gewählten Projekt; ein unbekanntes Projekt wird ignoriert (kein „Danke!“ für ein anderes)', mitDom(undefined, async (fx) => {
const ohneProjekt = await oeffneAnsicht(fx, { projectId: 'p2', hash: '#termine?bestellt=1', tabelle: await zweiProjekte() })
assert.ok(fx.finde(ohneProjekt, '[data-danke]'))
assert.equal(fx.window.location.hash, '#termine')
const unbekannt = await oeffneAnsicht(fx, { projectId: 'p2', hash: '#termine?bestellt=1&projekt=gibtsnicht', tabelle: await zweiProjekte() })
assert.ok(fx.finde(unbekannt, '[data-danke]') === null)
assert.equal(fx.finde(unbekannt, '#termine-project').value, 'p2', 'es bleibt bei der gewählten Website')
assert.equal(fx.window.location.hash, '#termine')
}))
test('Rückkehr: „Danke!“ nur für das Projekt der Zahlung und nur beim ersten Zeichnen, nicht nach einem Wechsel der Website', mitDom(undefined, async (fx) => {
const wurzel = await oeffneAnsicht(fx, { projectId: 'p1', hash: '#termine?bestellt=1&projekt=p2', tabelle: await zweiProjekte() })
assert.ok(fx.finde(wurzel, '[data-danke]'))
await fx.setze(fx.finde(wurzel, '#termine-project'), 'p1')
await fx.ruhe()
assert.ok(fx.finde(wurzel, 'nav.tm-fortschritt'), 'p1 ist die Website im Zustand off')
await fx.setze(fx.finde(wurzel, '#termine-project'), 'p2')
await fx.ruhe()
assert.ok(fx.finde(wurzel, '.tm-statuskarte'))
assert.ok(fx.finde(wurzel, '[data-danke]') === null, 'der Dank war einmal')
}))
test('Rückkehr: bestellt=1 für ein Projekt, das nicht bestellt ist (active, off), zeigt kein „Danke!“; fremde Hashes werden nicht angefasst', mitDom(undefined, async (fx) => {
const adressen = merkeAdressen(fx)
const aktiv = await oeffneAnsicht(fx, { projectId: 'p2', hash: '#termine?bestellt=1&projekt=p2', tabelle: await zweiProjekte('active', {}) })
assert.ok(fx.finde(aktiv, '[data-danke]') === null)
assert.ok(!fx.text(aktiv).includes('Danke!'))
assert.equal(adressen.length, 1)
const fremd = await oeffneAnsicht(fx, { projectId: 'p2', hash: '#news?bestellt=1&projekt=p2', tabelle: await zweiProjekte() })
assert.ok(fx.finde(fremd, '[data-danke]') === null, 'die Rückkehr der News gehört nicht hierher')
assert.equal(adressen.length, 1, 'und ihr Hash bleibt stehen')
assert.equal(fx.window.location.hash, '#news?bestellt=1&projekt=p2')
}))
test('Rückkehr: Projekt-ID aus der Adresse landet nur als Wert in der Anfrage (codiert), nie im Baum', mitDom(undefined, async (fx) => {
const wurzel = await oeffneAnsicht(fx, { projectId: 'p1', hash: '#termine?bestellt=1&projekt=%3Cimg%20src%3Dx%3E', tabelle: await zweiProjekte() })
assert.equal(fx.alle(wurzel, 'img').length, 0)
assert.ok(!AUFRUFE(fx).some((a) => a.includes('<img')))
}))
// ------------------------------------------------------- „Zahlung fortsetzen“ scheitert: Ansicht neu aufbauen (Ruling R110)
const ORDERED_P1 = () => projektEintrag('p1', 'ordered', GEORDERT)
const fortsetzenKnopf = (fx, wurzel) => fx.finde(wurzel, '[data-aktion="fortsetzen"]')
test('R110: 409 zustimmung_fehlt ⇒ Liste und Daten werden neu geholt und das Bestellformular steht an der Stelle der Karte', mitDom(undefined, async (fx) => {
const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('ordered', {
eintrag: GEORDERT,
tabelle: { [AN_CHECKOUT]: { status: 409, body: { error: 'Bitte stimme der Erklärung zu, um zu bestellen.', code: 'zustimmung_fehlt' } } },
}) })
assert.ok(fx.finde(wurzel, '.tm-statuskarte'))
assert.ok(fx.finde(wurzel, '.tm-bestellen') === null)
await fx.klick(fortsetzenKnopf(fx, wurzel))
await fx.ruhe()
assert.deepEqual(AUFRUFE(fx), [AN_FUNKTIONEN, an('p1'), AN_CHECKOUT, AN_FUNKTIONEN, an('p1')])
assert.deepEqual(fx.fetchLog[2].body, { projectId: 'p1', fortsetzen: true })
assert.ok(fx.finde(wurzel, '.tm-statuskarte') === null, 'die Karte ist weg')
const seite = fx.finde(wurzel, '.tm-bestellen')
assert.ok(seite, 'das Bestellformular')
assert.equal(fx.text(bestellKnopf(fx, wurzel)), (await bestellungTest()).consent.knopf, 'Knopftext aus der Antwort des Servers')
assert.equal(bestellKnopf(fx, wurzel).disabled, true, 'gesperrt, bis alle Haken stehen')
assert.equal(hakenFelder(fx, seite).length, registerHaken().length)
assert.equal(meldungDerHuelle(fx, wurzel), 'Bitte stimme der Erklärung zu, um zu bestellen.')
assert.deepEqual(reiterTexte(fx, wurzel), ['Leistungen', 'Team & Plätze', 'Einstellungen'], 'die Reiter bleiben, nur lesbar')
// „Ändern“ führt nicht in den (gesperrten) Fragebogen, sondern erklärt, warum nicht
await fx.klick(fx.finde(wurzel, '[data-aktion="aendern"]'))
assert.match(meldungDerHuelle(fx, wurzel), /seit der Bestellung gesperrt/)
// nach dem Setzen aller Haken geht die neue Zustimmung an den Checkout und es folgt die Zahlungsseite
routen(fx, { [AN_CHECKOUT]: { body: { url: KNOPF_URL } } })
await alleAnhaken(fx, seite)
assert.equal(bestellKnopf(fx, wurzel).disabled, false)
await fx.klick(bestellKnopf(fx, wurzel))
const bestellt = fx.fetchLog.at(-1)
assert.equal(bestellt.url, '/api/funktionen/termine/checkout')
assert.equal(bestellt.body.zustimmung.version, (await bestellungTest()).consent.version)
assert.equal(fx.window.location.href, KNOPF_URL)
}))
test('R110: 400 (die Bestellung ist inzwischen bezahlt, der Server kennt sie nicht mehr als offen) ⇒ die Ansicht folgt dem neuen Zustand, die Karte steht nicht veraltet da', mitDom(undefined, async (fx) => {
const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('ordered', {
eintrag: GEORDERT,
tabelle: {
[AN_CHECKOUT]: { status: 400, body: { error: 'Bitte stimme der Erklärung zu, um zu bestellen.', code: 'zustimmung_fehlt' } },
[AN_FUNKTIONEN]: [funktionenAntwort([ORDERED_P1()]), funktionenAntwort([projektEintrag('p1', 'setup', { ...GEORDERT, ticketWoid: 100342 })])],
[an('p1')]: [{ body: await ansichtDaten('ordered') }, { body: await ansichtDaten('setup') }],
},
}) })
const alteKarte = fx.finde(wurzel, '.tm-statuskarte')
await fx.klick(fortsetzenKnopf(fx, wurzel))
await fx.ruhe()
const karteEl = fx.finde(wurzel, '.tm-statuskarte')
assert.ok(karteEl !== alteKarte, 'neu gezeichnet')
assert.equal(fx.text(fx.finde(karteEl, '.tm-pille')), 'In Einrichtung')
assert.match(fx.text(karteEl), /Ticketnummer\s*#100342/)
assert.ok(fortsetzenKnopf(fx, wurzel) === null)
assert.ok(fx.finde(wurzel, '.tm-bestellen') === null, 'kein Bestellformular für eine bezahlte Bestellung')
assert.equal(meldungDerHuelle(fx, wurzel), '', 'der Fehler des alten Zustands steht nicht in der neuen Karte')
assert.ok(!fx.text(wurzel).includes('stimme der Erklärung'))
}))
test('R110: ist die Funktion nach dem Webhook aktiv, zeigt die Ansicht die vier Reiter statt der Karte', mitDom(undefined, async (fx) => {
const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('ordered', {
eintrag: GEORDERT,
tabelle: {
[AN_CHECKOUT]: { status: 400, body: { error: 'Bitte stimme der Erklärung zu, um zu bestellen.', code: 'zustimmung_fehlt' } },
[AN_FUNKTIONEN]: [funktionenAntwort([ORDERED_P1()]), funktionenAntwort([projektEintrag('p1', 'active')])],
[an('p1')]: [{ body: await ansichtDaten('ordered') }, { body: await ansichtDaten('active') }],
},
}) })
await fx.klick(fortsetzenKnopf(fx, wurzel))
await fx.ruhe()
assert.ok(fx.finde(wurzel, '.tm-statuskarte') === null)
assert.deepEqual(reiterTexte(fx, wurzel), ['Termine', 'Leistungen', 'Team & Plätze', 'Einstellungen'])
assert.equal(meldungDerHuelle(fx, wurzel), '')
}))
test('R110: ein anderer Fehler bei noch offener Bestellung (409, die Sitzung läuft schon) ⇒ die Karte wird neu gezeichnet und der Fehler steht am Knopf', mitDom(undefined, async (fx) => {
const FEHLER = 'Deine Zahlung läuft gerade oder ist schon da. Wir richten den Terminplaner ein, sobald sie bei uns ist.'
const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('ordered', {
eintrag: GEORDERT,
tabelle: { [AN_CHECKOUT]: { status: 409, body: { error: FEHLER } } },
}) })
const alteKarte = fx.finde(wurzel, '.tm-statuskarte')
await fx.klick(fortsetzenKnopf(fx, wurzel))
await fx.ruhe()
assert.deepEqual(AUFRUFE(fx), [AN_FUNKTIONEN, an('p1'), AN_CHECKOUT, AN_FUNKTIONEN, an('p1')])
const karteEl = fx.finde(wurzel, '.tm-statuskarte')
assert.ok(karteEl !== alteKarte)
assert.equal(fx.text(fx.finde(karteEl, '[data-meldung="fortsetzen"]')), FEHLER)
assert.equal(fortsetzenKnopf(fx, wurzel).disabled, false, 'der Knopf ist frei')
assert.ok(fx.finde(wurzel, '.tm-bestellen') === null)
}))
test('R110: lässt sich die Ansicht nicht neu laden, bleibt die Karte stehen und der Fehler steht am Knopf (bei fehlender Zustimmung der Grund des Neuladens)', mitDom(undefined, async (fx) => {
const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('ordered', {
eintrag: GEORDERT,
tabelle: {
[AN_CHECKOUT]: [{ status: 409, body: { error: 'Deine Zahlung läuft gerade.' } }, { status: 409, body: { error: 'Bitte stimme der Erklärung zu, um zu bestellen.', code: 'zustimmung_fehlt' } }],
[AN_FUNKTIONEN]: [funktionenAntwort([ORDERED_P1()]), { status: 500, body: { error: 'Der Server hat gerade Pause.' } }],
},
}) })
const karteEl = fx.finde(wurzel, '.tm-statuskarte')
await fx.klick(fortsetzenKnopf(fx, wurzel))
await fx.ruhe()
assert.ok(fx.finde(wurzel, '.tm-statuskarte') === karteEl, 'dieselbe Karte')
assert.equal(fx.text(fx.finde(karteEl, '[data-meldung="fortsetzen"]')), 'Deine Zahlung läuft gerade.')
assert.equal(fortsetzenKnopf(fx, wurzel).disabled, false)
await fx.klick(fortsetzenKnopf(fx, wurzel))
await fx.ruhe()
assert.ok(fx.finde(wurzel, '.tm-statuskarte') === karteEl)
assert.equal(fx.text(fx.finde(karteEl, '[data-meldung="fortsetzen"]')), 'Der Server hat gerade Pause.')
assert.ok(fx.finde(wurzel, '.tm-bestellen') === null)
}))
test('R110: wechselt der Nutzer die Website, während neu geladen wird, zeichnet die späte Antwort nichts mehr', mitDom(undefined, async (fx) => {
let freigeben
const spaet = new Promise((fertig) => { freigeben = fertig })
const tabelle = { ...(await zweiProjekte()), [AN_CHECKOUT]: { status: 409, body: { error: 'Deine Zahlung läuft gerade.' } } }
const wurzel = await oeffneAnsicht(fx, { projectId: 'p2', tabelle })
// ab jetzt hängt das Laden der Daten von p2, bis der Test es freigibt
const antwort = globalThis.fetch
globalThis.fetch = async (url, optionen) => {
if (String(url) !== '/api/termine/einstellungen?projectId=p2') return antwort(url, optionen)
fx.fetchLog.push({ method: 'GET', url: String(url), body: undefined })
await spaet
return { ok: true, status: 200, json: async () => structuredClone(await ansichtDaten('setup')) }
}
const klick = fx.klick(fortsetzenKnopf(fx, wurzel))
await fx.ruhe()
await fx.setze(fx.finde(wurzel, '#termine-project'), 'p1')
await fx.ruhe()
assert.ok(fx.finde(wurzel, 'nav.tm-fortschritt'), 'p1 steht da')
freigeben()
await klick
await fx.ruhe()
assert.ok(fx.finde(wurzel, 'nav.tm-fortschritt'), 'die späte Antwort für p2 hat p1 nicht überschrieben')
assert.ok(fx.finde(wurzel, '.tm-statuskarte') === null)
}))
+26 -1
View File
@@ -312,6 +312,8 @@ test('Portal: Ansicht #termine ist verdrahtet, funktionen.js lädt keine Terminp
const fn = lies('public/funktionen.js')
assert.deepEqual(fn.match(/from '\/[^']+'/g), ["from '/analyse-view.js'", "from '/geraete.js'"])
assert.doesNotMatch(fn, /from '\/termine/)
// dazu genau ein relativer Import: die gemeinsame Quelle des Links „Zu Verträge“ (Reiner Datenmodul, kein Terminplaner-Modul; C10b)
assert.deepEqual(fn.match(/from '\.[^']+'/g), ["from './vertraege-link.js'"])
assert.doesNotMatch(fn, /import\s*\(/, 'auch kein dynamischer Import')
})
@@ -365,10 +367,33 @@ test('Ansicht-Module: Reiter sind Module mit render(panel, ctx), die Huelle kenn
}
})
test('Ansicht nach Zustand (TP2 C10b): termine.js lädt Fragebogen, Statuskarte und die Quelle des Links relativ, kennt keinen Abo-Text und liest die Rückkehr von Stripe einmal', () => {
const ohneKommentare = (text) => text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|\s)\/\/.*$/gm, '')
const huelle = ohneKommentare(lies('public/termine.js'))
assert.ok(huelle.includes("import { renderFragebogen } from './termine-fragebogen.js'"))
assert.ok(huelle.includes("import { renderBestellen, statusKarte } from './funktion-bestellen.js'"))
assert.ok(huelle.includes("import { ZU_VERTRAEGE } from './vertraege-link.js'"))
assert.doesNotMatch(huelle, /from '\/|import\s*\(/, 'nur relative, statische Importe')
// R111: der Abo-Hinweis kommt vom Server (GET /api/funktionen, termine.hinweis), nie als Text aus dem Browser; R112: die alten Texte sind weg
assert.doesNotMatch(huelle, /Wartungs-Abo|gekündigt|nicht aktiv|nicht geprüft|brauchst du ein laufendes/, 'keine Kopie der Abo-Hinweise')
assert.doesNotMatch(huelle, /noch nicht freigeschaltet|Wir schalten den Terminplaner für dich frei/, 'die Texte der Ansicht vor C10b')
assert.match(huelle, /s\.termine\??\.hinweis/, 'der Text kommt aus der Antwort von /api/funktionen')
assert.match(huelle, /ZU_VERTRAEGE\.view/, 'der Link nimmt Ziel, Text und Klasse aus der gemeinsamen Quelle')
assert.doesNotMatch(huelle, /'vertraege'|"vertraege"|Zu Verträge/, 'und schreibt sie nicht noch einmal hin')
// Rückkehr von Stripe: die Adresse wird an genau einer Stelle gelesen und bereinigt
assert.equal(huelle.match(/history\.replaceState/g).length, 1)
assert.equal(huelle.match(/location\.hash/g).length, 1)
assert.match(huelle, /bestellt'\) === '1'/, 'danke nur mit bestellt=1')
// die Quelle des Links ist reine Daten ohne Import
const link = lies('public/vertraege-link.js')
assert.doesNotMatch(ohneKommentare(link), /\bimport\b/)
assert.match(link, /export const ZU_VERTRAEGE = Object\.freeze\(\{ text: 'Zu Verträge', view: 'vertraege', klasse: 'kb-link' \}\)/)
})
test('Fragebogen (TP2 C9a): renderFragebogen wird nur über termine.js geladen, nie absolut, nie von einem Reiter oder von funktionen.js', () => {
const dateien = readdirSync(new URL('../public/', import.meta.url)).filter((f) => /\.(js|html)$/.test(f))
const ladende = dateien.filter((f) => /termine-fragebogen\.js/.test(lies(`public/${f}`)))
assert.ok(ladende.every((f) => f === 'termine.js'), `nur die Huelle darf den Fragebogen laden: ${ladende.join(', ')}`)
assert.deepEqual(ladende, ['termine.js'], `nur die Huelle darf den Fragebogen laden (C10b verdrahtet ihn): ${ladende.join(', ')}`)
for (const f of dateien) assert.doesNotMatch(lies(`public/${f}`), /['"]\/termine-fragebogen\.js['"]/, `${f}: kein absoluter Import`)
assert.match(lies('public/termine-fragebogen.js'), /export function renderFragebogen\(root, ctx\)/)
})
+201 -5
View File
@@ -68,6 +68,21 @@ function portalHilfe(p, json) {
return json({})
}
// Branchen und Bestellsicht, wie GET /api/termine/einstellungen sie liefert (Server: BRANCHEN, bestellungAnsicht); feste Texte der Fixture
const FX_BRANCHEN = [
{ key: 'salon', name: 'Friseur und Salon', gesundheit: false },
{ key: 'kosmetik', name: 'Kosmetik und Wellness', gesundheit: false },
{ key: 'gesundheit', name: 'Gesundheitsbereich', gesundheit: true },
]
const FX_BESTELLUNG = {
preis: '149 €',
preisHinweis: 'ohne Umsatzsteuer gemäß § 19 UStG',
einrichtung: 'meist innerhalb von 1–2 Wochen',
consent: { version: 'fx-1', knopf: 'Zahlungspflichtig bestellen · 149 €', items: [{ id: 'verfuegbar', text: 'Ich bestelle den Terminplaner verbindlich.', haken: true }] },
dokumente: [],
belehrung: 'Widerrufsbelehrung der Fixture. Du hast das Recht, binnen vierzehn Tagen ohne Angabe von Gründen diesen Vertrag zu widerrufen.',
}
/** Fixture: Daten, die page.route statt der Schnittstellen /api/funktionen und /api/termine liefert. */
function neueFixture() {
const fx = {
@@ -82,14 +97,25 @@ function neueFixture() {
{ key: 'beratung', name: 'Beratung und Coaching', beschreibung: 'Gespräche vor Ort oder online.' },
],
fehlt: [],
fragebogen: { schema: 1, branche: 'salon', bestaetigt: [], bundeslandGewaehlt: false, preiseGeprueft: false, einbau: { modus: null, knopfText: '', stelle: '', gestaltung: '', bemerkung: '' } },
offen: [],
checkout: { status: 200, body: { url: 'https://checkout.stripe.test/c/pay/cs_test_fx' } }, // Antwort auf POST /api/funktionen/termine/checkout
anfragenOffen: 0,
buchungen: [],
aufrufe: [],
}
// GET /api/termine/einstellungen. off: Entwurf mit Abo (abo), offen und Bestellsicht; ordered: Bestellsicht und offen, aber kein abo (R110)
fx.daten = (state = fx.projekte[0].features.termine.state) => ({
state, aktivSeit: '2026-10-01T09:00:00.000Z', einstellung: fx.einstellung, version: fx.einstellung ? 3 : 0, vorlagen: fx.vorlagen, feeds: {}, siteKey: KEY,
einbau: { snippet: '' }, anfragenOffen: fx.buchungen.filter((b) => b.status === 'angefragt').length || fx.anfragenOffen, fehlt: fx.fehlt,
abgeschaltet: state === 'disabled' ? fx.abgeschaltet ?? { grund: null, seit: null, loeschungAm: null } : null,
branchen: FX_BRANCHEN,
fragebogen: fx.fragebogen,
kundenansicht: false,
...(fx.einstellung ? {} : { betriebVorschlag: { name: 'Salon Müller', adresse: 'Hauptstr. 1, 67722 Winnweiler', email: 'info@salon-mueller.example', telefon: '', websiteUrl: '' } }),
abo: state === 'off' ? (fx.bestellbar === true ? { ok: true } : fx.bestellbar === false ? { ok: false, grund: fx.aboGrund } : null) : null,
offen: state === 'off' || state === 'ordered' ? fx.offen : null,
bestellung: state === 'off' || state === 'ordered' ? FX_BESTELLUNG : null,
})
return fx
}
@@ -152,10 +178,14 @@ async function fixtureAntwort(fx, route, req, url, json) {
: { verfuegbar: false }
return json({ projects: fx.projekte, termine })
}
if (p === '/api/funktionen/termine/checkout' && m === 'POST') {
fx.aufrufe.push(['checkout', req.postDataJSON()])
return json(fx.checkout.body, fx.checkout.status)
}
if (p === '/api/termine/einstellungen' && m === 'GET') {
const projekt = q.get('projectId')
if (fx.verzoegerung?.[projekt]) await new Promise((fertig) => { setTimeout(fertig, fx.verzoegerung[projekt]) })
const daten = fx.daten()
const daten = fx.daten(fx.projekte.find((p) => p.projectId === projekt)?.features?.termine?.state)
if (fx.ohneEinstellungFuer?.includes(projekt)) Object.assign(daten, { einstellung: null, version: 0 })
return json(daten)
}
@@ -268,7 +298,7 @@ const formular = (page, name) => page.getByRole('form', { name })
const fehlerAm = (loc, pfad) => loc.locator(`[data-fehler="${pfad}"]`)
const warteAnsicht = async (page) => {
await page.locator('#view-termine.active').waitFor()
await page.locator('#termine-body .tm-reiterleiste, #termine-body .tm-vorlagen, #termine-body .tm-info').first().waitFor()
await page.locator('#termine-body .tm-reiterleiste, #termine-body .tm-vorlagen, #termine-body .tm-info, #termine-body .tm-fragebogen, #termine-body .tm-statuskarte').first().waitFor()
}
// ---- Gaeste ueber die oeffentliche Schnittstelle buchen (echte Pruefungen): Link-Token aus der Mail
@@ -448,11 +478,12 @@ async function pruefungen() {
await warteAnsicht(page)
await page.locator('.tm-reiterleiste').waitFor()
eq(await page.locator('#termine-project').inputValue(), 'p2', 'Auswahl: die Website der Kachel')
// andere Website ohne Terminplaner: Hinweis statt Reiter
// andere Website, Terminplaner nicht bestellt (Aus, das Abo läuft): der Fragebogen statt der Reiter (C10b ersetzt den Hinweis „noch nicht freigeschaltet“)
await page.locator('#termine-project').selectOption('p1')
await page.getByText('noch nicht freigeschaltet').waitFor()
await page.locator('#termine-body .tm-fragebogen').waitFor()
eq(await page.locator('.tm-reiterleiste').count(), 0, 'keine Reiter')
await knopf(page.locator('#termine-body'), 'Chat öffnen').waitFor()
assert(!(await textVon(page.locator('#termine-body'))).includes('noch nicht freigeschaltet'), 'der alte Text ist weg')
eq(await page.locator('#termine-body [data-view="chat"]').count(), 0, 'kein Chat-Knopf der alten Ansicht')
await shot(page, 'projektwahl')
await page.locator('#termine-project').selectOption('p2')
await page.locator('.tm-reiterleiste').waitFor()
@@ -473,6 +504,171 @@ async function pruefungen() {
})(),
})
// ------------------------------------------------------------ Ansicht nach Zustand (TP2 C10b)
/** Fixture mit einer Website im Zustand state (Eintrag der Liste wie GET /api/funktionen ihn liefert). */
const imZustand = (state, extra = {}) => {
const fx = neueFixture()
fx.projekte[0].features.termine = { state, ...extra }
return fx
}
const GEORDERT = { orderedAt: '2026-10-04T08:00:00.000Z', ticketWoid: null, invoiceUrl: null }
await check('fx_ansicht_aus_fragebogen_und_hinweis', async (page) => {
const fx = page.fx
// das Abo läuft: der Fragebogen mit Fortschrittsleiste, keine Reiter
await oeffne(page, '#termine')
await warteAnsicht(page)
await page.locator('#termine-body nav.tm-fortschritt').waitFor()
eq(await page.locator('.tm-reiterleiste').count(), 0, 'keine Reiter')
eq(await page.locator('[data-hinweis="abo"]').count(), 0, 'kein Hinweis')
eq(await page.locator('li.tm-schritt[aria-current="step"]').getAttribute('data-schritt'), 'branche', 'Start beim ersten offenen Schritt')
await shot(page, 'ansicht-fragebogen')
// ohne Abo: nur der Hinweis des Servers, bei kein_abo mit dem Link zu den Verträgen; kein Fragebogen
fx.bestellbar = false
for (const [grund, mitLink] of [['kein_abo', true], ['gekuendigt', false], ['nicht_aktiv', false], ['unbekannt', false]]) {
fx.aboGrund = grund === 'unbekannt' ? null : grund
fx.bestellbar = grund === 'unbekannt' ? null : false
await page.reload()
await page.locator('[data-hinweis="abo"]').waitFor()
const hinweis = page.locator('[data-hinweis="abo"]')
assert((await textVon(hinweis)).includes(FX_ABO_HINWEISE[grund]), `${grund}: Text des Servers in ${await textVon(hinweis)}`)
eq(await hinweis.locator('[data-view="vertraege"]').count(), mitLink ? 1 : 0, `${grund}: Link zu Verträge`)
eq(await page.locator('#termine-body nav.tm-fortschritt').count(), 0, `${grund}: kein Fragebogen`)
eq(await page.locator('#termine-body input').count(), 0, `${grund}: keine Felder`)
eq(await page.locator('.tm-reiterleiste').count(), 0, `${grund}: keine Reiter`)
if (grund === 'kein_abo') await shot(page, 'ansicht-ohne-abo')
}
// der Link führt zu den Verträgen
fx.bestellbar = false
fx.aboGrund = 'kein_abo'
await page.reload()
await knopf(page.locator('[data-hinweis="abo"]'), 'Zu Verträge').click()
await page.locator('#view-vertraege.active').waitFor()
}, { fixture: imZustand('off') })
for (const [state, pille, extra] of [
['ordered', 'Zahlung offen', GEORDERT],
['setup', 'In Einrichtung', { ...GEORDERT, ticketWoid: 100342, invoiceUrl: 'https://invoice.stripe.test/i/in_1' }],
]) {
await check(`fx_ansicht_${state}`, async (page) => {
await oeffne(page, '#termine')
await warteAnsicht(page)
const karte = page.locator('.tm-statuskarte')
await karte.waitFor()
eq(await textVon(karte.locator('.tm-pille')), pille, 'Pille')
assert((await textVon(karte)).includes('Bestellt am'), 'Bestelldatum')
if (state === 'setup') assert((await textVon(karte)).includes('#100342'), 'Ticketnummer')
else eq(await karte.locator('[data-aktion="fortsetzen"]').count(), 1, 'Zahlung fortsetzen')
eq(await page.getByRole('tab').allInnerTexts(), ['Leistungen', 'Team & Plätze', 'Einstellungen'], 'drei Reiter, keiner für Termine')
eq(await page.locator('#tm-tab-termine').count(), 0, 'kein Reiter Termine')
eq(await page.locator('.tm-hinweise .tm-box').count(), 0, 'weder Banner der Abschaltung noch „Noch nicht buchbar“')
await shot(page, `ansicht-${state}`)
// schreibgeschützt: Leistungen ohne „+ Leistung anlegen“, Einstellungen gesperrt, ohne Speichern, ohne „Aktiv“-Status und Export
await page.locator('#tm-tab-leistungen').click()
await page.locator('#tm-panel .tm-tab').waitFor()
eq(await page.locator('[data-werkzeug="leistung-neu"]').count(), 0, 'Leistungen: nichts zum Anlegen')
assert(!(await textVon(page.locator('#tm-panel'))).split('\n').some((zeile) => zeile.trim() === 'null'), 'Leistungen: kein Text „null“')
await page.locator('#tm-tab-einstellungen').click()
await page.locator('#tm-panel .tm-tab').waitFor()
eq(await page.locator('#tm-panel fieldset[disabled]').count() > 0, true, 'Einstellungen gesperrt')
eq(await page.locator('#tm-panel button[type="submit"]').count(), 0, 'Einstellungen: kein Speichern')
eq(await page.locator('#tm-panel .tm-status').count(), 0, 'kein „Aktiv“-Status vor der Freischaltung')
eq(await page.locator('#tm-panel a[download]').count(), 0, 'kein Export vor der Freischaltung')
eq(page.fx.aufrufe.filter((a) => a[0] === 'liste').length, 0, 'der Reiter Termine wird nicht gelesen')
}, { fixture: imZustand(state, extra) })
}
await check('fx_ansicht_bestellt_390', async (page) => {
await oeffne(page, '#termine')
await warteAnsicht(page)
await page.locator('.tm-statuskarte').waitFor()
eq(await passtInBreite(page), true, 'kein Streifen rechts')
const kleine = await page.evaluate(() => [...document.querySelectorAll('#view-termine button')]
.filter((e) => e.offsetParent !== null && e.getBoundingClientRect().height < 43.5).map((e) => `${e.textContent.trim().slice(0, 24)} ${Math.round(e.getBoundingClientRect().height)}`))
eq(kleine, [], 'Knöpfe mindestens 44 px hoch')
await shot(page, '390-ansicht-bestellt')
}, { fixture: imZustand('ordered', GEORDERT), context: { viewport: { width: 390, height: 844 } } })
await check('fx_rueckkehr_zahlung', async (page) => {
await oeffne(page, '#termine?bestellt=1&projekt=p2')
await warteAnsicht(page)
await page.locator('.tm-statuskarte [data-danke]').waitFor()
eq(await textVon(page.locator('[data-danke]')), 'Danke! Sobald die Zahlung da ist, legen wir los.', 'Danke')
eq(await page.locator('#termine-project').inputValue(), 'p2', 'Website der Zahlung')
eq(await page.evaluate(() => window.location.hash), '#termine', 'Adresse bereinigt')
await shot(page, 'rueckkehr-zahlung')
// neu geladen: kein Danke mehr, die Auswahl bleibt nicht (Seite neu), aber die Adresse ist sauber
await page.reload()
await warteAnsicht(page)
eq(await page.locator('[data-danke]').count(), 0, 'kein Danke nach dem Neuladen')
}, {
fixture: (() => {
const fx = neueFixture()
fx.projekte = [
{ projectId: 'p1', projectName: 'Alte Website', liveUrl: '', features: { news: { state: 'off' }, termine: { state: 'active' } } },
{ projectId: 'p2', projectName: 'Salon Müller', liveUrl: '', features: { news: { state: 'off' }, termine: { state: 'ordered', ...GEORDERT } } },
]
return fx
})(),
})
await check('fx_rueckkehr_abbruch', async (page) => {
await oeffne(page, '#termine?projekt=p2')
await warteAnsicht(page)
await page.locator('.tm-statuskarte').waitFor()
eq(await page.locator('[data-danke]').count(), 0, 'kein Danke nach dem Abbruch')
eq(await page.locator('#termine-project').inputValue(), 'p2', 'Website der Bestellung')
eq(await page.locator('.tm-statuskarte [data-aktion="fortsetzen"]').count(), 1, 'Zahlung fortsetzen')
eq(await page.evaluate(() => window.location.hash), '#termine', 'Adresse bereinigt')
}, {
fixture: (() => {
const fx = neueFixture()
fx.projekte = [
{ projectId: 'p1', projectName: 'Alte Website', liveUrl: '', features: { news: { state: 'off' }, termine: { state: 'active' } } },
{ projectId: 'p2', projectName: 'Salon Müller', liveUrl: '', features: { news: { state: 'off' }, termine: { state: 'ordered', ...GEORDERT } } },
]
return fx
})(),
})
await check('fx_zahlung_fortsetzen_zustimmung_fehlt', async (page) => {
const fx = page.fx
await oeffne(page, '#termine')
await warteAnsicht(page)
await page.locator('.tm-statuskarte').waitFor()
await knopf(page.locator('.tm-statuskarte'), 'Zahlung fortsetzen').click()
// die Karte weicht dem Bestellformular (R110), die Reiter bleiben
await page.locator('.tm-bestellen').waitFor()
eq(await page.locator('.tm-statuskarte').count(), 0, 'keine Karte mehr')
eq(fx.aufrufe.filter((a) => a[0] === 'checkout').map((a) => a[1]), [{ projectId: 'p1', fortsetzen: true }], 'Zahlung fortsetzen ohne Zustimmung')
assert((await textVon(meldung(page))).includes('Bitte stimme der Erklärung zu'), 'Meldung der Hülle')
const bestellen = page.locator('[data-aktion="bestellen"]')
eq(await bestellen.isDisabled(), true, 'gesperrt bis zum Haken')
await page.locator('.tm-bestellen input[type="checkbox"]').check()
eq(await bestellen.isDisabled(), false, 'frei nach dem Haken')
eq(await page.getByRole('tab').allInnerTexts(), ['Leistungen', 'Team & Plätze', 'Einstellungen'], 'Reiter bleiben')
await shot(page, 'zahlung-fortsetzen-bestellformular')
}, {
fixture: Object.assign(imZustand('ordered', GEORDERT), { checkout: { status: 409, body: { error: 'Bitte stimme der Erklärung zu, um zu bestellen.', code: 'zustimmung_fehlt' } } }),
})
await check('fx_zahlung_fortsetzen_inzwischen_bezahlt', async (page) => {
const fx = page.fx
await oeffne(page, '#termine')
await warteAnsicht(page)
await page.locator('.tm-statuskarte').waitFor()
eq(await textVon(page.locator('.tm-statuskarte .tm-pille')), 'Zahlung offen', 'zuerst offen')
// inzwischen kam die Zahlung an (Webhook): der Server kennt keine offene Bestellung mehr (400)
fx.projekte[0].features.termine = { state: 'setup', ...GEORDERT, ticketWoid: 100343 }
await knopf(page.locator('.tm-statuskarte'), 'Zahlung fortsetzen').click()
await page.locator('.tm-statuskarte .tm-pille', { hasText: 'In Einrichtung' }).waitFor()
assert((await textVon(page.locator('.tm-statuskarte'))).includes('#100343'), 'Ticketnummer der frischen Liste')
eq(await page.locator('[data-aktion="fortsetzen"]').count(), 0, 'kein Knopf mehr')
eq(await page.locator('.tm-bestellen').count(), 0, 'kein Bestellformular')
}, {
fixture: Object.assign(imZustand('ordered', GEORDERT), { checkout: { status: 400, body: { error: 'Bitte stimme der Erklärung zu, um zu bestellen.', code: 'zustimmung_fehlt' } } }),
})
// ------------------------------------------------------------ Reiter-Geruest
await check('fx_reiter_geruest', async (page) => {
await oeffne(page, '#termine')