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/: 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 = '' 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, /
[\s\S]*

Öffnungszeiten live<\/h1>[\s\S]*
/) assert.match(html, //) 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`) })