Files
Webklar-Kundenbereich/test/funktionenTiles.test.js
T
ClaudeandClaude Sonnet 5.5 6ae0f67457 Terminplaner TP2: Kachel mit Bestellweg, Abo-Felder in der Funktionen-Liste
GET /api/funktionen liefert bei termine.verfuegbar: true zusaetzlich bestellbar
(true | false | null bei Lesefehler im Abo), grund (kein_abo | gekuendigt |
nicht_aktiv | null), preis und einrichtung aus dem Register; nicht verfuegbar
bleibt exakt { verfuegbar: false }. Die Kachel zeigt je Zustand Pille und Knopf:
Aus mit Abo "Terminplaner einrichten · 149 €" bzw. "Fragebogen fortsetzen",
Aus ohne Abo keinen Knopf, sondern den Hinweis zum Abo, Zahlung offen und In
Einrichtung "Status ansehen", Aktiv "Termine oeffnen", Abgeschaltet "Termine
ansehen". Zielprojekt: aktiv, ordered/setup, abgeschaltet, Entwurf, erstes.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 22:34:50 +00:00

454 lines
27 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync, writeFileSync, mkdtempSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { aboMeldung } from '../server/services/featureOrder.js'
import { featureConfig } from '../server/services/featureRegistry.js'
// public/funktionen.js wirklich laden (die absoluten Importe /analyse-view.js und /geraete.js werden auf die Dateien umgeleitet)
const dir = mkdtempSync(join(tmpdir(), 'fn-tiles-'))
const fileUrl = (name) => pathToFileURL(new URL(`../public/${name}`, import.meta.url).pathname).href
const src = readFileSync(new URL('../public/funktionen.js', import.meta.url), 'utf8')
.replace("'/analyse-view.js'", `'${fileUrl('analyse-view.js')}'`)
.replace("'/geraete.js'", `'${fileUrl('geraete.js')}'`)
const file = join(dir, 'funktionen.mjs')
writeFileSync(file, src)
const { initFunktionen, initNews, initAnalyse, renderAnalyseOff } = await import(pathToFileURL(file).href)
const news = { state: 'off' }
const project = (id, analyse) => ({ projectId: id, projectName: `P ${id}`, liveUrl: '', features: { news }, analyse })
async function render(projects) {
globalThis.fetch = async () => ({ ok: true, status: 200, json: async () => ({ projects }) })
const root = { innerHTML: '', querySelector: () => null }
await initFunktionen(root)
return root.innerHTML
}
const tile = (html, key) => html.slice(html.indexOf(`data-fn-tile="${key}"`), html.indexOf('</article>', html.indexOf(`data-fn-tile="${key}"`)))
test('Kacheln rendern ohne Fehler: News, Analyse comingSoon/aus/aktiv/gesperrt', async () => {
const soon = await render([project('a', { comingSoon: true })])
assert.match(tile(soon, 'news'), /News-Pinnwand/)
assert.match(soon, /Kommt bald/)
assert.doesNotMatch(soon, /data-fn-tile="analyse"/)
const off = await render([project('a', { state: 'off', eligible: true, devMode: false })])
assert.match(tile(off, 'analyse'), /Einrichten/)
assert.doesNotMatch(off, /Dev-Modus/)
const dev = await render([project('a', { state: 'active', eligible: true, devMode: true })])
assert.match(tile(dev, 'analyse'), /Auswertung ansehen/)
assert.match(tile(dev, 'analyse'), /Im Dev-Modus wird nicht gemessen/)
assert.doesNotMatch(tile(dev, 'news'), /Dev-Modus/)
const live = await render([project('a', { state: 'active', eligible: true, devMode: false })])
assert.match(tile(live, 'analyse'), /Auswertung ansehen/)
assert.doesNotMatch(live, /Dev-Modus/)
const disabled = await render([project('a', { state: 'disabled' })])
assert.match(tile(disabled, 'analyse'), /Bitte melde dich bei uns/)
assert.doesNotMatch(disabled, /Dev-Modus/)
})
// ---- News-Kachel und -Buchungsseite: Preis und Zustimmungstext aus dem Register (features.news), Rückfall ohne features
async function renderMit(projects, extra) {
globalThis.fetch = async () => ({ ok: true, status: 200, json: async () => ({ projects, ...extra }) })
const root = { innerHTML: '', querySelector: () => null }
await initFunktionen(root)
return root.innerHTML
}
test('News-Kachel: Rückfall ohne features ⇒ bisheriger Preis 30 €', async () => {
const html = await render([project('a', { comingSoon: true })])
assert.match(tile(html, 'news'), /Einrichten lassen · 30 €/)
// auch bei leerem oder unvollständigem features-Objekt
for (const features of [{}, { news: {} }, { news: { priceLabel: '' } }, { news: { priceLabel: 31 } }, { termine: { priceLabel: '149 €' } }]) {
const rueck = await renderMit([project('a', { comingSoon: true })], { features })
assert.match(tile(rueck, 'news'), /Einrichten lassen · 30 €/, JSON.stringify(features))
}
})
test('News-Kachel: Preis aus dem Register ⇒ „Einrichten lassen · 31 €“', async () => {
const html = await renderMit([project('a', { comingSoon: true })], { features: { news: { priceLabel: '31 €' } } })
assert.match(tile(html, 'news'), /Einrichten lassen · 31 €/)
assert.doesNotMatch(tile(html, 'news'), /30 €/)
// der Preis kommt maskiert in die Kachel
const bose = await renderMit([project('a', { comingSoon: true })], { features: { news: { priceLabel: '<img src=x onerror=1>' } } })
assert.doesNotMatch(bose, /<img/)
})
const freiesProjekt = { projectId: 'p1', projectName: 'Muster', features: { news: { state: 'off' } } }
async function renderBuchung(antwort) {
const calls = []
globalThis.window = { location: { hash: '#news', pathname: '/dashboard.html', search: '', href: '' }, history: { replaceState() {} } }
globalThis.fetch = async (url, opts) => {
calls.push({ url: String(url), method: opts?.method, body: opts?.body ? JSON.parse(opts.body) : undefined })
if (String(url) === '/api/funktionen/news/checkout') return { ok: true, status: 200, json: async () => ({ url: 'https://checkout.example.test/s1' }) }
return { ok: true, status: 200, json: async () => antwort }
}
const stage = fakeStage()
const root = fakeEl()
root.querySelector = (sel) => (sel === '#news-stage' ? stage : null)
try {
await initNews(root, { projectId: 'p1' })
// Haken setzen und bestellen
const box = stage.els['#news-consent']
box.checked = true
box.handlers.change()
const button = stage.els['#news-order']
const knopfAktiv = button.disabled === false
await button.handlers.click()
return { stage, calls, knopfAktiv, href: globalThis.window.location.href }
} finally {
delete globalThis.window
}
}
test('News-Buchungsseite: Preis, Knopf und Zustimmungstext aus features.news, sendet weiter consent: true', async () => {
const register = { priceLabel: '31 €', consent: { version: 'v', knopf: 'Jetzt kaufen · 31 €', items: [{ id: 'beginn', text: 'Registertext der Zustimmung', haken: true }] } }
const { stage, calls, knopfAktiv, href } = await renderBuchung({
projects: [freiesProjekt],
setupEstimate: 'meist innerhalb von 1–2 Wochen',
consentText: 'Alter Text',
features: { news: register },
})
assert.match(stage.innerHTML, /<strong>31 €<\/strong>/)
assert.match(stage.innerHTML, /inklusive Mehrwertsteuer/, 'der Wortlaut bleibt bis Block F')
assert.match(stage.innerHTML, /id="news-order" disabled>Jetzt kaufen · 31 €</)
assert.equal(stage.els['#news-consent-text'].textContent, 'Registertext der Zustimmung')
assert.equal(knopfAktiv, true)
const bestellung = calls.filter((c) => c.url === '/api/funktionen/news/checkout')
assert.equal(bestellung.length, 1)
assert.equal(bestellung[0].method, 'POST')
assert.deepEqual(bestellung[0].body, { projectId: 'p1', consent: true })
assert.equal(href, 'https://checkout.example.test/s1')
})
test('News-Buchungsseite: Rückfall ohne features ⇒ 30 €, Standardknopf, consentText der Antwort', async () => {
const { stage, calls } = await renderBuchung({ projects: [freiesProjekt], setupEstimate: '', consentText: 'Alter Text' })
assert.match(stage.innerHTML, /<strong>30 €<\/strong>/)
assert.match(stage.innerHTML, /id="news-order" disabled>Zahlungspflichtig bestellen · 30 €</)
assert.equal(stage.els['#news-consent-text'].textContent, 'Alter Text')
assert.deepEqual(calls.find((c) => c.url === '/api/funktionen/news/checkout').body, { projectId: 'p1', consent: true })
})
test('News-Buchungsseite: Preis ohne Knopftext ⇒ Standardknopf mit Registerpreis, Knopftext kommt maskiert', async () => {
const nurPreis = await renderBuchung({ projects: [freiesProjekt], consentText: 'Alter Text', features: { news: { priceLabel: '31 €' } } })
assert.match(nurPreis.stage.innerHTML, /id="news-order" disabled>Zahlungspflichtig bestellen · 31 €</)
assert.equal(nurPreis.stage.els['#news-consent-text'].textContent, 'Alter Text')
const bose = await renderBuchung({ projects: [freiesProjekt], features: { news: { priceLabel: '31 €', consent: { knopf: '<img src=x onerror=1>', items: [] } } } })
assert.doesNotMatch(bose.stage.innerHTML, /<img/)
})
test('statisch: News postet weiter consent: true an /api/funktionen/news/checkout', () => {
assert.match(src, /request\('\/api\/funktionen\/news\/checkout',\s*\{[^}]*body:\s*\{[^}]*consent: true/)
})
// ---- Terminplaner-Kachel: nur mit Schalter (termine.verfuegbar), Zustand je Projekt aus features.termine
const withTermine = (id, state, extra = {}) => ({ projectId: id, projectName: `P ${id}`, liveUrl: '', features: { news, termine: { state, ...extra } } })
const noAnalyse = { comingSoon: true }
// Standardantwort bei verfuegbar: true wie GET /api/funktionen (Kunde mit Wartungs-Abo); abo ueberschreibt einzelne Felder
const ABO_OK = { bestellbar: true, grund: null, preis: '149 €', einrichtung: 'meist innerhalb von 1–2 Wochen' }
async function renderTermine(projects, { verfuegbar, anfragen = {}, abo = {} } = {}) {
const calls = []
globalThis.fetch = async (url) => {
calls.push(String(url))
if (String(url).startsWith('/api/termine/einstellungen')) {
const id = new URL(url, 'http://x').searchParams.get('projectId')
return { ok: true, status: 200, json: async () => ({ anfragenOffen: anfragen[id] ?? 0 }) }
}
const termine = verfuegbar === undefined ? {} : { termine: verfuegbar ? { verfuegbar, ...ABO_OK, ...abo } : { verfuegbar } }
return { ok: true, status: 200, json: async () => ({ projects, ...termine }) }
}
const count = { textContent: '', hidden: true }
const root = { innerHTML: '', querySelector: (sel) => (sel === '[data-termine-count]' ? count : null) }
await initFunktionen(root)
return { html: root.innerHTML, count, calls }
}
test('Terminplaner-Kachel: fehlt termine.verfuegbar oder ist false ⇒ „Kommt bald“ ohne Knopf', async () => {
for (const verfuegbar of [undefined, false]) {
const { html, calls } = await renderTermine([{ ...withTermine('a', 'active'), analyse: noAnalyse }], { verfuegbar })
assert.doesNotMatch(html, /data-fn-tile="termine"/)
assert.match(html, /fn-tile-soon[\s\S]*Terminplaner/)
assert.match(html, /Kommt bald/)
assert.doesNotMatch(html, /data-view="termine"/)
assert.deepEqual(calls, ['/api/funktionen'], 'ohne Schalter keine Abfrage an /api/termine')
}
})
// Die Abo-Hinweise der Kachel kommen aus aboMeldung (server/services/featureOrder.js, einzige Quelle der Abo-Hinweistexte);
// die Kachel steht im Browser und kennt den Text nur als Kopie: dieser Test haelt beide gleich.
const TERMINE_REGISTER = featureConfig('termine')
const KEIN_ABO_HINWEIS = /Im Wartungs-Abo enthalten\. <button type="button" class="kb-link" data-view="vertraege">Zu Verträge<\/button>/
const knopfDerKachel = (kachel) => kachel.match(/<button[^>]*fn-tile-cta[^>]*>([^<]*)<\/button>/)
test('Terminplaner-Kachel, Tabelle: jede Zeile mit Pille, Pillenklasse, Knopftext und Ziel', async () => {
const zeilen = [
// [Name, Projekte, Abo-Felder, Pillenklasse, Pille, Knopf, data-view]
['Aus mit Abo', [withTermine('a', 'off')], {}, 'off', 'Aus', 'Terminplaner einrichten · 149 €', 'termine'],
['Aus mit Abo, Entwurf (Dokument und siteKey)', [withTermine('a', 'off', { siteKey: 'abcdefghijklmnop' })], {}, 'off', 'Aus', 'Fragebogen fortsetzen', 'termine'],
['Aus, Projekt ohne Funktionsdokument (kein features.termine)', [{ ...project('a', noAnalyse), features: { news } }], {}, 'off', 'Aus', 'Terminplaner einrichten · 149 €', 'termine'],
['Zahlung offen', [withTermine('a', 'ordered')], {}, 'setup', 'Zahlung offen', 'Status ansehen', 'termine'],
['In Einrichtung', [withTermine('a', 'setup')], {}, 'setup', 'In Einrichtung', 'Status ansehen', 'termine'],
['Aktiv', [withTermine('a', 'active')], {}, 'on', 'Aktiv', 'Termine öffnen', 'termine'],
['Abgeschaltet', [withTermine('a', 'disabled')], {}, 'off', 'Abgeschaltet', 'Termine ansehen', 'termine'],
// das Abo betrifft nur die Bestellung: wer schon bestellt hat oder nutzt, behaelt seinen Weg in die Ansicht
['Zahlung offen, Abo gekündigt', [withTermine('a', 'ordered')], { bestellbar: false, grund: 'gekuendigt' }, 'setup', 'Zahlung offen', 'Status ansehen', 'termine'],
['In Einrichtung, Abo nicht lesbar', [withTermine('a', 'setup')], { bestellbar: null, grund: null }, 'setup', 'In Einrichtung', 'Status ansehen', 'termine'],
['Aktiv, Abo gekündigt', [withTermine('a', 'active')], { bestellbar: false, grund: 'gekuendigt' }, 'on', 'Aktiv', 'Termine öffnen', 'termine'],
['Abgeschaltet, Abo nicht aktiv', [withTermine('a', 'disabled')], { bestellbar: false, grund: 'nicht_aktiv' }, 'off', 'Abgeschaltet', 'Termine ansehen', 'termine'],
]
for (const [name, projects, abo, klasse, pille, knopfText, ziel] of zeilen) {
const { html } = await renderTermine(projects, { verfuegbar: true, abo })
const kachel = tile(html, 'termine')
assert.ok(kachel.includes(`<span class="fn-pill fn-pill-${klasse}">${pille}</span>`), `${name}: Pille ${pille} (${klasse}) in ${kachel}`)
const knopf = knopfDerKachel(kachel)
assert.ok(knopf, `${name}: Knopf`)
assert.equal(knopf[1], knopfText, name)
assert.ok(knopf[0].includes(`data-view="${ziel}" data-project="a"`), `${name}: Ziel ${knopf[0]}`)
assert.doesNotMatch(kachel, /Wir schalten den Terminplaner für dich frei/, name)
assert.doesNotMatch(kachel, /data-view="chat"/, `${name}: kein Chat-Knopf mehr`)
}
})
test('Terminplaner-Kachel: Aus ohne Abo ⇒ kein Knopf und kein Weg in die Ansicht, Hinweis je Grund', async () => {
const hinweise = [
['kein_abo', { bestellbar: false, grund: 'kein_abo' }, KEIN_ABO_HINWEIS],
['gekuendigt', { bestellbar: false, grund: 'gekuendigt' }, new RegExp(`${aboMeldung(TERMINE_REGISTER, 'gekuendigt')}`)],
['nicht_aktiv', { bestellbar: false, grund: 'nicht_aktiv' }, new RegExp(aboMeldung(TERMINE_REGISTER, 'nicht_aktiv').replace(/[()]/g, '\\$&'))],
['null (Lesefehler)', { bestellbar: null, grund: null }, /Das Abo konnte gerade nicht geprüft werden\./],
['Feld fehlt (ältere Antwort)', { bestellbar: undefined, grund: undefined }, /Das Abo konnte gerade nicht geprüft werden\./],
['unbekannter Grund', { bestellbar: false, grund: 'etwas_neues' }, /Das Abo konnte gerade nicht geprüft werden\./],
]
// mit und ohne Entwurf: auch ein vorhandener Entwurf oeffnet ohne Abo keinen Weg
for (const projekte of [[withTermine('a', 'off')], [withTermine('a', 'off', { siteKey: 'abcdefghijklmnop' })]]) {
for (const [name, abo, hinweis] of hinweise) {
const { html, calls } = await renderTermine(projekte, { verfuegbar: true, abo })
const kachel = tile(html, 'termine')
assert.match(kachel, /fn-pill-off">Aus</, name)
assert.match(kachel, hinweis, `${name}: ${kachel}`)
assert.doesNotMatch(kachel, /data-view="termine"/, name)
assert.doesNotMatch(kachel, /fn-tile-cta/, `${name}: kein Knopf`)
assert.doesNotMatch(html, /data-view="termine"/, `${name}: nirgends ein Weg in die Ansicht`)
assert.deepEqual(calls, ['/api/funktionen'], name)
}
}
// nur kein_abo verweist auf die Vertraege; die uebrigen Hinweise sind reiner Text
const gekuendigt = tile((await renderTermine([withTermine('a', 'off')], { verfuegbar: true, abo: { bestellbar: false, grund: 'gekuendigt' } })).html, 'termine')
assert.doesNotMatch(gekuendigt, /data-view=/)
})
test('Terminplaner-Kachel: die Abo-Hinweise stimmen wortgleich mit aboMeldung überein (gekündigt, nicht aktiv, nicht lesbar)', async () => {
for (const grund of ['gekuendigt', 'nicht_aktiv']) {
const { html } = await renderTermine([withTermine('a', 'off')], { verfuegbar: true, abo: { bestellbar: false, grund } })
assert.ok(tile(html, 'termine').includes(`<p class="fn-count">${aboMeldung(TERMINE_REGISTER, grund)}</p>`), grund)
}
const { html } = await renderTermine([withTermine('a', 'off')], { verfuegbar: true, abo: { bestellbar: null, grund: null } })
assert.ok(tile(html, 'termine').includes(`<p class="fn-count">${aboMeldung(TERMINE_REGISTER, 'unbekannt')}</p>`))
assert.equal(aboMeldung(TERMINE_REGISTER, 'unbekannt'), 'Das Abo konnte gerade nicht geprüft werden.')
})
test('Terminplaner-Kachel: ordered und setup fragen /api/termine nicht nach einer Zahl (nur aktive Websites)', async () => {
for (const state of ['ordered', 'setup']) {
const { html, calls, count } = await renderTermine([withTermine('a', state)], { verfuegbar: true })
assert.deepEqual(calls, ['/api/funktionen'], state)
assert.doesNotMatch(tile(html, 'termine'), /data-termine-count/, state)
assert.equal(count.textContent, '')
}
})
test('Terminplaner-Kachel: Preis kommt aus der Antwort und maskiert; ohne Preis steht der Knopf ohne Zahl', async () => {
const mit = await renderTermine([withTermine('a', 'off')], { verfuegbar: true, abo: { preis: '150 €' } })
assert.equal(knopfDerKachel(tile(mit.html, 'termine'))[1], 'Terminplaner einrichten · 150 €')
const bose = await renderTermine([withTermine('a', 'off')], { verfuegbar: true, abo: { preis: '<img src=x onerror=1>' } })
assert.doesNotMatch(bose.html, /<img/)
for (const preis of [undefined, '', 149, null]) {
const ohne = await renderTermine([withTermine('a', 'off')], { verfuegbar: true, abo: { preis } })
assert.equal(knopfDerKachel(tile(ohne.html, 'termine'))[1], 'Terminplaner einrichten', String(preis))
}
})
test('Terminplaner-Kachel: ohne Websites kein Absturz und kein Knopf', async () => {
const { html } = await renderTermine([], { verfuegbar: true })
const kachel = tile(html, 'termine')
assert.match(kachel, /fn-pill-off">Aus</)
assert.doesNotMatch(kachel, /fn-tile-cta|data-view=/)
assert.match(kachel, /Sobald deine Website angelegt ist/)
})
test('Terminplaner-Kachel: Aktiv ⇒ „Termine öffnen“ mit Projekt, Anfragen aus anfragenOffen', async () => {
const { html, count, calls } = await renderTermine([withTermine('a', 'off'), withTermine('b', 'active')], { verfuegbar: true, anfragen: { b: 3 } })
const kachel = tile(html, 'termine')
assert.match(kachel, /fn-pill-on">Aktiv</)
assert.match(kachel, /data-view="termine" data-project="b"[^>]*>Termine öffnen</)
assert.deepEqual(calls, ['/api/funktionen', '/api/termine/einstellungen?projectId=b'], 'nur aktive Projekte werden gefragt')
assert.equal(count.textContent, '3 Anfragen warten')
assert.equal(count.hidden, false)
})
test('Terminplaner-Kachel: Anfragen zählen über alle aktiven Websites, 1 ⇒ Einzahl, 0 ⇒ nichts', async () => {
const zwei = await renderTermine([withTermine('a', 'active'), withTermine('b', 'active')], { verfuegbar: true, anfragen: { a: 1, b: 2 } })
assert.equal(zwei.count.textContent, '3 Anfragen warten')
const eine = await renderTermine([withTermine('a', 'active')], { verfuegbar: true, anfragen: { a: 1 } })
assert.equal(eine.count.textContent, '1 Anfrage wartet')
const keine = await renderTermine([withTermine('a', 'active')], { verfuegbar: true, anfragen: {} })
assert.equal(keine.count.textContent, '')
assert.equal(keine.count.hidden, true)
})
test('Terminplaner-Kachel: Fehler bei der Anfragen-Zahl lässt die Kachel stehen', async () => {
globalThis.fetch = async (url) => {
if (String(url).startsWith('/api/termine/')) throw new Error('offline')
return { ok: true, status: 200, json: async () => ({ projects: [withTermine('a', 'active')], termine: { verfuegbar: true } }) }
}
const count = { textContent: '', hidden: true }
const root = { innerHTML: '', querySelector: (sel) => (sel === '[data-termine-count]' ? count : null) }
await initFunktionen(root)
assert.match(tile(root.innerHTML, 'termine'), /Termine öffnen/)
assert.equal(count.textContent, '')
assert.equal(count.hidden, true)
})
test('Terminplaner-Kachel: Abgeschaltet ⇒ Pille, Hinweis, Termine nur ansehen', async () => {
const { html, calls } = await renderTermine([withTermine('a', 'disabled')], { verfuegbar: true })
const kachel = tile(html, 'termine')
assert.match(kachel, /fn-pill-off">Abgeschaltet</)
assert.match(kachel, /abgeschaltet/)
assert.match(kachel, /data-view="termine" data-project="a"[^>]*>Termine ansehen</)
assert.deepEqual(calls, ['/api/funktionen'], 'abgeschaltet: keine Anfragen-Zahl')
})
test('Terminplaner-Kachel: mehrere Websites, Hinweis „aktiv für n von m“, Aktive zuerst', async () => {
const { html } = await renderTermine([withTermine('a', 'disabled'), withTermine('b', 'off'), withTermine('c', 'active')], { verfuegbar: true })
const kachel = tile(html, 'termine')
assert.match(kachel, /aktiv für 1 von 3 Websites/)
assert.match(kachel, /data-project="c"/)
})
test('Terminplaner-Kachel: Zielprojekt = aktiv, dann ordered/setup, dann abgeschaltet, dann Entwurf, dann das erste', async () => {
const entwurf = { siteKey: 'abcdefghijklmnop' }
const faelle = [
['aktiv vor allem', [withTermine('a', 'off', entwurf), withTermine('b', 'disabled'), withTermine('c', 'setup'), withTermine('d', 'ordered'), withTermine('e', 'active')], 'e', 'Aktiv', 'Termine öffnen'],
['ordered/setup vor abgeschaltet und Entwurf (setup zuerst in der Liste)', [withTermine('a', 'off', entwurf), withTermine('b', 'disabled'), withTermine('c', 'setup'), withTermine('d', 'ordered')], 'c', 'In Einrichtung', 'Status ansehen'],
['ordered/setup vor abgeschaltet und Entwurf (ordered zuerst in der Liste)', [withTermine('a', 'off', entwurf), withTermine('b', 'disabled'), withTermine('c', 'ordered'), withTermine('d', 'setup')], 'c', 'Zahlung offen', 'Status ansehen'],
['abgeschaltet vor Entwurf', [withTermine('a', 'off', entwurf), withTermine('b', 'off'), withTermine('c', 'disabled')], 'c', 'Abgeschaltet', 'Termine ansehen'],
['Entwurf vor dem ersten', [withTermine('a', 'off'), withTermine('b', 'off'), withTermine('c', 'off', entwurf)], 'c', 'Aus', 'Fragebogen fortsetzen'],
['sonst das erste', [withTermine('a', 'off'), withTermine('b', 'off')], 'a', 'Aus', 'Terminplaner einrichten · 149 €'],
]
for (const [name, projekte, ziel, pille, knopf] of faelle) {
const { html } = await renderTermine(projekte, { verfuegbar: true })
const kachel = tile(html, 'termine')
assert.ok(kachel.includes(`data-view="termine" data-project="${ziel}"`), `${name}: Ziel ${ziel} in ${kachel}`)
assert.match(kachel, new RegExp(`<span class="fn-pill fn-pill-[a-z]+">${pille}</span>`), name)
assert.equal(knopfDerKachel(kachel)[1], knopf, name)
}
})
test('Terminplaner-Kachel: mehrere Websites ohne Abo ⇒ die Pille bleibt „Aus“, nirgends ein Knopf (auch mit Entwurf)', async () => {
const { html } = await renderTermine([withTermine('a', 'off'), withTermine('b', 'off', { siteKey: 'abcdefghijklmnop' })], { verfuegbar: true, abo: { bestellbar: false, grund: 'kein_abo' } })
const kachel = tile(html, 'termine')
assert.match(kachel, /fn-pill-off">Aus</)
assert.doesNotMatch(kachel, /fn-tile-cta|data-view="termine"/)
assert.match(kachel, KEIN_ABO_HINWEIS)
})
test('Terminplaner-Kachel: Projektname und Zustand kommen nur maskiert in die Kachel', async () => {
const { html } = await renderTermine([{ ...withTermine('a<b>', 'active'), projectName: '<img src=x onerror=1>' }], { verfuegbar: true })
assert.doesNotMatch(html, /<img/)
assert.match(tile(html, 'termine'), /data-project="a&lt;b&gt;"/)
// auch im Zustand Aus mit Entwurf und in Einrichtung
for (const state of ['off', 'ordered', 'setup', 'disabled']) {
const bose = await renderTermine([{ ...withTermine('a<b>', state, { siteKey: 'abcdefghijklmnop' }), projectName: '<img src=x onerror=1>' }], { verfuegbar: true })
assert.doesNotMatch(bose.html, /<img/, state)
assert.match(tile(bose.html, 'termine'), /data-project="a&lt;b&gt;"/, state)
}
})
// ---- Analyse-Einschalten und Ausschalten (ohne DOM: Attrappen, die innerHTML und Klick-Handler festhalten)
function fakeEl() {
const handlers = {}
return { innerHTML: '', disabled: false, checked: false, value: '', dataset: {}, classList: { add() {}, remove() {}, toggle() {} }, handlers, addEventListener(ev, fn) { handlers[ev] = fn }, querySelector: () => null, querySelectorAll: () => [] }
}
function fakeStage() {
const els = {}
const get = (sel) => (els[sel] ||= fakeEl())
return { els, innerHTML: '', querySelector: get, querySelectorAll: () => [] }
}
const offProject = (analyse) => ({ projectId: 'p1', projectName: 'Muster', analyse: { state: 'off', eligible: true, accentColor: '#111111', avvVersion: '2026-10-02', ...analyse } })
const LEGAL = { avv: '/rechtliches/avv-website-analyse-2026-10-02.html', lb: '/rechtliches/leistungsbeschreibung-website-analyse-2026-10-02.html' }
const CTRL = { ok: true, company: 'Muster GmbH', address: 'Weg 1, 12345 Ort', email: 'info@muster.de' }
async function renderActivation(analyse) {
globalThis.fetch = async () => ({ ok: true, status: 200, json: async () => ({ projects: [offProject(analyse)] }) })
const root = fakeEl()
const stage = fakeStage()
root.querySelector = (sel) => (sel === '#an-stage' ? stage : null)
await initAnalyse(root)
return stage
}
test('Einschalten: zwei Links aus legal, Fassung, Verantwortlicher, Kinder-Satz, Banner-Vorschau', async () => {
const stage = await renderActivation({ legal: LEGAL, controller: CTRL })
const html = stage.innerHTML
assert.ok(html.includes(`href="${LEGAL.avv}"`))
assert.ok(html.includes(`href="${LEGAL.lb}"`))
assert.match(html, /Fassung 2026-10-02/)
assert.match(html, /Muster GmbH/)
assert.match(html, /info@muster\.de/)
assert.match(html, /Stimmt etwas nicht\? Schreib uns im Chat, bevor Du einschaltest\./)
assert.match(html, /nicht gezielt an Kinder unter 16 Jahren/)
assert.match(html, /Ohne Zustimmung der Besucher zählen wir nur Seitenaufrufe mit Land und Geräteklasse/)
assert.doesNotMatch(html, /anonym/)
const preview = stage.els['#an-preview'].innerHTML
assert.match(preview, /Ablehnen/)
assert.match(preview, /Annehmen/)
assert.match(preview, /Datenschutz-Einstellungen/)
// Haken schaltet den Knopf frei
const box = stage.els['#an-avv']
box.checked = true
box.handlers.change()
assert.equal(stage.els['#an-on'].disabled, false)
})
test('Einschalten: Firma und Anschrift werden maskiert', async () => {
const stage = await renderActivation({ legal: LEGAL, controller: { ...CTRL, company: 'A<b>&Co' } })
assert.match(stage.innerHTML, /A&lt;b&gt;&amp;Co/)
assert.doesNotMatch(stage.innerHTML, /A<b>/)
})
test('Einschalten: ohne Verantwortlichen-Angaben gesperrt, Hinweis sichtbar, kein Schreib-uns-Hinweis der Vorschau', async () => {
const stage = await renderActivation({ legal: LEGAL, controller: { ok: false, company: '', address: '', email: '', missing: ['firma'] } })
assert.match(stage.innerHTML, /fehlen in Deinen Kundendaten Firma oder Name und eine gültige E-Mail-Adresse/)
assert.doesNotMatch(stage.innerHTML, /bevor Du einschaltest/)
const box = stage.els['#an-avv']
box.checked = true
box.handlers.change()
assert.equal(stage.els['#an-on'].disabled, true)
})
test('Ausschalten: Hinweis vorher, nach "Ja, ausschalten" Bestätigung, erst "OK" lädt neu', async () => {
const calls = []
globalThis.fetch = async (url) => { calls.push(url); return { ok: true, status: 200, json: async () => ({}) } }
const box = fakeEl()
const actions = fakeEl()
const yes = fakeEl()
const no = fakeEl()
const ok = fakeEl()
const ask = fakeEl()
box.querySelector = (sel) => ({ '#an-off-actions': actions, '#an-off-ask': ask, '#an-off-ok': ok, '#an-off-error': fakeEl() })[sel]
actions.querySelector = (sel) => ({ '#an-off-yes': yes, '#an-off-no': no })[sel]
let reloads = 0
renderAnalyseOff(box, 'p1', () => { reloads++ })
assert.match(box.innerHTML, /Skript und Zusatzseite entfernen wir mit der nächsten Veröffentlichung Deiner Website/)
assert.match(box.innerHTML, /bis zum 46\. Tag kannst Du eine Kopie anfordern/)
ask.handlers.click()
await yes.handlers.click({ currentTarget: yes })
assert.deepEqual(calls, ['/api/funktionen/analyse/deactivate'])
assert.match(box.innerHTML, /Die Website-Analyse ist ausgeschaltet\. Ab jetzt werden keine Daten mehr gespeichert\./)
assert.equal(reloads, 0)
ok.handlers.click()
assert.equal(reloads, 1)
})