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('', 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: '' } } })
assert.doesNotMatch(bose, / {
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, /31 €<\/strong>/)
assert.doesNotMatch(stage.innerHTML, /Mehrwertsteuer/, 'der Preishinweis kommt aus ustHinweis (Block F)')
assert.match(stage.innerHTML, /einmalig, <\/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, /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: '', items: [] } } } })
assert.doesNotMatch(bose.stage.innerHTML, / {
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 = ''
const knopfDerKachel = (kachel) => kachel.match(/
`), `${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(`
${ZU_VERTRAEGE}
`), 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(`