Files
ClaudeandClaude Sonnet 5.5 447129d4e2 zeitenBestellen: Pin auf die Listen in app.js an den Buchungskalender angepasst
VIEW_HASHES, APP_PARENT und FEATURE_LABELS tragen seit dem Buchungskalender
einen Eintrag zwischen Webshop und Zeiten. Die Regexe folgen den echten Listen;
app.js bleibt unverändert.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 19:30:54 +00:00

879 lines
54 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync, readdirSync } from 'node:fs'
import { installFakeDom } from './support/fakeDom.js'
import { config } from '../server/config.js'
import { featureConfig, featureHaken, freiweg, oeffentlicheAnsicht } from '../server/services/featureRegistry.js'
import { oeffentlicheConsent } from '../server/services/featureOrder.js'
import { h } from '../public/termine-dom.js'
import { initZeiten } from '../public/zeiten.js'
import { behaltenFrist, behaltenLage, renderZeitenBestellen, tagText, zeitenStatusKarte } from '../public/zeiten-bestellen.js'
// Einschalt-, Bestell- und Behalten-Weg der Öffnungszeiten live im Portal (public/zeiten.js, public/zeiten-bestellen.js; Spec 3.1 bis 3.3,
// Rulings R20 und R32). Fake-DOM und Fake-fetch aus test/support/fakeDom.js: jeder Aufruf steht in fx.fetchLog, die Antworten kommen der Reihe nach
// aus fx.antworten. Die Texte der Register (Zustimmungen, Knopf, Belehrung) kommen aus dem echten Register des Servers.
const REGISTER = featureConfig('zeiten')
const BELEHRUNG = await featureHaken('zeiten').belehrung()
const STRIPE = 'https://checkout.stripe.test/c/pay/cs_test_1'
const KNOPF_BEZAHLT = REGISTER.consent.knopf
const HAKEN_BEZAHLT = REGISTER.consent.items.filter((item) => item.haken).map((item) => item.id)
// Der Editor im Editorplatz (public/zeiten-editor.js, eigene Tests in test/zeitenEditor.test.js) lädt GET /api/zeiten/<id>: hier mit fester Antwort
// (noch nichts gespeichert), ohne die Warteschlange der Antworten und fx.fetchLog zu berühren; fx.editorAufrufe zählt diese Abrufe.
const EDITOR_STAND = { state: 'active', daten: null, version: 0, kundenansicht: false, vorschau: null, feiertage: [], googleText: '' }
const mitDom = (optionen, fn) => async () => {
const fx = installFakeDom(optionen)
const echtes = globalThis.fetch
fx.editorAufrufe = []
globalThis.fetch = async (url, anfrage) => {
if (!String(url).startsWith('/api/zeiten/')) return echtes(url, anfrage)
fx.editorAufrufe.push(String(url))
return { ok: true, status: 200, json: async () => structuredClone(EDITOR_STAND) }
}
try {
await fn(fx)
await fx.ruhe() // ein Editor, der noch lädt, soll fertig sein, bevor das Fake-DOM geht
} finally {
fx.restore()
}
}
// ---------------------------------------------------------------------------------------------------------------- Fixtures
const zeitenFeature = (state = 'off', extra = {}) => ({
state, siteKey: null, ticketWoid: null, orderedAt: null, activatedAt: null, invoiceUrl: null, disabledReason: null, disabledAt: null, loeschungAm: null,
unlockedBy: state === 'off' ? null : 'abo', aboEndeAt: null, einbau: null, ...extra,
})
const projekt = (id = 'p1', state = 'off', feature = {}, extra = {}) => ({
projectId: id, projectName: `Website ${id}`, liveUrl: '', features: { news: { state: 'off' }, zeiten: zeitenFeature(state, feature) }, zeitenStatus: null, zeitenLeer: true, ...extra,
})
const angebot = (inklusive, grund = null, extra = {}) => ({
verfuegbar: true,
inklusive,
grund,
preis: REGISTER.priceLabel,
einschalten: { consent: oeffentlicheConsent(freiweg(REGISTER).consent), leistungsbeschreibung: { version: REGISTER.leistungsbeschreibung.version, url: REGISTER.leistungsbeschreibung.url } },
belehrung: BELEHRUNG,
...extra,
})
/** Antwort von GET /api/funktionen. */
const liste = (projects, zeiten, extra = {}) => ({
status: 200,
body: { projects, features: { zeiten: oeffentlicheAnsicht('zeiten') }, ustHinweis: config.ustHinweis, zeiten, ...extra },
})
const fehler = (status, error, code) => ({ status, body: { error, ...(code ? { code } : {}) } })
// ---------------------------------------------------------------------------------------------------------------- Hilfen
/** Die Ansicht in einen Behälter hängen und initZeiten laufen lassen. */
async function oeffne(fx, { projectId = 'p1', hash = '', dataset = {} } = {}) {
const root = h('div', { id: 'zeiten-body' })
document.body.append(root)
fx.window.location.hash = hash
for (const [name, wert] of Object.entries(dataset)) root.dataset[name] = wert
await initZeiten(root, { projectId })
return root
}
const knopf = (fx, root, aktion) => fx.finde(root, `[data-aktion="${aktion}"]`)
const haken = (fx, root) => fx.alle(root, 'input[type="checkbox"]')
const alleAnhaken = async (fx, root) => { for (const feld of haken(fx, root)) if (!feld.checked) await fx.klick(feld) }
const meldung = (fx, root) => fx.text(fx.finde(root, '[data-meldung="bestellen"]'))
const posts = (fx) => fx.fetchLog.filter((aufruf) => aufruf.method === 'POST')
const lesen = (pfad) => readFileSync(new URL(`../${pfad}`, import.meta.url), 'utf8')
// ---------------------------------------------------------------------------------------------------------------- Einschalten
test('Einschalten: Haken schaltet Knopf frei, POST /api/funktionen/zeiten/einschalten', mitDom({}, async (fx) => {
fx.antworten.push(
liste([projekt()], angebot(true)),
{ status: 200, body: { feature: { state: 'setup' }, einbau: 'angestossen' } },
liste([projekt('p1', 'setup', { einbau: 'angestossen' })], angebot(true)))
const root = await oeffne(fx)
const seite = fx.finde(root, '[data-variante="einschalten"]')
assert.ok(seite, 'die Einschaltseite')
assert.equal(fx.finde(seite, '.tm-rechtstext'), null, 'ohne Preis keine Widerrufsbelehrung: es gibt kein Widerrufsverlangen')
assert.doesNotMatch(fx.text(seite), /30 €|Zahlungspflichtig|einmalig|Widerruf/)
assert.match(fx.text(seite), /ohne Aufpreis enthalten/)
const link = fx.finde(seite, 'a')
assert.equal(link.getAttribute('href'), '/vertragsunterlagen/zeiten-leistungsbeschreibung.html')
assert.equal(link.getAttribute('rel'), 'noopener noreferrer')
const button = knopf(fx, root, 'einschalten')
assert.equal(fx.text(button), 'Einschalten', 'der Knopf trägt genau den Text des Registers (consentFrei)')
assert.equal(fx.text(button), REGISTER.consentFrei.knopf)
assert.equal(button.disabled, true, 'ohne Haken gesperrt')
assert.equal(haken(fx, root).length, 1)
assert.equal(fx.text(fx.finde(seite, '[data-hinweis="bestellen"]')), 'Setze den Haken, um einzuschalten.')
await fx.klick(button)
assert.equal(posts(fx).length, 0, 'ein gesperrter Knopf sendet nichts')
await fx.klick(haken(fx, root)[0])
assert.equal(button.disabled, false, 'der Haken gibt den Knopf frei')
await fx.klick(haken(fx, root)[0])
assert.equal(button.disabled, true, 'ohne Haken wieder gesperrt')
await fx.klick(haken(fx, root)[0])
await fx.klick(button)
assert.deepEqual(posts(fx), [{
method: 'POST',
url: '/api/funktionen/zeiten/einschalten',
body: { projectId: 'p1', zustimmung: { version: REGISTER.consentFrei.version, haken: ['leistung'] } },
}])
assert.deepEqual(fx.fetchLog.map((a) => `${a.method} ${a.url}`), ['GET /api/funktionen', 'POST /api/funktionen/zeiten/einschalten', 'GET /api/funktionen'], 'danach wird die Ansicht nach dem neuen Stand gezeichnet')
const karte = fx.finde(root, '.zt-status')
assert.equal(karte.getAttribute('data-state'), 'setup')
assert.match(fx.text(fx.finde(karte, '[data-danke]')), /Öffnungszeiten live ist eingeschaltet/)
assert.match(fx.text(karte), /nach dem nächsten Veröffentlichen/)
assert.equal(fx.finde(root, '[data-variante]'), null, 'die Einschaltseite ist weg')
}))
test('Einschalten: Fehler 503 und 400 bleiben am Knopf, der Knopf ist danach wieder frei; ein Klick zählt nur einmal', mitDom({}, async (fx) => {
fx.antworten.push(
liste([projekt()], angebot(true)),
fehler(503, 'Gerade nicht prüfbar – bitte versuch es gleich noch einmal.'),
{ status: 200, body: { feature: { state: 'setup' }, einbau: 'manuell' } },
liste([projekt('p1', 'setup', { einbau: 'manuell', ticketWoid: '4711' })], angebot(true)))
const root = await oeffne(fx)
await alleAnhaken(fx, root)
const button = knopf(fx, root, 'einschalten')
await fx.klick(button)
assert.equal(meldung(fx, root), 'Gerade nicht prüfbar – bitte versuch es gleich noch einmal.')
assert.equal(button.disabled, false, 'nach dem Fehler wieder frei (Haken bleiben)')
assert.equal(haken(fx, root)[0].checked, true)
await fx.klick(button)
assert.equal(posts(fx).length, 2)
assert.equal(fx.text(fx.finde(root, '.zt-status')).includes('#4711'), true, 'Ticketnummer in der Karte')
}))
test('Einschalten: 409 (Abo nicht mehr aktiv) zeichnet die Ansicht nach der neuen Liste mit der Meldung des Servers', mitDom({}, async (fx) => {
fx.antworten.push(
liste([projekt()], angebot(true)),
fehler(409, 'Dein Wartungs-Abo läuft nicht mehr. Du kannst Öffnungszeiten live für einmalig 30 € bestellen.', 'gekuendigt'),
liste([projekt()], angebot(false, 'gekuendigt')))
const root = await oeffne(fx)
await alleAnhaken(fx, root)
await fx.klick(knopf(fx, root, 'einschalten'))
assert.equal(fx.alle(root, '[data-variante]').length, 1)
assert.ok(fx.finde(root, '[data-variante="bestellen"]'), 'jetzt der bezahlte Weg')
assert.match(fx.text(fx.finde(root, '[data-meldung="ansicht"]')), /läuft nicht mehr/)
assert.equal(knopf(fx, root, 'bestellen').disabled, true, 'die Haken sind neu, der Knopf gesperrt')
assert.equal(haken(fx, root).every((feld) => !feld.checked), true)
}))
test('Einschalten: scheitert das Neuladen nach einem 409, steht die Meldung am Knopf', mitDom({}, async (fx) => {
fx.antworten.push(liste([projekt()], angebot(true)), fehler(409, 'Öffnungszeiten live ist für dieses Projekt schon bestellt oder eingeschaltet.'), fehler(500, 'Interner Serverfehler'))
const root = await oeffne(fx)
await alleAnhaken(fx, root)
await fx.klick(knopf(fx, root, 'einschalten'))
assert.match(meldung(fx, root), /schon bestellt oder eingeschaltet/)
assert.equal(knopf(fx, root, 'einschalten').disabled, false)
}))
// ---------------------------------------------------------------------------------------------------------------- Bestellen
test('Bestellen: POST /api/funktionen/zeiten/checkout, Weiterleitung zu Stripe', mitDom({}, async (fx) => {
fx.antworten.push(liste([projekt()], angebot(false, 'kein_abo')), { status: 200, body: { url: STRIPE } })
const root = await oeffne(fx)
const seite = fx.finde(root, '[data-variante="bestellen"]')
assert.ok(seite)
const alle = fx.alle(root, '*')
const stelle = (selektor) => {
const el = fx.finde(root, selektor)
assert.ok(el, `${selektor} fehlt`)
return alle.indexOf(el)
}
const stellen = ['.tm-bestellen-preis', '.tm-rechtstext', '.tm-bestellen-dokumente', '.tm-bestellen-haken input', '[data-aktion="bestellen"]'].map(stelle)
assert.deepEqual(stellen, [...stellen].sort((a, b) => a - b), 'Preis, Belehrung, Unterlagen, Haken, Knopf in dieser Reihenfolge')
assert.equal(new Set(stellen).size, stellen.length)
// Ruling R32: genau der Text des Nachweises, unverändert, in einem scrollbaren Bereich, nie eingeklappt
const bereich = fx.finde(root, '.tm-rechtstext')
assert.equal(bereich.textContent, BELEHRUNG)
assert.match(bereich.textContent, /Muster-Widerrufsformular/)
assert.equal(bereich.getAttribute('tabindex'), '0')
assert.equal(fx.alle(root, 'details').length, 0)
assert.equal(fx.alle(root, '[hidden]').length, 0)
assert.match(fx.text(fx.finde(root, '.tm-bestellen-betrag')), /^30 € einmalig, ohne Umsatzsteuer gemäß § 19 UStG$/)
assert.match(fx.text(fx.finde(root, '.tm-bestellen-preis')), /Einrichtung: meist innerhalb weniger Tage/)
assert.deepEqual(haken(fx, root).map((feld) => feld.dataset.haken), HAKEN_BEZAHLT, 'die zwei Haken des Registers: Leistungsbeschreibung und Beginn')
assert.deepEqual(fx.alle(root, '.tm-haken span').map((el) => fx.text(el)), REGISTER.consent.items.map((item) => item.text), 'die Texte aus dem Register')
const button = knopf(fx, root, 'bestellen')
assert.equal(fx.text(button), KNOPF_BEZAHLT)
assert.equal(fx.text(button), 'Zahlungspflichtig bestellen · 30 €')
assert.equal(button.disabled, true)
await fx.klick(haken(fx, root)[0])
assert.equal(button.disabled, true, 'ein Haken von zwei reicht nicht')
await fx.klick(haken(fx, root)[1])
assert.equal(button.disabled, false)
await fx.klick(button)
assert.deepEqual(posts(fx), [{
method: 'POST',
url: '/api/funktionen/zeiten/checkout',
body: { projectId: 'p1', zustimmung: { version: REGISTER.consent.version, haken: ['leistung', 'beginn'] } },
}])
assert.equal(fx.window.location.href, STRIPE, 'Weiterleitung zu Stripe')
assert.equal(button.disabled, true, 'die Seite wird verlassen, der Knopf bleibt gesperrt')
assert.equal(fx.fetchLog.length, 2, 'nach der Weiterleitung keine weitere Abfrage')
}))
test('Bestellen: ohne Abo, bei vorgemerkter Kündigung und ohne Grund erscheint die Bestellseite; mit Preis aus dem Angebot', mitDom({}, async (fx) => {
for (const grund of ['kein_abo', 'gekuendigt', null]) {
fx.antworten.push(liste([projekt()], angebot(false, grund)))
const root = await oeffne(fx)
assert.ok(fx.finde(root, '[data-variante="bestellen"]'), String(grund))
}
}))
test('Bestellen: eine Adresse, die nicht https ist, wird nie angesteuert', mitDom({}, async (fx) => {
for (const url of ['javascript:alert(1)', 'http://stripe.example/x', '/rechtliches/x.html', '//evil.example/x', '', undefined, 5]) {
fx.antworten.push(liste([projekt()], angebot(false, 'kein_abo')), { status: 200, body: { url } })
fx.window.location.href = 'http://localhost/'
const root = await oeffne(fx)
await alleAnhaken(fx, root)
await fx.klick(knopf(fx, root, 'bestellen'))
assert.equal(fx.window.location.href, 'http://localhost/', `Weiterleitung zu ${String(url)}`)
assert.match(meldung(fx, root), /Zahlungsseite konnte nicht geöffnet werden/)
assert.equal(knopf(fx, root, 'bestellen').disabled, false, 'der Kunde kann es noch einmal versuchen')
}
}))
test('Bestellen: ohne Belehrung oder ohne Zustimmung wird nie bestellt (R32), das Einschalten braucht beides nicht', mitDom({}, async (fx) => {
for (const belehrung of [null, '', ' \n', undefined]) {
fx.antworten.push(liste([projekt()], angebot(false, 'kein_abo', { belehrung })))
const root = await oeffne(fx)
assert.equal(knopf(fx, root, 'bestellen'), null, `Belehrung ${JSON.stringify(belehrung)}: kein Bestellknopf`)
assert.equal(haken(fx, root).length, 0)
assert.match(fx.text(root), /konnte gerade nicht geladen werden/)
}
// Zustimmung des Registers fehlt oder ist beschädigt
for (const features of [{}, { zeiten: { consent: null } }, { zeiten: { consent: { version: 'v', items: [] } } }]) {
fx.antworten.push(liste([projekt()], angebot(false, 'kein_abo'), { features }))
const root = await oeffne(fx)
assert.equal(knopf(fx, root, 'bestellen'), null, JSON.stringify(features))
}
// Einschalten ohne Belehrung: geht (kein Widerrufsverlangen)
fx.antworten.push(liste([projekt()], angebot(true, null, { belehrung: null })))
const root = await oeffne(fx)
assert.ok(knopf(fx, root, 'einschalten'))
}))
test('409 inklusive beim Bestellen ⇒ wechselt zur Einschalt-Ansicht', mitDom({}, async (fx) => {
const text = 'Im Wartungs-Abo bereits enthalten – einfach einschalten.'
fx.antworten.push(liste([projekt()], angebot(false, 'kein_abo')), fehler(409, text, 'inklusive'), liste([projekt()], angebot(true)))
const root = await oeffne(fx)
await alleAnhaken(fx, root)
await fx.klick(knopf(fx, root, 'bestellen'))
assert.deepEqual(fx.fetchLog.map((a) => `${a.method} ${a.url}`), ['GET /api/funktionen', 'POST /api/funktionen/zeiten/checkout', 'GET /api/funktionen'])
assert.equal(fx.finde(root, '[data-variante="bestellen"]'), null)
assert.ok(fx.finde(root, '[data-variante="einschalten"]'), 'die Einschaltseite')
assert.equal(fx.text(knopf(fx, root, 'einschalten')), 'Einschalten')
assert.equal(knopf(fx, root, 'einschalten').disabled, true, 'die Zustimmung des Gratisweges ist eine andere, der Haken muss neu gesetzt werden')
assert.equal(fx.text(fx.finde(root, '[data-meldung="ansicht"]')), text)
assert.equal(fx.finde(root, '.tm-rechtstext'), null)
}))
test('409 fassung_veraltet: die Seite zeigt nach dem Neuladen die neue Fassung mit leeren Haken', mitDom({}, async (fx) => {
const neu = oeffentlicheAnsicht('zeiten')
neu.consent = { ...neu.consent, version: '2099-01-01', items: neu.consent.items.map((item, i) => (i === 0 ? { ...item, text: 'Neuer Wortlaut des ersten Hakens.' } : item)) }
fx.antworten.push(
liste([projekt()], angebot(false, 'kein_abo')),
fehler(409, 'Die Erklärungen wurden inzwischen geändert. Bitte lies sie noch einmal und stimme neu zu.', 'fassung_veraltet'),
liste([projekt()], angebot(false, 'kein_abo'), { features: { zeiten: neu } }),
{ status: 200, body: { url: STRIPE } })
const root = await oeffne(fx)
await alleAnhaken(fx, root)
await fx.klick(knopf(fx, root, 'bestellen'))
assert.equal(fx.text(fx.alle(root, '.tm-haken span')[0]), 'Neuer Wortlaut des ersten Hakens.')
assert.equal(haken(fx, root).every((feld) => !feld.checked), true)
assert.match(fx.text(fx.finde(root, '[data-meldung="ansicht"]')), /inzwischen geändert/)
await alleAnhaken(fx, root)
await fx.klick(knopf(fx, root, 'bestellen'))
assert.equal(posts(fx).at(-1).body.zustimmung.version, '2099-01-01', 'gesendet wird die Fassung, die die Seite zeigt')
}))
test('Abo noch nicht aktiv (R20): Hinweis „sobald dein Abo aktiv ist“, kein Knopf, kein Haken; Abo nicht lesbar: Hinweis mit „Noch einmal versuchen“', mitDom({}, async (fx) => {
fx.antworten.push(liste([projekt()], angebot(false, 'nicht_aktiv')))
let root = await oeffne(fx)
assert.match(fx.text(root), /einschaltbar, sobald dein Abo aktiv ist/)
assert.equal(fx.alle(root, 'button').length, 0)
assert.equal(haken(fx, root).length, 0)
assert.doesNotMatch(fx.text(root), /30 €/)
fx.antworten.push(liste([projekt()], angebot(null, null)), liste([projekt()], angebot(true)))
root = await oeffne(fx)
assert.match(fx.text(root), /Gerade nicht prüfbar – bitte später erneut/)
assert.equal(haken(fx, root).length, 0)
const nochmal = fx.alle(root, 'button').find((b) => fx.text(b) === 'Noch einmal versuchen')
assert.ok(nochmal)
await fx.klick(nochmal)
assert.ok(knopf(fx, root, 'einschalten'), 'nach dem erneuten Versuch der Einschaltweg')
}))
test('Kundenansicht: Einschalten/Bestellen gesperrt mit Hinweis', mitDom({}, async (fx) => {
fx.window.KbView = { id: () => 'ansicht-1' }
fx.antworten.push(liste([projekt()], angebot(true)))
let root = await oeffne(fx)
await alleAnhaken(fx, root)
assert.equal(knopf(fx, root, 'einschalten').disabled, true, 'auch mit Haken')
assert.equal(fx.text(fx.finde(root, '[data-hinweis="bestellen"]')), 'In der Kundenansicht kann nicht eingeschaltet werden.')
await fx.klick(knopf(fx, root, 'einschalten'))
fx.antworten.push(liste([projekt()], angebot(false, 'kein_abo')))
root = await oeffne(fx)
await alleAnhaken(fx, root)
assert.equal(knopf(fx, root, 'bestellen').disabled, true)
assert.equal(fx.text(fx.finde(root, '[data-hinweis="bestellen"]')), 'In der Kundenansicht kann nicht bestellt werden.')
await fx.klick(knopf(fx, root, 'bestellen'))
// Behalten und Zahlung fortsetzen in der Karte
fx.antworten.push(liste([projekt('p1', 'active', { aboEndeAt: '2026-10-01T10:00:00.000Z' }, { zeitenLeer: false })], angebot(false, 'kein_abo')))
root = await oeffne(fx)
assert.equal(knopf(fx, root, 'behalten').disabled, true)
assert.match(fx.text(root), /In der Kundenansicht kann nicht bestellt werden/)
fx.antworten.push(liste([projekt('p1', 'ordered', { unlockedBy: null })], angebot(false, 'kein_abo')))
root = await oeffne(fx)
assert.equal(knopf(fx, root, 'fortsetzen').disabled, true)
assert.equal(posts(fx).length, 0, 'in der Kundenansicht geht keine Anfrage hinaus')
// ohne Kundenansicht ist nichts gesperrt
fx.window.KbView = { id: () => '' }
fx.antworten.push(liste([projekt()], angebot(true)))
root = await oeffne(fx)
await alleAnhaken(fx, root)
assert.equal(knopf(fx, root, 'einschalten').disabled, false)
}))
// ---------------------------------------------------------------------------------------------------------------- Rückkehr
test('Rückkehr #zeiten?bestellt=1 und ?behalten=1 zeigt Danke-Karte', mitDom({}, async (fx) => {
// Bestellung: der Webhook hat die Zahlung noch nicht gebucht (ordered)
fx.antworten.push(liste([projekt('p1'), projekt('p2', 'ordered', { unlockedBy: null })], angebot(false, 'kein_abo')))
let root = await oeffne(fx, { projectId: '', hash: '#zeiten?bestellt=1&projekt=p2' })
let karte = fx.finde(root, '.zt-status')
assert.equal(karte.getAttribute('data-state'), 'ordered')
assert.equal(fx.text(fx.finde(karte, '[data-danke="bestellt"]')), 'Danke! Sobald die Zahlung da ist, legen wir los.')
assert.equal(fx.window.location.hash, '#zeiten', 'bestellt=1 und projekt stehen nicht mehr in der Adresse')
assert.equal(fx.finde(root, '#zeiten-project').value, 'p2', 'das Projekt der Zahlung ist gewählt')
// nächster Besuch ohne Flag: kein Dank mehr
fx.antworten.push(liste([projekt('p1'), projekt('p2', 'ordered', { unlockedBy: null })], angebot(false, 'kein_abo')))
root = await oeffne(fx, { projectId: '' })
assert.equal(fx.finde(root, '[data-danke]'), null)
// Bestellung schon gebucht (setup)
fx.antworten.push(liste([projekt('p1', 'setup', { unlockedBy: 'kauf', einbau: 'angestossen' }, { zeitenLeer: false })], angebot(false, 'kein_abo')))
root = await oeffne(fx, { hash: '#zeiten?bestellt=1&projekt=p1' })
assert.equal(fx.text(fx.finde(root, '[data-danke="bestellt"]')), 'Danke! Deine Zahlung ist bei uns angekommen.')
// Behalten: die Zahlung ist unterwegs (aboEndeAt noch gesetzt) bzw. gebucht (aboEndeAt leer, unlockedBy kauf)
fx.antworten.push(liste([projekt('p1', 'active', { aboEndeAt: '2026-10-01T10:00:00.000Z' }, { zeitenLeer: false })], angebot(false, 'kein_abo')))
root = await oeffne(fx, { hash: '#zeiten?behalten=1&projekt=p1' })
assert.match(fx.text(fx.finde(root, '[data-danke="behalten"]')), /^Danke! Sobald die Zahlung da ist, behältst du Öffnungszeiten live\./)
fx.antworten.push(liste([projekt('p1', 'active', { unlockedBy: 'kauf' }, { zeitenLeer: false })], angebot(false, 'kein_abo')))
root = await oeffne(fx, { hash: '#zeiten?behalten=1&projekt=p1' })
assert.equal(fx.text(fx.finde(root, '[data-danke="behalten"]')), 'Danke! Du behältst Öffnungszeiten live.')
assert.equal(fx.window.location.hash, '#zeiten')
// Abbruch in Stripe (#zeiten?projekt=p2): das Projekt ist gewählt, kein Dank
fx.antworten.push(liste([projekt('p1'), projekt('p2')], angebot(false, 'kein_abo')))
root = await oeffne(fx, { projectId: '', hash: '#zeiten?projekt=p2' })
assert.equal(fx.finde(root, '[data-danke]'), null)
assert.equal(fx.finde(root, '#zeiten-project').value, 'p2')
// ein Projekt, das es nicht (mehr) gibt: kein Dank für ein anderes
fx.antworten.push(liste([projekt('p1', 'ordered', { unlockedBy: null })], angebot(false, 'kein_abo')))
root = await oeffne(fx, { projectId: '', hash: '#zeiten?bestellt=1&projekt=unbekannt' })
assert.equal(fx.finde(root, '[data-danke]'), null)
// andere Hashes werden nicht angefasst
fx.antworten.push(liste([projekt('p1')], angebot(true)))
await oeffne(fx, { hash: '#zeitenplan' })
assert.equal(fx.window.location.hash, '#zeitenplan')
}))
// ---------------------------------------------------------------------------------------------------------------- Behalten
const ABO_ENDE = { aboEndeAt: '2026-10-01T10:00:00.000Z', einbau: 'angestossen' }
test('Behalten: Hinweis mit Datum in der Karte, Behalten-Seite mit Belehrung, POST /api/funktionen/zeiten/behalten, „Zurück“ zeigt die Karte wieder', mitDom({}, async (fx) => {
fx.antworten.push(liste([projekt('p1', 'active', ABO_ENDE, { zeitenLeer: false })], angebot(false, 'kein_abo')), { status: 200, body: { url: STRIPE } })
const root = await oeffne(fx)
const karte = fx.finde(root, '.zt-status')
assert.equal(karte.getAttribute('data-state'), 'active')
assert.match(fx.text(fx.finde(karte, '[data-hinweis="behalten"]')), /Dein Wartungs-Abo ist beendet – behalte die Funktion bis 31\.10\.2026 für einmalig 30 €\./)
const editor = fx.finde(root, '.zt-editor')
assert.ok(editor, 'der Platz des Editors steht unter der Karte')
await fx.klick(knopf(fx, root, 'behalten'))
const seite = fx.finde(root, '[data-variante="behalten"]')
assert.ok(seite)
assert.equal(fx.finde(root, '.zt-status'), null, 'die Seite ersetzt die Karte')
assert.equal(editor.hidden, true, 'der Editor bleibt, nur versteckt (nichts geht verloren)')
assert.equal(fx.finde(root, '.tm-rechtstext').textContent, BELEHRUNG)
assert.match(fx.text(seite), /ohne neuen Einbau/)
assert.equal(fx.alle(root, '.tm-bestellen-preis h4').map((el) => fx.text(el))[0], 'Preis')
assert.doesNotMatch(fx.text(fx.finde(root, '.tm-bestellen-preis')), /Einrichtung:/, 'beim Behalten gibt es keinen neuen Einbau')
assert.equal(fx.text(knopf(fx, root, 'behalten')), KNOPF_BEZAHLT)
// zurück: die Karte ist wieder da, der Editor sichtbar
await fx.klick(knopf(fx, root, 'zurueck'))
assert.ok(fx.finde(root, '.zt-status'))
assert.equal(editor.hidden, false)
assert.equal(fx.finde(root, '.zt-editor'), editor, 'derselbe Editorplatz, nicht neu gebaut')
await fx.klick(knopf(fx, root, 'behalten'))
await alleAnhaken(fx, root)
await fx.klick(knopf(fx, root, 'behalten'))
assert.deepEqual(posts(fx), [{
method: 'POST',
url: '/api/funktionen/zeiten/behalten',
body: { projectId: 'p1', zustimmung: { version: REGISTER.consent.version, haken: ['leistung', 'beginn'] } },
}])
assert.equal(fx.window.location.href, STRIPE)
}))
test('Behalten: Frist ist der Berliner Tag des Abo-Endes plus 30 Tage; läuft das Abo wieder, gibt es keinen Behalten-Knopf', mitDom({}, async (fx) => {
assert.equal(behaltenFrist('2026-10-01T10:00:00.000Z'), '31.10.2026')
assert.equal(behaltenFrist('2026-10-01T23:30:00.000Z'), '01.11.2026', 'in Berlin schon der 2. Oktober')
assert.equal(behaltenFrist('2026-10-01T10:00:00.000+00:00'), '31.10.2026')
assert.equal(behaltenFrist('2026-03-01T10:00:00.000Z'), '31.03.2026')
assert.equal(behaltenFrist('kein-datum'), '')
assert.equal(behaltenFrist(null), '')
assert.equal(tagText('2026-11-04'), '04.11.2026')
assert.equal(tagText('4.11.2026'), '')
fx.antworten.push(liste([projekt('p1', 'active', ABO_ENDE, { zeitenLeer: false })], angebot(true)))
let root = await oeffne(fx)
assert.equal(knopf(fx, root, 'behalten'), null, 'das Abo läuft wieder: nichts zu bezahlen')
assert.doesNotMatch(fx.text(root), /behalte die Funktion/)
fx.antworten.push(liste([projekt('p1', 'active', ABO_ENDE, { zeitenLeer: false })], angebot(null, null)))
root = await oeffne(fx)
assert.equal(knopf(fx, root, 'behalten'), null, 'Abo nicht lesbar: kein Bezahlknopf auf Verdacht')
}))
test('Behalten aus der Kachel (dataset.wunschWeg): die Seite öffnet direkt, das Projekt der Kachel ist gewählt; ein Weg ohne Anlass wird ignoriert', mitDom({}, async (fx) => {
fx.antworten.push(liste([projekt('p1'), projekt('p2', 'active', ABO_ENDE, { zeitenLeer: false })], angebot(false, 'kein_abo')))
const root = await oeffne(fx, { projectId: '', dataset: { wunschProjekt: 'p2', wunschWeg: 'behalten' } })
assert.ok(fx.finde(root, '[data-variante="behalten"]'))
assert.equal(fx.finde(root, '#zeiten-project').value, 'p2')
assert.equal(root.dataset.wunschProjekt, undefined, 'die Wünsche werden einmal gelesen')
assert.equal(root.dataset.wunschWeg, undefined)
// kein Abo-Ende: kein Behalten, sondern die Karte
fx.antworten.push(liste([projekt('p1', 'active', {}, { zeitenLeer: false })], angebot(false, 'kein_abo')))
const ohne = await oeffne(fx, { dataset: { wunschWeg: 'behalten' } })
assert.equal(fx.finde(ohne, '[data-variante]'), null)
assert.ok(fx.finde(ohne, '.zt-status'))
}))
test('Behalten nach dem Abschalten (abo_ende) mit Daten: „Für 30 € wieder einschalten“; ohne Daten, nach der Löschfrist oder durch das Team nicht', mitDom({}, async (fx) => {
const abgeschaltet = (feature, extra) => projekt('p1', 'disabled', { disabledReason: 'abo_ende', disabledAt: '2026-10-04T10:00:00.000Z', ...feature }, extra)
fx.antworten.push(liste([abgeschaltet({ loeschungAm: '2999-01-01' }, { zeitenLeer: false })], angebot(false, 'kein_abo')), { status: 200, body: { url: STRIPE } })
let root = await oeffne(fx)
const karte = fx.finde(root, '.zt-status')
assert.equal(karte.getAttribute('data-state'), 'disabled')
assert.match(fx.text(karte), /Öffnungszeiten live ist abgeschaltet, weil dein Wartungs-Abo beendet ist/)
assert.match(fx.text(fx.finde(karte, '[data-hinweis="wieder-einschalten"]')), /Deine Eingaben bleiben bis 01\.01\.2999 gespeichert\. Für einmalig 30 € schaltest du die Funktion wieder ein\./)
assert.equal(fx.text(knopf(fx, root, 'behalten')), 'Für 30 € wieder einschalten')
assert.ok(fx.finde(root, '.zt-editor'), 'die Eingaben bleiben lesbar (Platz des Editors)')
await fx.klick(knopf(fx, root, 'behalten'))
assert.match(fx.text(fx.finde(root, '.zt-bestellen > p')), /abgeschaltet, deine Eingaben sind noch gespeichert\. Für einmalig 30 € schaltest du die Funktion wieder ein\./)
await alleAnhaken(fx, root)
await fx.klick(knopf(fx, root, 'behalten'))
assert.equal(posts(fx)[0].url, '/api/funktionen/zeiten/behalten')
for (const [name, p] of [
['keine Daten', abgeschaltet({ loeschungAm: '2999-01-01' }, { zeitenLeer: true })],
['Löschfrist vorbei', abgeschaltet({ loeschungAm: '2000-01-01' }, { zeitenLeer: false })],
['vom Team', projekt('p1', 'disabled', { disabledReason: 'team' }, { zeitenLeer: false })],
]) {
fx.antworten.push(liste([p], angebot(false, 'kein_abo')))
root = await oeffne(fx)
assert.equal(knopf(fx, root, 'behalten'), null, name)
}
fx.antworten.push(liste([projekt('p1', 'disabled', { disabledReason: 'team' }, { zeitenLeer: false })], angebot(false, 'kein_abo')))
root = await oeffne(fx)
assert.match(fx.text(root), /Öffnungszeiten live wurde für diese Website abgeschaltet\. Schreib uns im Chat, wenn du die Funktion wieder möchtest\./)
// das Abo läuft wieder: der Abgleich schaltet selbst ein
fx.antworten.push(liste([abgeschaltet({ loeschungAm: '2999-01-01' }, { zeitenLeer: false })], angebot(true)))
root = await oeffne(fx)
assert.equal(knopf(fx, root, 'behalten'), null)
assert.match(fx.text(root), /Dein Wartungs-Abo läuft wieder/)
}))
test('Behalten: 409 „Abo läuft wieder“ zeichnet die Ansicht nach der neuen Liste (kein Behalten mehr) mit der Meldung des Servers', mitDom({}, async (fx) => {
const text = 'Dein Wartungs-Abo läuft wieder – Öffnungszeiten live ist darin enthalten.'
fx.antworten.push(
liste([projekt('p1', 'active', ABO_ENDE, { zeitenLeer: false })], angebot(false, 'kein_abo')),
fehler(409, text),
liste([projekt('p1', 'active', {}, { zeitenLeer: false })], angebot(true)))
const root = await oeffne(fx, { dataset: { wunschWeg: 'behalten' } })
await alleAnhaken(fx, root)
await fx.klick(knopf(fx, root, 'behalten'))
assert.equal(fx.text(fx.finde(root, '[data-meldung="ansicht"]')), text)
assert.equal(fx.finde(root, '[data-variante]'), null)
assert.equal(knopf(fx, root, 'behalten'), null)
assert.ok(fx.finde(root, '.zt-status'))
}))
// Fix Runde 1: Behalten (30 €) gibt es nur ohne Abo (inklusive false UND grund kein_abo). nicht_aktiv und gekuendigt heissen, dass das Abo läuft (R20/R25, Z12).
test('behaltenLage: bezahlen nur bei inklusive false und kein_abo', () => {
assert.equal(behaltenLage(angebot(false, 'kein_abo')), 'bezahlen')
assert.equal(behaltenLage(angebot(false, 'nicht_aktiv')), 'abo')
assert.equal(behaltenLage(angebot(false, 'gekuendigt')), 'abo')
assert.equal(behaltenLage(angebot(true)), 'inklusive')
for (const unklar of [angebot(null, null), angebot(false, null), angebot(false, 'unbekannt'), { verfuegbar: true }, undefined, null]) assert.equal(behaltenLage(unklar), 'unklar', JSON.stringify(unklar))
})
test('Behalten bei laufendem Abo (nicht_aktiv, gekuendigt): die Karte sagt „Abo läuft wieder“, es gibt keinen Behalten-Knopf und keine Behalten-Seite', mitDom({}, async (fx) => {
for (const grund of ['nicht_aktiv', 'gekuendigt']) {
// aktiv mit erkanntem Abo-Ende, auch wenn die Kachel „Für 30 € behalten“ (wunschWeg) schicken wollte
fx.antworten.push(liste([projekt('p1', 'active', ABO_ENDE, { zeitenLeer: false })], angebot(false, grund)))
let root = await oeffne(fx, { dataset: { wunschWeg: 'behalten' } })
assert.equal(knopf(fx, root, 'behalten'), null, grund)
assert.equal(fx.finde(root, '[data-variante]'), null, `${grund}: keine Behalten-Seite`)
assert.match(fx.text(fx.finde(root, '[data-hinweis="abo-laeuft"]')), /Dein Wartungs-Abo läuft wieder – Öffnungszeiten live ist darin enthalten\./, grund)
assert.doesNotMatch(fx.text(root), /ist beendet|behalte die Funktion|Für 30 €/, grund)
assert.equal(fx.alle(root, 'input[type="checkbox"]').length, 0)
// abgeschaltet (abo_ende) mit Daten
fx.antworten.push(liste([projekt('p1', 'disabled', { disabledReason: 'abo_ende', disabledAt: '2026-10-04T10:00:00.000Z', loeschungAm: '2999-01-01' }, { zeitenLeer: false })], angebot(false, grund)))
root = await oeffne(fx, { dataset: { wunschWeg: 'behalten' } })
assert.equal(knopf(fx, root, 'behalten'), null, `${grund}: abgeschaltet`)
assert.equal(fx.finde(root, '[data-variante]'), null)
assert.match(fx.text(fx.finde(root, '[data-hinweis="abo-laeuft"]')), /Dein Wartungs-Abo läuft wieder/, grund)
assert.doesNotMatch(fx.text(root), /Für 30 €|schaltest du die Funktion wieder ein/, grund)
}
// unbekannter Grund (false, null): kein Bezahlknopf auf Verdacht
for (const an of [angebot(false, null), angebot(false, 'unbekannt')]) {
fx.antworten.push(liste([projekt('p1', 'active', ABO_ENDE, { zeitenLeer: false })], an))
const root = await oeffne(fx, { dataset: { wunschWeg: 'behalten' } })
assert.equal(knopf(fx, root, 'behalten'), null)
assert.equal(fx.finde(root, '[data-variante]'), null)
fx.antworten.push(liste([projekt('p1', 'disabled', { disabledReason: 'abo_ende', loeschungAm: '2999-01-01' }, { zeitenLeer: false })], an))
const aus = await oeffne(fx)
assert.equal(knopf(fx, aus, 'behalten'), null)
assert.ok(fx.finde(aus, '[data-hinweis="nicht-pruefbar"]'))
}
// Gegenprobe: ohne Abo (kein_abo) bleibt Behalten
fx.antworten.push(liste([projekt('p1', 'active', ABO_ENDE, { zeitenLeer: false })], angebot(false, 'kein_abo')))
const ohne = await oeffne(fx, { dataset: { wunschWeg: 'behalten' } })
assert.ok(fx.finde(ohne, '[data-variante="behalten"]'))
}))
test('Behalten-Seite direkt gerufen (renderZeitenBestellen, modus behalten): nur bei kein_abo die bezahlte Seite, sonst Hinweis ohne Haken und Knopf', mitDom({}, (fx) => {
const ctx = { projectId: 'p1', features: { zeiten: oeffentlicheAnsicht('zeiten') }, ustHinweis: config.ustHinweis, kundenansicht: false }
const zeige = (an) => {
const wurzel = h('div')
renderZeitenBestellen(wurzel, { ctx, angebot: an, projekt: projekt('p1', 'active', ABO_ENDE), modus: 'behalten' })
return wurzel
}
assert.ok(fx.finde(zeige(angebot(false, 'kein_abo')), '[data-variante="behalten"]'))
for (const grund of ['nicht_aktiv', 'gekuendigt']) {
const wurzel = zeige(angebot(false, grund))
assert.match(fx.text(wurzel), /Dein Wartungs-Abo läuft wieder/, grund)
assert.equal(fx.alle(wurzel, 'input,button').length, 0, grund)
}
for (const grund of [null, 'unbekannt']) {
const wurzel = zeige(angebot(false, grund))
assert.match(fx.text(wurzel), /Gerade nicht prüfbar – bitte später erneut/, String(grund))
assert.equal(fx.alle(wurzel, 'input').length, 0)
assert.equal(fx.finde(wurzel, '[data-variante]'), null)
}
}))
test('renderZeitenBestellen im Modus behalten: Abo läuft wieder ⇒ Hinweis statt Seite; Abo nicht lesbar ⇒ „Gerade nicht prüfbar“', mitDom({}, (fx) => {
const ctx = { projectId: 'p1', features: { zeiten: oeffentlicheAnsicht('zeiten') }, ustHinweis: config.ustHinweis, kundenansicht: false }
const wurzel = h('div')
renderZeitenBestellen(wurzel, { ctx, angebot: angebot(true), projekt: projekt('p1', 'active', ABO_ENDE), modus: 'behalten' })
assert.match(fx.text(wurzel), /Dein Wartungs-Abo läuft wieder/)
assert.equal(fx.alle(wurzel, 'input').length, 0)
renderZeitenBestellen(wurzel, { ctx, angebot: angebot(null), projekt: projekt('p1', 'active', ABO_ENDE), modus: 'behalten' })
assert.match(fx.text(wurzel), /Gerade nicht prüfbar – bitte später erneut/)
}))
// ---------------------------------------------------------------------------------------------------------------- Statuskarte
test('Statuskarte: Zahlung offen, In Einrichtung (je nach einbau), Aktiv, Abgeschaltet', mitDom({}, (fx) => {
const karte = (state, feature = {}, extra = {}, an = angebot(true)) => zeitenStatusKarte({ projekt: projekt('p1', state, feature, extra), feature: zeitenFeature(state, feature), angebot: an, kundenansicht: false })
const ordered = karte('ordered', { unlockedBy: null })
assert.equal(ordered.getAttribute('data-state'), 'ordered')
assert.match(fx.text(ordered), /Zahlung offen/)
assert.match(fx.text(ordered), /Zahlung wird bestätigt …/)
assert.match(fx.text(ordered), /SEPA/)
assert.ok(fx.finde(ordered, '[data-aktion="fortsetzen"]'))
const angestossen = karte('setup', { einbau: 'angestossen' }, { zeitenLeer: false })
assert.match(fx.text(angestossen), /In Einrichtung/)
assert.match(fx.text(angestossen), /Hinweis- und Kontakt-Leiste erscheinen nach dem nächsten Veröffentlichen deiner Website \(meist in wenigen Minuten\)\./)
assert.equal(fx.finde(angestossen, '[data-hinweis="zeiten-eintragen"]'), null)
assert.equal(fx.finde(angestossen, '[data-aktion="fortsetzen"]'), null)
const manuell = karte('setup', { einbau: 'manuell', ticketWoid: '4711', orderedAt: '2026-10-04T10:00:00.000Z' }, { zeitenLeer: true })
assert.match(fx.text(manuell), /Unser Team baut die Funktion ein\./)
assert.doesNotMatch(fx.text(manuell), /nach dem nächsten Veröffentlichen/)
assert.match(fx.text(manuell), /Ticketnummer#4711/)
assert.match(fx.text(manuell), /Bestellt am04\.10\.2026/)
assert.match(fx.text(fx.finde(manuell, '[data-hinweis="zeiten-eintragen"]')), /Trag jetzt deine Zeiten ein – erst dann kann das Team die Tabelle einbauen\./)
const rechnung = zeitenStatusKarte({ projekt: projekt('p1', 'setup'), feature: zeitenFeature('setup', { invoiceUrl: 'https://invoice.stripe.test/i/1' }), angebot: angebot(false, 'kein_abo') })
assert.equal(fx.finde(rechnung, 'a').getAttribute('href'), 'https://invoice.stripe.test/i/1')
const boese = zeitenStatusKarte({ projekt: projekt('p1', 'setup'), feature: zeitenFeature('setup', { invoiceUrl: 'javascript:alert(1)' }), angebot: angebot(false, 'kein_abo') })
assert.equal(fx.finde(boese, 'a'), null, 'unsichere Adressen werden nie Links')
const aktiv = karte('active', {}, { zeitenLeer: false })
assert.match(fx.text(aktiv), /Aktiv/)
assert.match(fx.text(aktiv), /Öffnungszeiten live ist aktiv/)
assert.equal(fx.finde(aktiv, '[data-hinweis="zeiten-eintragen"]'), null)
assert.match(fx.text(fx.finde(karte('active', {}, { zeitenLeer: true }), '[data-hinweis="zeiten-eintragen"]')), /Trag jetzt deine Zeiten ein\./)
const aus = karte('disabled', { disabledReason: 'team' }, { zeitenLeer: false })
assert.match(fx.text(aus), /Abgeschaltet/)
assert.match(fx.text(aus), /Schreib uns im Chat/)
assert.ok(fx.finde(aus, '[data-view="chat"]'))
}))
test('Statuskarte: Zahlung fortsetzen führt zur offenen Zahlungsseite; ohne offene Sitzung zeigt die Ansicht wieder die Bestellseite', mitDom({}, async (fx) => {
fx.antworten.push(liste([projekt('p1', 'ordered', { unlockedBy: null })], angebot(false, 'kein_abo')), { status: 200, body: { url: STRIPE } })
let root = await oeffne(fx)
await fx.klick(knopf(fx, root, 'fortsetzen'))
assert.deepEqual(posts(fx), [{ method: 'POST', url: '/api/funktionen/zeiten/checkout', body: { projectId: 'p1', fortsetzen: true } }])
assert.equal(fx.window.location.href, STRIPE)
// keine offene Zahlungsseite mehr (400 zustimmung_fehlt): die Bestellseite mit der Meldung des Servers; Bestellen legt eine neue Sitzung an
fx.window.location.href = 'http://localhost/'
fx.antworten.push(
liste([projekt('p1', 'ordered', { unlockedBy: null })], angebot(false, 'kein_abo')),
{ status: 400, body: { error: 'Bitte stimme der Erklärung zu, um zu bestellen.', code: 'zustimmung_fehlt' } },
{ status: 200, body: { url: STRIPE } })
root = await oeffne(fx)
await fx.klick(knopf(fx, root, 'fortsetzen'))
assert.ok(fx.finde(root, '[data-variante="bestellen"]'))
assert.match(fx.text(fx.finde(root, '[data-meldung="ansicht"]')), /stimme der Erklärung zu/)
await alleAnhaken(fx, root)
await fx.klick(knopf(fx, root, 'bestellen'))
assert.equal(fx.window.location.href, STRIPE)
// andere Fehler stehen am Knopf, der Knopf ist wieder frei
fx.antworten.push(liste([projekt('p1', 'ordered', { unlockedBy: null })], angebot(false, 'kein_abo')), fehler(500, 'Interner Serverfehler'))
root = await oeffne(fx)
await fx.klick(knopf(fx, root, 'fortsetzen'))
assert.equal(fx.text(fx.finde(root, '[data-meldung="karte"]')), 'Interner Serverfehler')
assert.equal(knopf(fx, root, 'fortsetzen').disabled, false)
}))
// ---------------------------------------------------------------------------------------------------------------- Ansicht
test('Projektauswahl: bei mehreren Websites eine Auswahl; der Wunsch der Kachel gilt einmal; ein Wechsel zeichnet das andere Projekt', mitDom({}, async (fx) => {
const projekte = [projekt('p1', 'active', {}, { zeitenLeer: false }), projekt('p2'), projekt('p3', 'setup')]
fx.antworten.push(liste(projekte, angebot(true)))
let root = await oeffne(fx, { projectId: '', dataset: { wunschProjekt: 'p2' } })
const auswahl = fx.finde(root, '#zeiten-project')
assert.deepEqual(fx.alle(auswahl, 'option').map((o) => o.getAttribute('value')), ['p1', 'p2', 'p3'])
assert.equal(auswahl.value, 'p2')
assert.ok(fx.finde(root, '[data-variante="einschalten"]'), 'p2 ist aus')
await fx.setze(auswahl, 'p3')
assert.equal(fx.finde(root, '[data-variante]'), null)
assert.equal(fx.finde(root, '.zt-status').getAttribute('data-state'), 'setup')
await fx.setze(auswahl, 'p1')
assert.equal(fx.finde(root, '.zt-status').getAttribute('data-state'), 'active')
// ohne Wunsch: die gemerkte Auswahl, bei neuer Sitzung aktiv vor Einrichtung vor aus
fx.antworten.push(liste([projekt('p1'), projekt('p2', 'setup'), projekt('p3', 'active', {}, { zeitenLeer: false })], angebot(true)))
root = await oeffne(fx, { projectId: '', dataset: {} })
assert.equal(fx.finde(root, '#zeiten-project').value, 'p1', 'die Auswahl von eben bleibt')
fx.antworten.push(liste([projekt('p9'), projekt('p8', 'setup'), projekt('p7', 'active', {}, { zeitenLeer: false })], angebot(true)))
root = await oeffne(fx, { projectId: '' })
assert.equal(fx.finde(root, '#zeiten-project').value, 'p7', 'ein unbekanntes Projekt: das aktive')
// genau eine Website: keine Auswahl
fx.antworten.push(liste([projekt('p1')], angebot(true)))
root = await oeffne(fx)
assert.equal(fx.finde(root, '#zeiten-project'), null)
}))
test('Ansicht: nicht verfügbar und keine Website', mitDom({}, async (fx) => {
fx.antworten.push(liste([projekt()], { verfuegbar: false }))
let root = await oeffne(fx)
assert.match(fx.text(root), /Öffnungszeiten live ist noch nicht verfügbar/)
assert.equal(fx.alle(root, 'input,button').length, 0)
fx.antworten.push(liste([projekt()], undefined))
root = await oeffne(fx)
assert.match(fx.text(root), /noch nicht verfügbar/)
fx.antworten.push(liste([], angebot(true)))
root = await oeffne(fx)
assert.match(fx.text(root), /Noch keine Website-Projekte zugewiesen/)
// ein Fehler der Liste kommt bei app.js an (zeigt ihn in der Ansicht)
fx.antworten.push(fehler(500, 'Die Funktionen konnten gerade nicht geladen werden. Bitte lade die Seite gleich noch einmal.'))
await assert.rejects(oeffne(fx), /konnten gerade nicht geladen werden/)
}))
test('Ansicht: der Editorplatz steht nur in setup, active und (mit Daten) disabled; nie in off und ordered', mitDom({}, async (fx) => {
const platz = async (p) => {
fx.antworten.push(liste([p], angebot(false, 'kein_abo')))
return fx.finde(await oeffne(fx), '.zt-editor')
}
assert.equal(await platz(projekt('p1', 'off')), null)
assert.equal(await platz(projekt('p1', 'ordered', { unlockedBy: null })), null)
assert.ok(await platz(projekt('p1', 'setup')))
assert.ok(await platz(projekt('p1', 'active')))
assert.ok(await platz(projekt('p1', 'disabled', { disabledReason: 'abo_ende' }, { zeitenLeer: false })))
assert.equal(await platz(projekt('p1', 'disabled', { disabledReason: 'team' }, { zeitenLeer: true })), null)
}))
test('app.js ruft initZeiten bei jedem Wechsel in die Ansicht: gleiches Projekt und gleicher Stand bauen den Editorplatz nicht neu auf, ein neuer Stand zeichnet nur den Kopf neu, die Rückkehr von Stripe alles', mitDom({}, async (fx) => {
const aktiv = () => liste([projekt('p1', 'active', {}, { zeitenLeer: false })], angebot(true))
fx.antworten.push(aktiv())
const root = await oeffne(fx)
await fx.ruhe() // der Editor lädt (fester Stand, siehe mitDom)
const editor = fx.finde(root, '.zt-editor')
assert.deepEqual(fx.editorAufrufe, ['/api/zeiten/p1'])
assert.ok(fx.finde(editor, 'details[data-plan]'), 'der Editor steht im Platz')
const eingabe = h('input', { type: 'text' })
eingabe.value = 'ungespeichert'
editor.append(eingabe)
// zweiter Besuch: nur die Liste wird geholt, nichts neu gezeichnet
fx.antworten.push(aktiv())
await initZeiten(root, { projectId: 'p1' })
assert.equal(fx.finde(root, '.zt-editor'), editor, 'derselbe Editorplatz')
assert.equal(eingabe.parentNode, editor)
assert.equal(eingabe.value, 'ungespeichert')
assert.equal(fx.fetchLog.length, 2)
assert.equal(fx.fetchLog.every((a) => a.method === 'GET' && a.url === '/api/funktionen'), true, 'keine Zusatzabfrage')
// die Kachel schickt dasselbe Projekt: ebenfalls kein Neuaufbau
root.dataset.wunschProjekt = 'p1'
fx.antworten.push(aktiv())
await initZeiten(root)
assert.equal(fx.finde(root, '.zt-editor'), editor)
// die Abo-Lage hat sich geändert (Behalten gibt es nur ohne Abo): die Karte muss stimmen, also wird der Kopf neu gezeichnet – der Editor bleibt mit seinen Eingaben
const kopf = fx.finde(root, '.zt-kopf')
fx.antworten.push(liste([projekt('p1', 'active', {}, { zeitenLeer: false })], angebot(false, 'kein_abo')))
await initZeiten(root)
assert.equal(fx.finde(root, '.zt-editor'), editor, 'der Editorplatz bleibt stehen')
assert.equal(eingabe.value, 'ungespeichert', 'ungespeicherte Eingaben gehen nicht verloren')
assert.notEqual(fx.finde(root, '.zt-kopf'), kopf, 'der Kopf ist neu gezeichnet')
assert.equal(root.dataset.zeitenKey.includes('kein_abo'), true, 'der neue Stand gilt')
// „Für 30 € behalten“ aus der Kachel: Behalten-Seite; der Stand hat sich geändert (aboEndeAt), der Editor bleibt auch dann
fx.antworten.push(liste([projekt('p1', 'active', ABO_ENDE, { zeitenLeer: false })], angebot(false, 'kein_abo')))
root.dataset.wunschWeg = 'behalten'
await initZeiten(root)
assert.equal(fx.finde(root, '.zt-editor'), editor)
assert.equal(editor.hidden, true, 'die Behalten-Seite versteckt den Editor')
assert.ok(fx.finde(root, '[data-variante="behalten"]'))
// gleicher neuer Stand noch einmal mit Weg: der Editor bleibt, die Seite öffnet
await fx.klick(knopf(fx, root, 'zurueck'))
assert.equal(editor.hidden, false)
fx.antworten.push(liste([projekt('p1', 'active', ABO_ENDE, { zeitenLeer: false })], angebot(false, 'kein_abo')))
root.dataset.wunschWeg = 'behalten'
await initZeiten(root)
assert.equal(fx.finde(root, '.zt-editor'), editor, 'der Editor bleibt')
assert.equal(editor.hidden, true)
assert.ok(fx.finde(root, '[data-variante="behalten"]'))
assert.deepEqual(fx.editorAufrufe, ['/api/zeiten/p1'], 'der Editor hat in der ganzen Zeit nur einmal geladen')
// Rückkehr von Stripe: immer neu
fx.antworten.push(liste([projekt('p1', 'active', ABO_ENDE, { zeitenLeer: false })], angebot(false, 'kein_abo')))
fx.window.location.hash = '#zeiten?behalten=1&projekt=p1'
await initZeiten(root)
assert.notEqual(fx.finde(root, '.zt-editor'), editor)
assert.ok(fx.finde(root, '[data-danke="behalten"]'))
assert.equal(fx.editorAufrufe.length, 2, 'ein frischer Editor lädt')
}))
test('Fremdtext (Projektname, Meldung des Servers) kommt nur als Text in die Seite', mitDom({}, async (fx) => {
const bose = '<img src=x onerror=alert(1)>'
fx.antworten.push(
liste([{ ...projekt('p1'), projectName: bose }, projekt('p2')], angebot(true)),
fehler(409, bose),
liste([{ ...projekt('p1'), projectName: bose }, projekt('p2')], angebot(true)))
const root = await oeffne(fx)
assert.equal(fx.alle(root, 'img').length, 0)
assert.ok(fx.text(fx.finde(root, '#zeiten-project')).includes(bose))
await alleAnhaken(fx, root)
await fx.klick(knopf(fx, root, 'einschalten'))
assert.equal(fx.alle(root, 'img').length, 0)
assert.equal(fx.text(fx.finde(root, '[data-meldung="ansicht"]')), bose)
}))
test('veraltete Antwort: ein Wechsel der Website während des Neuladens überschreibt die neuere Ansicht nicht', mitDom({}, async (fx) => {
const echtes = globalThis.fetch
let freigeben
const wartet = new Promise((resolve) => { freigeben = resolve })
let listen = 0
// die zweite Liste (nach dem 409) kommt erst nach der Freigabe
globalThis.fetch = async (url, optionen) => {
if (String(url) === '/api/funktionen' && (optionen?.method ?? 'GET') === 'GET' && ++listen === 2) await wartet
return echtes(url, optionen)
}
fx.antworten.push(
liste([projekt('p1'), projekt('p2')], angebot(true)),
fehler(409, 'Stand geändert'),
liste([projekt('p1'), projekt('p2')], angebot(false, 'kein_abo')))
const root = await oeffne(fx, { projectId: 'p1' })
await alleAnhaken(fx, root)
const klick = fx.klick(knopf(fx, root, 'einschalten'))
await fx.ruhe()
await fx.setze(fx.finde(root, '#zeiten-project'), 'p2') // der Kunde wechselt die Website, während die Liste noch lädt
freigeben()
await klick
assert.ok(fx.finde(root, '[data-variante="einschalten"]'), 'die späte Antwort zeichnet nichts mehr')
assert.equal(fx.finde(root, '[data-meldung="ansicht"]'), null)
assert.equal(fx.finde(root, '#zeiten-project').value, 'p2')
}))
// ---------------------------------------------------------------------------------------------------------------- Quelltext
const ohneKommentare = (quelle) => quelle.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|\s)\/\/.*$/gm, '')
test('Portal: Ansicht #zeiten ist in app.js und dashboard.html verdrahtet', () => {
const app = lesen('public/app.js')
// zeiten steht in allen drei Listen hinter dem Webshop (main zuerst, dann die Öffnungszeiten); seit dem Buchungskalender steht dessen Eintrag dazwischen
assert.match(app, /VIEW_HASHES = \{[^}]*webshop: 'webshop', buchungen: 'buchungen', zeiten: 'zeiten' \}/)
assert.match(app, /APP_PARENT = \{ news: 'funktionen', analyse: 'funktionen', termine: 'funktionen', [^}]*webshop: 'funktionen', buchungen: 'funktionen', zeiten: 'funktionen' \}/)
assert.match(app, /FEATURE_LABELS = \{[^}]*shop: 'Webshop', unterkunft: 'Buchungskalender', zeiten: 'Öffnungszeiten' \}/)
assert.match(app, /zeiten: \(\) => import\('\/zeiten\.js'\)\.then\(\(m\) => m\.initZeiten\(document\.getElementById\('zeiten-body'\)\)\)/)
const html = lesen('public/dashboard.html')
assert.match(html, /<section class="kb-view" id="view-zeiten">[\s\S]*<h1>Öffnungszeiten live<\/h1>[\s\S]*<div id="zeiten-body">/)
assert.match(html, /<link rel="stylesheet" href="\/zeiten\.css">/)
assert.doesNotMatch(html, /id="view-zeiten"[^>]*aria-live/)
assert.doesNotMatch(html.slice(html.indexOf('id="zeiten-body"') - 80, html.indexOf('id="zeiten-body"') + 80), /aria-live/)
// die bestehenden Ansichten bleiben, wie sie waren
assert.match(app, /termine: \(\) => import\('\/termine\.js'\)\.then\(\(m\) => m\.initTermine\(document\.getElementById\('termine-body'\)\)\)/)
})
test('Quelltext: zeiten.js und zeiten-bestellen.js importieren nur relativ und nur das Nötige; kein HTML aus Daten, kein Speicher, Du-Form', () => {
const importe = (datei) => [...ohneKommentare(lesen(`public/${datei}`)).matchAll(/\bfrom\s+'([^']+)'/g)].map((m) => m[1])
assert.deepEqual(importe('zeiten.js'), ['./termine-dom.js', './zeiten-bestellen.js', './zeiten-editor.js'])
assert.deepEqual(importe('zeiten-bestellen.js'), ['./funktion-bestellen.js', './termine-dom.js'])
const dateien = readdirSync(new URL('../public/', import.meta.url)).filter((f) => /^zeiten.*\.js$/.test(f))
for (const datei of ['zeiten.js', 'zeiten-bestellen.js']) {
assert.ok(dateien.includes(datei))
const quelle = ohneKommentare(lesen(`public/${datei}`))
assert.doesNotMatch(quelle, /innerHTML|insertAdjacentHTML|outerHTML|document\.write/, `${datei}: Daten nie als HTML`)
assert.doesNotMatch(quelle, /\bconfirm\(|\balert\(|\bprompt\(/, `${datei}: Rückfragen stehen in der Seite`)
assert.doesNotMatch(quelle, /\bSie\b|\bIhr(e[nmrs]?)?\b/, `${datei}: Portaltexte in der Du-Form`)
assert.doesNotMatch(quelle, /localStorage|sessionStorage|document\.cookie/, `${datei}: kein Browserspeicher`)
assert.doesNotMatch(quelle, /24 Uhr|\bbrutto\b|inklusive (Mehr|Umsatz)steuer/i, `${datei}: Wortwahl`)
assert.doesNotMatch(quelle, /import\s*\(/, `${datei}: kein dynamischer Import`)
}
// der einzige Link der Bestellseite ist die Leistungsbeschreibung (und die Rechnung in der Karte), jeder geht durch sicherLink bzw. unterlagenLink
const bestellen = ohneKommentare(lesen('public/zeiten-bestellen.js'))
assert.deepEqual(bestellen.match(/href: [^,}]+/g), ['href: link', 'href: rechnung'])
assert.match(bestellen, /const link = unterlagenLink\(/)
assert.match(bestellen, /const rechnung = sicherLink\(/)
// keine termine*.js und kein funktion-bestellen.js angefasst: sie sind gepinnt
assert.doesNotMatch(bestellen, /\btermine-view\b/)
})
test('zeiten.css: die Klassen der Ansicht sind gestaltet, die Bausteine der Bestellseite gibt es in termine.css', () => {
const css = lesen('public/zeiten.css')
for (const klasse of ['zt-kopf', 'zt-editor', 'zt-bestellen', 'zt-status', 'zt-hinweis']) assert.match(css, new RegExp(`\\.${klasse}[\\s{,.:\\[>]`), `.${klasse} ist gestaltet`)
assert.match(css, /#view-zeiten \.fn-project/)
assert.match(css, /prefers-reduced-motion/)
assert.match(css, /min-height: 44px/, 'Tippflächen am Handy')
const termine = lesen('public/termine.css')
for (const klasse of ['tm-bestellen', 'tm-rechtstext', 'tm-statuskarte', 'tm-haken', 'tm-pille', 'tm-box', 'tm-meldung', 'tm-danke', 'tm-fakten', 'tm-info', 'tm-aktionen']) assert.match(termine, new RegExp(`\\.${klasse}[\\s{,.:\\[]`), `.${klasse} steht in termine.css`)
// jede zt-Klasse, die die Module setzen, ist gestaltet (tm-Klassen sind teils reine Haken der Struktur)
const quelle = lesen('public/zeiten.js') + lesen('public/zeiten-bestellen.js')
const gesetzt = new Set([...quelle.matchAll(/class: '([^']+)'/g)].flatMap((m) => m[1].split(/\s+/)).filter((k) => /^zt-/.test(k)))
assert.ok(gesetzt.size >= 4)
for (const klasse of gesetzt) assert.ok(css.includes(`.${klasse}`), `.${klasse} hat keine Gestaltung`)
})