Öffnungszeiten: Kachel und Bestell-/Einschaltweg im Portal

Kachel „Öffnungszeiten live“ inline in funktionen.js (keine neuen Importe, keine Zusatzabfrage),
Ansicht #zeiten (zeiten.js, zeiten-bestellen.js, zeiten.css) mit Einschalten im Wartungs-Abo,
Bestellen für 30 € (Widerrufsbelehrung vor dem Knopf, Ruling R32), Behalten nach dem Abo-Ende,
Statuskarte je Zustand, Rückkehr von Stripe und Platz für den Editor (Aufgabe 29).
GET /api/funktionen liefert dafür zeiten.belehrung (Haken belehrung der Funktion).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-05 05:30:23 +00:00
1 parent f20f056c60
commit 7bb918390a
10 files changed
+1878 -8

No files matched your search

+310
View File
@@ -0,0 +1,310 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync, writeFileSync, mkdtempSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
// Kachel „Öffnungszeiten live“ in public/funktionen.js (Spec 3.1, Ruling R20). Die Kachel steht inline in funktionen.js (keine neuen Importe,
// test/termineView.test.js und test/termineFragebogenView.test.js zählen sie) und macht keine Abfrage über /api/funktionen hinaus.
// Eigener Loader: ALLE Importe von funktionen.js (absolut /x.js oder relativ ./x.js) zeigen auf die Dateien in public/, damit ein künftiger
// Import die Tests nicht stillschweigend bricht.
const dir = mkdtempSync(join(tmpdir(), 'fn-zeiten-tile-'))
const fileUrl = (name) => pathToFileURL(new URL(`../public/${name}`, import.meta.url).pathname).href
const quelle = readFileSync(new URL('../public/funktionen.js', import.meta.url), 'utf8')
const src = quelle.replace(/(\bfrom\s+)'(?:\/|\.\/)([^']+)'/g, (_, von, name) => `${von}'${fileUrl(name)}'`)
const file = join(dir, 'funktionen.mjs')
writeFileSync(file, src)
const { initFunktionen } = await import(pathToFileURL(file).href)
// ---------------------------------------------------------------------------------------------------------------- Hilfen
const zeitenFeature = (state, 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, state = 'off', feature = {}, extra = {}) => ({
projectId: id,
projectName: `Website ${id}`,
liveUrl: '',
features: { news: { state: 'off' }, zeiten: zeitenFeature(state, feature) },
analyse: { comingSoon: true },
zeitenStatus: null,
zeitenLeer: true,
...extra,
})
const ANGEBOT = (inklusive = true, grund = null) => ({
verfuegbar: true,
inklusive,
grund,
preis: '30 €',
einschalten: { consent: { version: 'v', knopf: 'Einschalten', items: [{ id: 'leistung', text: 'Haken', haken: true }] }, leistungsbeschreibung: { version: 'v', url: '/vertragsunterlagen/zeiten-leistungsbeschreibung.html' } },
})
async function render(projects, extra = {}) {
const aufrufe = []
globalThis.fetch = async (url) => {
aufrufe.push(String(url))
return { ok: true, status: 200, json: async () => ({ projects, ...extra }) }
}
const root = { innerHTML: '', querySelector: () => null }
await initFunktionen(root)
return { html: root.innerHTML, aufrufe }
}
const kachel = (html) => {
const von = html.indexOf('data-fn-tile="zeiten"')
assert.ok(von >= 0, 'die Kachel fehlt')
return html.slice(von, html.indexOf('</article>', von))
}
const nurText = (html) => html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()
const knoepfe = (html) => [...html.matchAll(/<button\b[^>]*>([^<]*)<\/button>/g)].map((m) => m[1].trim())
// ---------------------------------------------------------------------------------------------------------------- Tests
test('ohne verfuegbar keine Kachel', async () => {
const P = [projekt('a')]
const ohne = (await render(P)).html
assert.doesNotMatch(ohne, /data-fn-tile="zeiten"/)
assert.doesNotMatch(ohne, /Öffnungszeiten/)
const aus = (await render(P, { zeiten: { verfuegbar: false } })).html
assert.equal(aus, ohne, 'verfuegbar: false sieht aus wie ein Server ohne Zeiten')
assert.equal(ohne.match(/Kommt bald/g).length, 2, 'Terminplaner und Analyse, wie bisher')
// auch ein beschädigtes Angebot macht keine Kachel
for (const kaputt of [null, 'ja', { verfuegbar: 'ja' }, { inklusive: true }]) assert.doesNotMatch((await render(P, { zeiten: kaputt })).html, /data-fn-tile="zeiten"/, JSON.stringify(kaputt))
})
test('Kachel steht zwischen Terminplaner und Analyse, mit Angebot genau eine zusätzliche Kachel', async () => {
const { html } = await render([projekt('a')], { zeiten: ANGEBOT(true) })
const stellen = ['data-fn-tile="news"', 'Terminplaner', 'data-fn-tile="zeiten"', 'Analyse'].map((s) => html.indexOf(s))
assert.ok(stellen.every((s) => s >= 0), stellen.join())
assert.deepEqual(stellen, [...stellen].sort((x, y) => x - y))
assert.equal(html.match(/class="fn-tile[ "]/g).length, 4, 'News, Terminplaner (Kommt bald), Zeiten, Analyse (Kommt bald)')
assert.equal(html.match(/Kommt bald/g).length, 2)
assert.match(kachel(html), /<h3>Öffnungszeiten live<\/h3>/)
assert.match(kachel(html), /<svg class="fn-ico"/)
})
test('Abo: „Im Wartungs-Abo enthalten“ und Knopf Einschalten', async () => {
const k = kachel((await render([projekt('p1')], { zeiten: ANGEBOT(true) })).html)
assert.match(nurText(k), /Im Wartungs-Abo enthalten/)
assert.match(k, /fn-pill fn-pill-off">Aus</)
assert.deepEqual(knoepfe(k), ['Einschalten'])
assert.match(k, /<button type="button" class="kb-btn-primary fn-tile-cta" data-view="zeiten" data-project="p1">Einschalten<\/button>/)
assert.doesNotMatch(k, /30 €|Zahlungspflichtig|einmalig/)
})
test('Abo nicht aktiv: „sobald dein Abo aktiv ist“, kein Bezahlen', async () => {
const k = kachel((await render([projekt('p1')], { zeiten: ANGEBOT(false, 'nicht_aktiv') })).html)
assert.match(nurText(k), /Im Wartungs-Abo enthalten – einschaltbar, sobald dein Abo aktiv ist/)
assert.deepEqual(knoepfe(k), [])
assert.doesNotMatch(k, /30 €|Zahlungspflichtig|einmalig|<button/)
})
test('ohne Abo: „30 € einmalig“', async () => {
for (const grund of ['kein_abo', 'gekuendigt', null]) {
const k = kachel((await render([projekt('p1')], { zeiten: ANGEBOT(false, grund) })).html)
assert.match(nurText(k), /30 € einmalig/, String(grund))
assert.deepEqual(knoepfe(k), ['Öffnungszeiten einrichten · 30 €'], String(grund))
assert.match(k, /data-view="zeiten" data-project="p1"/)
assert.doesNotMatch(k, /Im Wartungs-Abo enthalten/, 'nichts verspricht, was nicht gilt')
}
// der Preis kommt aus dem Register, maskiert
const anderer = kachel((await render([projekt('p1')], { zeiten: { ...ANGEBOT(false, 'kein_abo'), preis: '31 €' } })).html)
assert.match(nurText(anderer), /31 € einmalig/)
assert.deepEqual(knoepfe(anderer), ['Öffnungszeiten einrichten · 31 €'])
assert.doesNotMatch(anderer, /30 €/)
const bose = kachel((await render([projekt('p1')], { zeiten: { ...ANGEBOT(false, 'kein_abo'), preis: '<b>31 €</b>' } })).html)
assert.doesNotMatch(bose, /<b>/)
// ohne Preis im Angebot gilt der Rückfall 30 €
assert.match(nurText(kachel((await render([projekt('p1')], { zeiten: { ...ANGEBOT(false, 'kein_abo'), preis: '' } })).html)), /30 € einmalig/)
})
test('Abo-Lesefehler (inklusive null): „Gerade nicht prüfbar – bitte später erneut“', async () => {
const k = kachel((await render([projekt('p1')], { zeiten: ANGEBOT(null, null) })).html)
assert.match(nurText(k), /Gerade nicht prüfbar – bitte später erneut/)
assert.deepEqual(knoepfe(k), [])
assert.doesNotMatch(k, /30 €|Im Wartungs-Abo enthalten/)
})
test('setup: Text je nach einbau, Hinweis „Trag jetzt deine Zeiten ein“ bei zeitenLeer', async () => {
const zeigen = async (feature, extra) => kachel((await render([projekt('p1', 'setup', feature, extra)], { zeiten: ANGEBOT(true) })).html)
const angestossen = await zeigen({ einbau: 'angestossen' }, { zeitenLeer: false })
assert.match(angestossen, /fn-pill fn-pill-setup">In Einrichtung</)
assert.match(nurText(angestossen), /Hinweis- und Kontakt-Leiste erscheinen nach dem nächsten Veröffentlichen \(meist in wenigen Minuten\)/)
assert.doesNotMatch(nurText(angestossen), /Trag jetzt deine Zeiten ein/)
assert.deepEqual(knoepfe(angestossen), ['Zeiten bearbeiten'])
assert.match(angestossen, /data-view="zeiten" data-project="p1"/)
const manuell = await zeigen({ einbau: 'manuell', ticketWoid: '4711' }, { zeitenLeer: false })
assert.match(nurText(manuell), /Unser Team baut die Funktion ein \(Ticket #4711\)/)
assert.doesNotMatch(nurText(manuell), /nach dem nächsten Veröffentlichen/)
const ohneTicket = await zeigen({ einbau: 'manuell' }, { zeitenLeer: false })
assert.match(nurText(ohneTicket), /Unser Team baut die Funktion ein\./)
assert.doesNotMatch(ohneTicket, /Ticket #/)
const leer = await zeigen({ einbau: 'angestossen' }, { zeitenLeer: true })
assert.match(nurText(leer), /Trag jetzt deine Zeiten ein – erst dann kann das Team die Tabelle einbauen/)
const leerManuell = await zeigen({ einbau: 'manuell', ticketWoid: '1' }, { zeitenLeer: true })
assert.match(nurText(leerManuell), /Trag jetzt deine Zeiten ein/)
// Kauf: der Neubau steht noch aus (einbau leer): der Text nennt weder Veröffentlichen noch ein Ticket, das es nicht gibt
const kauf = await zeigen({ unlockedBy: 'kauf', einbau: null }, { zeitenLeer: false })
assert.match(nurText(kauf), /Unser Team baut die Funktion ein\./)
})
test('active: Kurzstatus aus zeitenStatus, keine Zusatzabfrage', async () => {
const status = { offen: true, kurz: 'Geöffnet', text: 'Jetzt geöffnet · schließt um 18 Uhr' }
const { html, aufrufe } = await render([projekt('p1', 'active', { einbau: 'angestossen' }, { zeitenStatus: status, zeitenLeer: false })], { zeiten: ANGEBOT(true) })
assert.deepEqual(aufrufe, ['/api/funktionen'], 'der Status kommt in der Liste, die Kachel fragt nichts nach')
const k = kachel(html)
assert.match(k, /fn-pill fn-pill-on">Aktiv</)
assert.match(nurText(k), /Jetzt geöffnet · schließt um 18 Uhr/)
assert.deepEqual(knoepfe(k), ['Zeiten bearbeiten'])
// ohne gespeicherte Zeiten kein Status; stattdessen der Hinweis
const ohne = kachel((await render([projekt('p1', 'active', {}, { zeitenStatus: null, zeitenLeer: true })], { zeiten: ANGEBOT(true) })).html)
assert.doesNotMatch(ohne, /Jetzt geöffnet/)
assert.match(nurText(ohne), /Trag jetzt deine Zeiten ein/)
// Fremdtext nur maskiert
const bose = kachel((await render([projekt('p1', 'active', {}, { zeitenStatus: { offen: true, kurz: 'Geöffnet', text: '<img src=x onerror=1>' }, zeitenLeer: false })], { zeiten: ANGEBOT(true) })).html)
assert.doesNotMatch(bose, /<img/)
assert.match(bose, /&lt;img/)
})
test('mehrere Websites: Zielprojekt ist die aktive, dann die in Einrichtung, dann die abgeschaltete, sonst die erste; „aktiv für 1 von 2 Websites“', async () => {
const wahl = async (liste, angebot = ANGEBOT(true)) => kachel((await render(liste, { zeiten: angebot })).html).match(/data-project="([^"]+)"/)?.[1]
assert.equal(await wahl([projekt('a'), projekt('b', 'active', {}, { zeitenLeer: false })]), 'b')
assert.equal(await wahl([projekt('a'), projekt('b', 'setup'), projekt('c', 'disabled')]), 'b')
assert.equal(await wahl([projekt('a'), projekt('b', 'disabled', { disabledReason: 'abo_ende', loeschungAm: '2999-01-01' }, { zeitenLeer: false })], ANGEBOT(false, 'kein_abo')), 'b')
assert.equal(await wahl([projekt('a'), projekt('b')]), 'a')
assert.match(nurText(kachel((await render([projekt('a'), projekt('b', 'active', {}, { zeitenLeer: false })], { zeiten: ANGEBOT(true) })).html)), /aktiv für 1 von 2 Websites/)
// ohne Website: ein Satz statt eines Knopfs
const keine = kachel((await render([], { zeiten: ANGEBOT(true) })).html)
assert.deepEqual(knoepfe(keine), [])
assert.match(nurText(keine), /Sobald deine Website angelegt ist/)
})
test('aboEndeAt: Behalten-Hinweis mit Datum', async () => {
const zeigen = async (feature, angebot = ANGEBOT(false, 'kein_abo'), state = 'active') => kachel((await render([projekt('p1', state, feature, { zeitenLeer: false })], { zeiten: angebot })).html)
const k = await zeigen({ aboEndeAt: '2026-10-01T10:00:00.000Z', einbau: 'angestossen' })
assert.match(nurText(k), /Dein Wartungs-Abo ist beendet – behalte die Funktion bis 31\.10\.2026 für einmalig 30 €/)
assert.deepEqual(knoepfe(k), ['Zeiten bearbeiten', 'Für 30 € behalten'])
assert.match(k, /<button type="button" class="kb-btn-outline" data-view="zeiten" data-project="p1" data-zeiten-weg="behalten">Für 30 € behalten<\/button>/)
// Berliner Kalendertag, nicht UTC: 01.10. 23:30 UTC ist schon der 02.10. in Berlin ⇒ Frist 01.11.
assert.match(nurText(await zeigen({ aboEndeAt: '2026-10-01T23:30:00.000Z' })), /bis 01\.11\.2026/)
// Appwrite liefert +00:00
assert.match(nurText(await zeigen({ aboEndeAt: '2026-10-01T10:00:00.000+00:00' })), /bis 31\.10\.2026/)
// auch in der Einrichtung
assert.match(nurText(await zeigen({ aboEndeAt: '2026-10-01T10:00:00.000Z' }, ANGEBOT(false, 'kein_abo'), 'setup')), /behalte die Funktion bis 31\.10\.2026/)
// läuft das Abo wieder (inklusive true), gibt es nichts zu bezahlen: der Abgleich nimmt das Ende gleich zurück
const wieder = await zeigen({ aboEndeAt: '2026-10-01T10:00:00.000Z' }, ANGEBOT(true))
assert.doesNotMatch(wieder, /behalten|Für 30 €/)
assert.deepEqual(knoepfe(wieder), ['Zeiten bearbeiten'])
// Abo nicht lesbar: kein Bezahlknopf auf Verdacht
const unklar = await zeigen({ aboEndeAt: '2026-10-01T10:00:00.000Z' }, ANGEBOT(null, null))
assert.deepEqual(knoepfe(unklar), ['Zeiten bearbeiten'])
// ohne Datum kein Hinweis
assert.doesNotMatch(await zeigen({ aboEndeAt: null }), /Wartungs-Abo ist beendet/)
// ein unlesbares Datum erfindet kein Datum
const kaputt = await zeigen({ aboEndeAt: 'kein-datum' })
assert.doesNotMatch(nurText(kaputt), /bis NaN|Invalid|undefined/)
})
test('ordered: „Zahlung wird bestätigt …“', async () => {
const k = kachel((await render([projekt('p1', 'ordered', { unlockedBy: null })], { zeiten: ANGEBOT(false, 'kein_abo') })).html)
assert.match(k, /fn-pill fn-pill-setup">Zahlung offen</)
assert.match(nurText(k), /Zahlung wird bestätigt …/)
assert.deepEqual(knoepfe(k), ['Status ansehen'])
assert.match(k, /data-view="zeiten" data-project="p1"/)
})
test('disabled abo_ende mit Daten: Knopf „Für 30 € wieder einschalten“', async () => {
const zeigen = async (feature, extra, angebot = ANGEBOT(false, 'kein_abo')) => kachel((await render([projekt('p1', 'disabled', feature, extra)], { zeiten: angebot })).html)
const k = await zeigen({ disabledReason: 'abo_ende', disabledAt: '2026-10-04T10:00:00.000Z', loeschungAm: '2999-01-01' }, { zeitenLeer: false })
assert.match(k, /fn-pill fn-pill-off">Abgeschaltet</)
assert.match(nurText(k), /Öffnungszeiten live ist abgeschaltet, weil dein Wartungs-Abo beendet ist/)
assert.match(nurText(k), /Deine Eingaben bleiben bis 01\.01\.2999 gespeichert/)
assert.deepEqual(knoepfe(k), ['Für 30 € wieder einschalten'])
assert.match(k, /data-view="zeiten" data-project="p1" data-zeiten-weg="behalten"/)
// keine Daten mehr (gelöscht oder nie eingetragen): nichts zu behalten
const leer = await zeigen({ disabledReason: 'abo_ende', loeschungAm: '2999-01-01' }, { zeitenLeer: true })
assert.doesNotMatch(leer, /Für 30 €/)
assert.deepEqual(knoepfe(leer), ['Chat öffnen'])
// die Frist der Löschung ist vorbei
const zuSpaet = await zeigen({ disabledReason: 'abo_ende', loeschungAm: '2000-01-01' }, { zeitenLeer: false })
assert.doesNotMatch(zuSpaet, /Für 30 €/)
// Abschaltung durch das Team: Grundtext aus der Abschaltung, kein Behalten
const team = await zeigen({ disabledReason: 'team' }, { zeitenLeer: false })
assert.doesNotMatch(team, /Für 30 €/)
assert.match(nurText(team), /Öffnungszeiten live wurde für diese Website abgeschaltet\. Schreib uns im Chat, wenn du die Funktion wieder möchtest\./)
assert.deepEqual(knoepfe(team), ['Chat öffnen'])
// Altbestand ohne Grund
assert.deepEqual(knoepfe(await zeigen({}, { zeitenLeer: false })), ['Chat öffnen'])
// das Abo läuft wieder: der Abgleich schaltet selbst wieder ein, 30 € wären doppelt bezahlt
const wieder = await zeigen({ disabledReason: 'abo_ende', loeschungAm: '2999-01-01' }, { zeitenLeer: false }, ANGEBOT(true))
assert.doesNotMatch(wieder, /Für 30 €/)
assert.match(nurText(wieder), /Dein Wartungs-Abo läuft wieder/)
})
// ---------------------------------------------------------------------------------------------------------------- Klick
test('Knopf: das Projekt (und beim Behalten der Weg) geht an #zeiten-body; nur Knöpfe der Zeiten-Kachel', async () => {
const gesetzt = []
const gehoert = {}
const artikel = { addEventListener: (typ, fn) => { gehoert[typ] = fn } }
const body = { dataset: {} }
const vorher = globalThis.document
globalThis.document = { getElementById: (id) => { gesetzt.push(id); return id === 'zeiten-body' ? body : null } }
try {
globalThis.fetch = async () => ({ ok: true, status: 200, json: async () => ({ projects: [projekt('p1', 'active', { aboEndeAt: '2026-10-01T10:00:00.000Z' }, { zeitenLeer: false })], zeiten: ANGEBOT(false, 'kein_abo') }) })
const root = { innerHTML: '', querySelector: (sel) => (sel === '[data-fn-tile="zeiten"]' ? artikel : null) }
await initFunktionen(root)
assert.equal(typeof gehoert.click, 'function', 'ein Klick-Handler an der Kachel')
const knopf = (dataset) => ({ closest: (sel) => (sel === 'button[data-view="zeiten"]' ? { dataset } : null) })
gehoert.click({ target: knopf({ view: 'zeiten', project: 'p1' }) })
assert.equal(body.dataset.wunschProjekt, 'p1')
assert.equal(body.dataset.wunschWeg, undefined, 'ohne Weg keiner')
gehoert.click({ target: knopf({ view: 'zeiten', project: 'p1', zeitenWeg: 'behalten' }) })
assert.equal(body.dataset.wunschWeg, 'behalten')
// der nächste Klick ohne Weg nimmt den alten zurück
gehoert.click({ target: knopf({ view: 'zeiten', project: 'p1' }) })
assert.equal(body.dataset.wunschWeg, undefined)
gehoert.click({ target: knopf({ view: 'zeiten', project: 'p1', zeitenWeg: 'behalten' }) })
assert.equal(body.dataset.wunschWeg, 'behalten')
// ein Klick auf etwas anderes in der Kachel (Text) legt nichts ab
body.dataset = {}
gehoert.click({ target: { closest: () => null } })
gehoert.click({ target: {} })
assert.deepEqual(body.dataset, {})
// ein unbekannter Weg wird nicht übernommen
gehoert.click({ target: knopf({ view: 'zeiten', project: 'p2', zeitenWeg: 'alles-frei' }) })
assert.equal(body.dataset.wunschProjekt, 'p2')
assert.equal(body.dataset.wunschWeg, undefined)
} finally {
if (vorher === undefined) delete globalThis.document
else globalThis.document = vorher
}
})
test('Projekt-IDs und Namen kommen nur maskiert in die Kachel', async () => {
const k = kachel((await render([projekt('"><script>x</script>', 'off')], { zeiten: ANGEBOT(true) })).html)
assert.doesNotMatch(k, /<script>/)
assert.match(k, /data-project="&quot;&gt;&lt;script&gt;x&lt;\/script&gt;"/)
})
// ---------------------------------------------------------------------------------------------------------------- Quelltext
test('funktionen.js: keine neuen Importe für die Zeiten (die Kachel steht inline), keine „24 Uhr“, Du-Form', () => {
assert.deepEqual(quelle.match(/from '\/[^']+'/g), ["from '/analyse-view.js'", "from '/geraete.js'"])
assert.deepEqual(quelle.match(/from '\.[^']+'/g), ["from './vertraege-link.js'"])
assert.doesNotMatch(quelle, /import\s*\(/)
assert.doesNotMatch(quelle, /from '[^']*zeiten/, 'kein Zeiten-Modul in funktionen.js')
const von = quelle.indexOf('Öffnungszeiten live')
assert.ok(von > 0)
assert.doesNotMatch(quelle.slice(von), /24 Uhr/)
assert.doesNotMatch(quelle, /\bSie\b/)
})
+791
View File
@@ -0,0 +1,791 @@
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, 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)
const mitDom = (optionen, fn) => async () => {
const fx = installFakeDom(optionen)
try {
await fn(fx)
} 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'))
}))
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 oder die Rückkehr von Stripe schon', mitDom({}, async (fx) => {
const aktiv = () => liste([projekt('p1', 'active', {}, { zeitenLeer: false })], angebot(true))
fx.antworten.push(aktiv())
const root = await oeffne(fx)
let editor = fx.finde(root, '.zt-editor')
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(editor.firstChild, eingabe)
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 neu aufgebaut
fx.antworten.push(liste([projekt('p1', 'active', {}, { zeitenLeer: false })], angebot(false, 'kein_abo')))
await initZeiten(root)
assert.notEqual(fx.finde(root, '.zt-editor'), editor)
editor = fx.finde(root, '.zt-editor')
// „Für 30 € behalten“ aus der Kachel: Behalten-Seite; der Stand hat sich geändert (aboEndeAt), also neu aufgebaut
fx.antworten.push(liste([projekt('p1', 'active', ABO_ENDE, { zeitenLeer: false })], angebot(false, 'kein_abo')))
root.dataset.wunschWeg = 'behalten'
await initZeiten(root)
assert.notEqual(fx.finde(root, '.zt-editor'), editor)
assert.ok(fx.finde(root, '[data-variante="behalten"]'))
// gleicher neuer Stand noch einmal mit Weg: der Editor bleibt, die Seite öffnet
const editor2 = fx.finde(root, '.zt-editor')
await fx.klick(knopf(fx, root, 'zurueck'))
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'), editor2, 'der Editor bleibt')
assert.equal(editor2.hidden, true)
assert.ok(fx.finde(root, '[data-variante="behalten"]'))
// 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'), editor2)
assert.ok(fx.finde(root, '[data-danke="behalten"]'))
}))
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')
assert.match(app, /VIEW_HASHES = \{[^}]*termine: 'termine', zeiten: 'zeiten'/)
assert.match(app, /APP_PARENT = \{ news: 'funktionen', analyse: 'funktionen', termine: 'funktionen', zeiten: 'funktionen'/)
assert.match(app, /FEATURE_LABELS = \{[^}]*termine: 'Terminplaner', 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'])
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`)
})
+6 -2
View File
@@ -526,9 +526,13 @@ test('Angebot: nicht verfügbar ⇒ genau { verfuegbar: false }, das Abo wird ni
})
test('Angebot: inklusive true/false/null je nach Abo-Lage, Preis und Texte aus dem Register', async () => {
const erwartet = (inklusive, grund) => ({ verfuegbar: true, inklusive, grund, preis: '30 €', einschalten: FREI() })
// Ruling R32: die Belehrung der Bestellseite ist genau der Text, den der Haken belehrung der Zeiten auch in den Nachweis schreibt
const { featureHaken } = await import('../server/services/featureRegistry.js')
const belehrung = await featureHaken('zeiten').belehrung()
assert.match(belehrung, /Muster-Widerrufsformular/)
const erwartet = (inklusive, grund) => ({ verfuegbar: true, inklusive, grund, preis: '30 €', einschalten: FREI(), belehrung })
assert.deepEqual(await angebot(), erwartet(true, null))
assert.deepEqual(Object.keys(await angebot()), ['verfuegbar', 'inklusive', 'grund', 'preis', 'einschalten'])
assert.deepEqual(Object.keys(await angebot()), ['verfuegbar', 'inklusive', 'grund', 'preis', 'einschalten', 'belehrung'])
aw.seed('subscriptions', hostingDoc)
assert.deepEqual(await angebot(), erwartet(false, 'kein_abo'), 'nur Hosting')