Die Ansicht #termine folgt dem Zustand aus GET /api/termine/einstellungen: off mit laufendem Abo zeigt den Fragebogen samt Bestellseite, ohne Abo den Hinweis des Servers (termine.hinweis, R111) und bei kein_abo den Link zu Vertraege aus einer gemeinsamen Quelle (vertraege-link.js); ordered und setup zeigen die Statuskarte und die Reiter Leistungen, Team & Plaetze und Einstellungen nur zum Lesen (ohne Reiter Termine); active und disabled bleiben wie bisher. Rueckkehr von Stripe: #termine?bestellt=1&projekt=<id> und #termine?projekt=<id> waehlen das Projekt, "Danke!" nur mit bestellt=1, die Adresse wird einmal gelesen und bereinigt. R110: GET /einstellungen liefert bestellung und offen auch in ordered (ohne Abo-Punkt, abo bleibt null); scheitert "Zahlung fortsetzen", zeichnet die Huelle Liste und Daten neu (bei 409 zustimmung_fehlt mit dem Bestellformular, sonst nach dem neuen Zustand, die Meldung bleibt am Knopf, solange die Bestellung offen ist). Dazu: "null" als Text im lesbaren Reiter Leistungen entfernt, Export und Status der Einstellungen erst ab der Freischaltung. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
487 lines
31 KiB
JavaScript
487 lines
31 KiB
JavaScript
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 /analyse-view.js und /geraete.js und der relative ./vertraege-link.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')}'`)
|
||
.replace("'./vertraege-link.js'", `'${fileUrl('vertraege-link.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', 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 & 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 = '<img src=x onerror=1>"'&'
|
||
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<b>"/)
|
||
// 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<b>"/, 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<b>&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)
|
||
})
|