Files
Webklar-Kundenbereich/test/funktionenTiles.test.js
T
ClaudeandClaude Opus 5.5 809da80c9b Shop G1: Verdrahtung #webshop, Webshop-Kachel, Bestellseite mit key und optionaler Zusammenfassung
Dashboard-Abschnitt view-webshop, shop.css und Platzhalter shop.js, app.js (Hash, Refresher, Label, Elternansicht), Kachel in funktionen.js nach shop.verfuegbar, GET /api/funktionen liefert shop.verfuegbar, funktion-bestellen.js nimmt key/name/zusammenfassung (Standard: Terminplaner unverändert).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 08:57:02 +00:00

567 lines
36 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'
// public/funktionen.js wirklich laden (die absoluten Importe /fn-util.js und /analyse-view.js und der relative ./vertraege-link.js werden
// auf die Dateien umgeleitet); analyse.js importiert relativ und wird direkt geladen
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("'/fn-util.js'", `'${fileUrl('fn-util.js')}'`)
.replace("'/analyse-view.js'", `'${fileUrl('analyse-view.js')}'`)
.replace("'./vertraege-link.js'", `'${fileUrl('vertraege-link.js')}'`)
const file = join(dir, 'funktionen.mjs')
writeFileSync(file, src)
const { initFunktionen, initNews } = await import(pathToFileURL(file).href)
const { initAnalyse, renderAnalyseOff } = await import(fileUrl('analyse.js'))
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.doesNotMatch(stage.innerHTML, /Mehrwertsteuer/, 'der Preishinweis kommt aus ustHinweis (Block F)')
assert.match(stage.innerHTML, /einmalig, <span id="news-ust-hinweis"><\/span>/)
assert.equal(stage.els['#news-ust-hinweis'].textContent, 'ohne Umsatzsteuer gemäß § 19 UStG', 'Rückfall ohne ustHinweis in der Antwort')
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', hinweis: null }
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 vom Server (termine.hinweis = aboMeldung in server/services/featureOrder.js, einzige Quelle der
// Abo-Hinweistexte, R111); der Browser hat keine Kopie und zeigt den Text nur maskiert an. Die Tests nehmen eigene Texte, damit
// eine Kopie im Browser auffaellt. Der Link zu den Vertraegen steht nur bei kein_abo.
const ZU_VERTRAEGE = '<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', hinweis: 'Serverhinweis gekündigt.' }, 'setup', 'Zahlung offen', 'Status ansehen', 'termine'],
['In Einrichtung, Abo nicht lesbar', [withTermine('a', 'setup')], { bestellbar: null, grund: null, hinweis: 'Serverhinweis nicht prüfbar.' }, 'setup', 'In Einrichtung', 'Status ansehen', 'termine'],
['Aktiv, Abo gekündigt', [withTermine('a', 'active')], { bestellbar: false, grund: 'gekuendigt', hinweis: 'Serverhinweis gekündigt.' }, 'on', 'Aktiv', 'Termine öffnen', 'termine'],
['Abgeschaltet, Abo nicht aktiv', [withTermine('a', 'disabled')], { bestellbar: false, grund: 'nicht_aktiv', hinweis: 'Serverhinweis 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`)
assert.doesNotMatch(kachel, /Serverhinweis/, `${name}: der Abo-Hinweis gilt nur im Zustand Aus`)
}
})
test('Terminplaner-Kachel: Aus ohne Abo => kein Knopf und kein Weg in die Ansicht, der Hinweis kommt vom Server (Link zu Verträge nur bei kein_abo)', async () => {
const faelle = [
['kein_abo', { bestellbar: false, grund: 'kein_abo', hinweis: 'Serverhinweis ohne Abo.' }, `Serverhinweis ohne Abo. ${ZU_VERTRAEGE}`],
['gekuendigt', { bestellbar: false, grund: 'gekuendigt', hinweis: 'Serverhinweis gekündigt & beendet.' }, 'Serverhinweis gekündigt &amp; beendet.'],
['nicht_aktiv', { bestellbar: false, grund: 'nicht_aktiv', hinweis: 'Serverhinweis „nicht aktiv“ (offen).' }, 'Serverhinweis „nicht aktiv“ (offen).'],
['unbekannt (Lesefehler: bestellbar null)', { bestellbar: null, grund: null, hinweis: 'Serverhinweis nicht prüfbar.' }, 'Serverhinweis nicht prüfbar.'],
['unbekannter Grund', { bestellbar: false, grund: 'etwas_neues', hinweis: 'Serverhinweis zu etwas Neuem.' }, 'Serverhinweis zu etwas Neuem.'],
]
// 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, erwartet] of faelle) {
const { html, calls } = await renderTermine(projekte, { verfuegbar: true, abo })
const kachel = tile(html, 'termine')
assert.match(kachel, /fn-pill-off">Aus</, name)
assert.ok(kachel.includes(`<p class="fn-count">${erwartet}</p>`), `${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
assert.equal(kachel.split('data-view=').length - 1, abo.grund === 'kein_abo' ? 1 : 0, `${name}: Links`)
}
}
})
test('Terminplaner-Kachel: der Hinweis des Servers kommt maskiert in die Kachel (auch bei kein_abo neben dem Link)', async () => {
const bose = '<img src=x onerror=1>"\'&'
const maskiert = '&lt;img src=x onerror=1&gt;&quot;&#39;&amp;'
for (const grund of ['gekuendigt', 'nicht_aktiv', 'kein_abo']) {
const { html } = await renderTermine([withTermine('a', 'off')], { verfuegbar: true, abo: { bestellbar: false, grund, hinweis: bose } })
const kachel = tile(html, 'termine')
assert.doesNotMatch(html, /<img/, grund)
assert.ok(kachel.includes(`<p class="fn-count">${maskiert}${grund === 'kein_abo' ? ` ${ZU_VERTRAEGE}` : ''}</p>`), `${grund}: ${kachel}`)
}
})
test('Terminplaner-Kachel: fehlt der Hinweis (leer, null, kein Text) gibt es keine Zeile und keinen Knopf; kein_abo behält den Link', async () => {
for (const hinweis of [undefined, null, '', 5, { text: 'x' }]) {
const ohne = tile((await renderTermine([withTermine('a', 'off')], { verfuegbar: true, abo: { bestellbar: false, grund: 'gekuendigt', hinweis } })).html, 'termine')
assert.match(ohne, /fn-pill-off">Aus</)
assert.doesNotMatch(ohne, /fn-tile-cta|data-view=|fn-count|\[object/, String(hinweis))
const keinAbo = tile((await renderTermine([withTermine('a', 'off')], { verfuegbar: true, abo: { bestellbar: false, grund: 'kein_abo', hinweis } })).html, 'termine')
assert.ok(keinAbo.includes(`<p class="fn-count">${ZU_VERTRAEGE}</p>`), String(hinweis))
assert.doesNotMatch(keinAbo, /fn-tile-cta|data-view="termine"/)
}
})
test('Terminplaner-Kachel: mit Abo (bestellbar true) steht der Knopf da und ein Hinweis erscheint nicht', async () => {
const kachel = tile((await renderTermine([withTermine('a', 'off')], { verfuegbar: true, abo: { hinweis: 'Serverhinweis darf nicht erscheinen.' } })).html, 'termine')
assert.equal(knopfDerKachel(kachel)[1], 'Terminplaner einrichten · 149 €')
assert.doesNotMatch(kachel, /Serverhinweis|fn-count/)
})
test('Der Browser hat keine Kopie der Abo-Hinweise; der Link zu Verträge hat eine einzige Quelle (vertraege-link.js: Analyse, Terminplaner-Kachel und Ansicht #termine teilen sie)', async () => {
assert.doesNotMatch(src, /TERMINE_ABO_TEXTE|termineAboHinweis|KEIN_ABO_HINWEIS|ist gekündigt\. Für|gerade nicht aktiv|nicht geprüft werden/)
const quelle = readFileSync(new URL('../public/vertraege-link.js', import.meta.url), 'utf8')
assert.equal(quelle.split("view: 'vertraege'").length - 1, 1, 'Ziel, Text und Klasse stehen genau einmal in vertraege-link.js')
assert.equal(src.split('data-view="vertraege"').length - 1, 0, 'funktionen.js baut den Link aus ZU_VERTRAEGE, ohne eigenes Ziel')
assert.equal(src.split('ZU_VERTRAEGE_LINK =').length - 1, 1, 'ein Baustein für alle Stellen')
// Analyse-Kachel und -Seite (unveraendert) und Terminplaner-Kachel zeigen genau diesen Link
const analyse = tile(await render([project('a', { eligible: false, reason: 'kein_abo', reasonText: 'Kein Abo' })]), 'analyse')
assert.ok(analyse.includes(`<p class="fn-count">Im Wartungs-Abo enthalten. ${ZU_VERTRAEGE}</p>`), analyse)
const andere = tile(await render([project('a', { eligible: false, reason: 'gekuendigt', reasonText: 'Gekündigt' })]), 'analyse')
assert.ok(andere.includes('<p class="fn-count">Gekündigt</p>') && !andere.includes('data-view='), andere)
const stage = await renderActivation({ state: undefined, eligible: false, reason: 'kein_abo', reasonText: 'Kein Abo' })
assert.ok(stage.innerHTML.includes(`<p>Kein Abo ${ZU_VERTRAEGE}</p>`), stage.innerHTML)
const termine = tile((await renderTermine([withTermine('a', 'off')], { verfuegbar: true, abo: { bestellbar: false, grund: 'kein_abo', hinweis: 'T' } })).html, 'termine')
assert.ok(termine.includes(ZU_VERTRAEGE), termine)
})
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', hinweis: 'Serverhinweis ohne Abo.' } })
const kachel = tile(html, 'termine')
assert.match(kachel, /fn-pill-off">Aus</)
assert.doesNotMatch(kachel, /fn-tile-cta|data-view="termine"/)
assert.ok(kachel.includes(`<p class="fn-count">Serverhinweis ohne Abo. ${ZU_VERTRAEGE}</p>`), kachel)
})
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)
})
// ---- Webshop-Kachel (Task G1): nur bei shop.verfuegbar, Pille und Knopf nach dem Zustand des Funktionsdokuments
const shopProject = (id, state, extra = {}) => ({ projectId: id, projectName: `P ${id}`, liveUrl: '', features: { news, shop: { state, ...extra } }, analyse: { comingSoon: true } })
test('Webshop-Kachel: ohne shop.verfuegbar (fehlt, false) gar keine Kachel', async () => {
for (const extra of [{}, { shop: { verfuegbar: false } }, { shop: {} }, { shop: null }]) {
const html = await renderMit([shopProject('a', 'off')], extra)
assert.doesNotMatch(html, /data-fn-tile="shop"/, JSON.stringify(extra))
assert.doesNotMatch(html, /Webshop/, JSON.stringify(extra))
}
})
test('Webshop-Kachel: Text mit Preis, Knopf und Pille je Zustand, Ziel #webshop mit Projekt', async () => {
const faelle = [
['off', 'Webshop anfragen', 'Aus', 'off'],
['ordered', 'Zahlung offen', 'Zahlung offen', 'setup'],
['setup', 'In Einrichtung', 'In Einrichtung', 'setup'],
['active', 'Webshop öffnen', 'Aktiv', 'on'],
['disabled', 'Webshop ansehen', 'Abgeschaltet', 'off'],
]
for (const [state, knopf, pille, klasse] of faelle) {
const html = await renderMit([shopProject('p1', state)], { shop: { verfuegbar: true } })
const t = tile(html, 'shop')
assert.match(t, /<h3>Webshop<\/h3>/, state)
assert.match(t, /Dein eigener Online-Shop auf Deiner Website\. Zahlungen gehen direkt auf Dein Stripe-Konto\. 299 € einmalig\./, state)
assert.match(t, new RegExp(`<button type="button" class="kb-btn-primary fn-tile-cta" data-view="webshop" data-project="p1">${knopf}</button>`), state)
assert.match(t, new RegExp(`fn-pill fn-pill-${klasse}">${pille}</span>`), state)
}
// ohne Dokument (features.shop fehlt) gilt off
const ohne = await renderMit([{ projectId: 'p1', projectName: 'P', features: { news }, analyse: { comingSoon: true } }], { shop: { verfuegbar: true } })
assert.match(tile(ohne, 'shop'), /Webshop anfragen/)
assert.match(tile(ohne, 'shop'), /fn-pill-off">Aus</)
})
test('Webshop-Kachel: Preis aus dem Register maskiert, Rückfall 299 €, Kachel steht nach dem Terminplaner und vor der Analyse', async () => {
const html = await renderMit([shopProject('p1', 'off')], { shop: { verfuegbar: true }, features: { shop: { priceLabel: '<img src=x onerror=1> 349 €' } } })
assert.doesNotMatch(html, /<img/)
assert.match(tile(html, 'shop'), /&lt;img src=x onerror=1&gt; 349 € einmalig/)
const rueck = await renderMit([shopProject('p1', 'off')], { shop: { verfuegbar: true }, features: { shop: { priceLabel: '' } } })
assert.match(tile(rueck, 'shop'), /299 € einmalig/)
const mitAnalyse = await renderMit([{ ...shopProject('p1', 'off'), analyse: { state: 'off', eligible: true } }], { shop: { verfuegbar: true }, termine: { verfuegbar: true, bestellbar: true } })
const reihenfolge = ['news', 'termine', 'shop', 'analyse'].map((k) => mitAnalyse.indexOf(`data-fn-tile="${k}"`))
assert.ok(reihenfolge.every((i) => i >= 0))
assert.deepEqual([...reihenfolge].sort((a, b) => a - b), reihenfolge)
})
test('Webshop-Kachel: mehrere Websites ⇒ aktives zuerst, dann Einrichtung, dann abgeschaltet; ohne Website ein Hinweis statt Knopf', async () => {
const mehrere = await renderMit([shopProject('a', 'off'), shopProject('b', 'disabled'), shopProject('c', 'setup'), shopProject('d', 'active')], { shop: { verfuegbar: true } })
assert.match(tile(mehrere, 'shop'), /data-project="d">Webshop öffnen/)
assert.match(tile(mehrere, 'shop'), /aktiv für 1 von 4 Websites/)
const ohneAktiv = await renderMit([shopProject('a', 'off'), shopProject('b', 'disabled'), shopProject('c', 'ordered')], { shop: { verfuegbar: true } })
assert.match(tile(ohneAktiv, 'shop'), /data-project="c">Zahlung offen/)
const keine = await renderMit([], { shop: { verfuegbar: true } })
assert.match(tile(keine, 'shop'), /Sobald deine Website angelegt ist/)
assert.doesNotMatch(tile(keine, 'shop'), /<button/)
})
test('Webshop-Kachel: Projekt-ID maskiert, Klick legt das Wunschprojekt in #webshop-body', async () => {
const bose = await renderMit([shopProject('"><img src=x>', 'off')], { shop: { verfuegbar: true } })
assert.doesNotMatch(bose, /<img/)
// Klick-Handler: derselbe Aufbau wie bei der Terminplaner-Kachel
globalThis.fetch = async () => ({ ok: true, status: 200, json: async () => ({ projects: [shopProject('p7', 'active')], shop: { verfuegbar: true } }) })
const handlers = {}
const knopf = { dataset: { project: 'p7' }, addEventListener: (typ, fn) => { handlers[typ] = fn } }
const root = { innerHTML: '', querySelector: (sel) => (sel === '[data-fn-tile="shop"] .fn-tile-cta[data-project]' ? knopf : null) }
const body = { dataset: {} }
const vorher = globalThis.document
globalThis.document = { getElementById: (id) => (id === 'webshop-body' ? body : null) }
try {
await initFunktionen(root)
handlers.click({ currentTarget: knopf })
} finally {
globalThis.document = vorher
}
assert.equal(body.dataset.wunschProjekt, 'p7')
})