import { test } from 'node:test' import assert from 'node:assert/strict' import { installFakeDom } from './support/fakeDom.js' import { zeigeFunktionen, vergissMerker } from '../public/neu/ansicht-funktionen.js' import { daten, projekt, feature } from './support/knFunktionenDaten.js' const JETZT = () => new Date('2026-10-20T10:00:00Z') const MEINE = { bereich: 'funktionen', reiter: 'meine', query: '' } const ENTDECKEN = { bereich: 'funktionen', reiter: 'entdecken', query: '' } const mit = (fn) => async () => { vergissMerker(); const fx = installFakeDom(); try { await fn(fx) } finally { fx.restore() } } function umgebung(fx, { d = daten(), abo, api, post = { feature: { state: 'setup' }, ticketWoid: '100301' }, me = {} } = {}) { const wurzel = fx.document.createElement('div'); fx.document.body.append(wurzel) const u = { wurzel, d, rechnungen: [], rechnungenFehler: false, gegangen: [], aufrufe: [], gespeichert: {}, overlay: null } const basis = async (pfad, o = {}) => { u.aufrufe.push([o.method ?? 'GET', pfad]) if (o.method === 'POST') return post if (pfad === '/api/funktionen') return u.d if (pfad === '/api/invoices') { if (u.rechnungenFehler) throw Object.assign(new Error('x'), { status: 500, body: {} }); return { invoices: u.rechnungen, incomplete: false } } if (pfad === '/api/abo') { if (abo instanceof Error) throw abo; return abo ?? { subscription: null } } throw new Error(`unerwartet ${pfad}`) } const abh = { api: api ?? basis, jetzt: JETZT, speicher: { setItem: (k, v) => { u.gespeichert[k] = v } }, geheZu: (hash, o) => { u.gegangen.push(o ? [hash, o] : [hash]) }, oeffneOverlay: ({ schliesse }) => { u.overlay = schliesse }, beendeOverlay: () => { const s = u.overlay; u.overlay = null; s?.() }, } u.zeige = (route, ctx = {}) => zeigeFunktionen(wurzel, { route, me: { customer: { kundenartWirksam: 'unternehmen' }, viewAs: false, ...me }, nav: 'tab', ...ctx }, abh) return u } const tab = (fx, u, text) => fx.alle(u.wurzel, '[role="tab"]').find((t) => t.textContent === text) test('Meine: Liste mit Name, zweiter Zeile und Pille; Tipp öffnet die Etage mit der Website der Zeile, eine angenommene Anfrage die Bestätigung', mit(async (fx) => { const u = umgebung(fx, { d: daten({ projekte: [projekt('p1', 'Bäckerei', { termine: feature('active'), shop: feature('off', { anfrageStatus: 'angenommen' }) }), projekt('p2', 'Café', { termine: feature('active') })] }) }) await u.zeige(MEINE) const zeilen = fx.alle(u.wurzel, 'li[data-fn]') assert.deepEqual(zeilen.map((z) => [z.getAttribute('data-fn'), z.getAttribute('data-projekt')]), [['termine', 'p1'], ['termine', 'p2'], ['shop', 'p1']]) assert.match(fx.text(zeilen[1]), /Terminplaner.*Café.*Aktiv/) await fx.klick(fx.finde(zeilen[0], 'button')) await fx.klick(fx.finde(zeilen[1], 'button')) assert.deepEqual(u.gegangen, [['#termine?projekt=p1'], ['#termine?projekt=p2']]) await fx.klick(fx.finde(zeilen[2], 'button')) assert.ok(fx.finde(u.wurzel, 'section.kn-fn-einschalten[data-key="shop"][data-projekt="p1"]')) })) test('Meine leer: der erste Aufruf springt ersetzend zu Entdecken, ein ausdrücklich gewähltes Meine zeigt den Leerzustand', mit(async (fx) => { const u = umgebung(fx) await u.zeige(MEINE) assert.deepEqual(u.gegangen, [['#entdecken', { ersetzen: true }]]) await u.zeige(ENTDECKEN) u.gegangen.length = 0 await fx.klick(tab(fx, u, 'Meine')) assert.deepEqual(u.gegangen, [['#funktionen', { ersetzen: true }]]) await u.zeige(MEINE) // so ruft der Router danach auf assert.equal(u.gegangen.length, 1) assert.match(fx.text(u.wurzel), /Du hast noch keine Funktion eingeschaltet\./) })) test('Rechnungszeile oben: die Ansicht lädt GET /api/invoices selbst (ohne Zähler), nur balance > 0, in Meine und Entdecken; fällt der Abruf aus, bleibt die Liste', mit(async (fx) => { const u = umgebung(fx, { d: daten({ projekte: [projekt('p1', 'B', { news: feature('active') })] }) }) await u.zeige(MEINE) assert.equal(fx.finde(u.wurzel, '.kn-rechnung-offen'), null) assert.ok(u.aufrufe.some(([m, p]) => m === 'GET' && p === '/api/invoices')) u.rechnungen = [{ id: 'r0', number: 'RE-2026-0040', balance: 0, portalUrl: null }, { id: 'r1', number: 'RE-2026-0042', balance: 50, portalUrl: 'https://invoice.stripe.com/i/x' }] await u.zeige(MEINE) assert.match(fx.text(fx.finde(u.wurzel, '.kn-rechnung-offen')), /^Rechnung offen · RE-2026-0042 · 50,00\s€ · Zahlen$/) assert.ok(fx.finde(u.wurzel, 'li[data-fn]')) await u.zeige(ENTDECKEN) assert.ok(fx.finde(u.wurzel, '.kn-rechnung-offen')) u.rechnungenFehler = true await u.zeige(MEINE) assert.equal(fx.finde(u.wurzel, '.kn-rechnung-offen'), null) assert.ok(fx.finde(u.wurzel, 'li[data-fn]')) })) test('Entdecken: Bild, Name, Beschreibung, "Im … Abo enthalten", Knopf; Katalogpreise erscheinen nie', mit(async (fx) => { const u = umgebung(fx, { d: daten({ katalogEdit: { termine: { priceLabel: '149 €' } } }) }) await u.zeige(ENTDECKEN) const e = fx.alle(u.wurzel, 'section[data-fn]') assert.deepEqual(e.map((x) => x.getAttribute('data-fn')), ['news', 'termine', 'shop', 'unterkunft', 'zeiten', 'analyse']) assert.equal(fx.finde(e[1], 'img').getAttribute('src'), '/neu/bilder/termine.webp') assert.deepEqual(['alt', 'width', 'height', 'loading'].map((a) => fx.finde(e[1], 'img').getAttribute(a)), ['Beispiel: Terminplaner', '960', '600', 'lazy']) assert.match(fx.text(e[1]), /Terminplaner.*Beschreibung Terminplaner\..*Im Wartungs-Abo enthalten/) assert.match(fx.text(e[5]), /Im Hosting-Abo enthalten/) assert.equal(fx.finde(e[1], '[data-aktion="einschalten"]').textContent, 'Einschalten') assert.doesNotMatch(fx.text(u.wurzel), /€|Euro/) })) test('Einschalten-Tap (eine Website) geht je Abo-Lage an den richtigen Ort', mit(async (fx) => { const faelle = [ // [Abo, Funktion, Sprung ('seite' = Bestätigung ohne Sprung)] [{ produkt: 'wartung' }, 'news', 'seite'], [{ produkt: 'wartung' }, 'termine', ['#termine?projekt=p1']], [{ produkt: 'wartung' }, 'shop', ['#webshop?projekt=p1']], [{ produkt: 'hosting' }, 'analyse', ['#analyse?projekt=p1']], [{ produkt: 'hosting' }, 'zeiten', ['#wechsel']], [{ produkt: null, laeuft: false }, 'zeiten', ['#bestellen-wartung']], [{ produkt: null, laeuft: false }, 'analyse', ['#bestellen-hosting']], ] for (const [abo, key, sprung] of faelle) { const u = umgebung(fx, { d: daten({ abo }) }) await u.zeige(ENTDECKEN) await fx.klick(fx.finde(u.wurzel, `section[data-fn="${key}"] [data-aktion="einschalten"]`)) if (sprung === 'seite') { assert.deepEqual(u.gegangen, []); assert.ok(fx.finde(u.wurzel, `section.kn-fn-einschalten[data-key="${key}"]`)); continue } assert.deepEqual(u.gegangen, [sprung], `${abo.produkt} ${key}`) if (sprung[0] === '#wechsel') assert.deepEqual(JSON.parse(u.gespeichert['kn-wechsel-funktion']), { key, projectId: 'p1' }) } })) test('Einschalten-Tap mit Meldung: gekündigtes Abo zeigt den Text im Eintrag und geht nirgends hin', mit(async (fx) => { const u = umgebung(fx, { d: daten({ abo: { gekuendigt: true } }) }) await u.zeige(ENTDECKEN) const news = fx.finde(u.wurzel, 'section[data-fn="news"]') await fx.klick(fx.finde(news, '[data-aktion="einschalten"]')) assert.match(fx.text(fx.finde(news, '[role="alert"]')), /^Dein Abo ist gekündigt\./) assert.deepEqual(u.gegangen, []) })) test('Privatkunde: Webshop zeigt den Hinweis statt des Knopfes; ohne Websites steht ein Hinweis statt der Knöpfe', mit(async (fx) => { const u = umgebung(fx, { d: daten({ kundenart: 'privat' }), me: { customer: { kundenartWirksam: 'privat' } } }) await u.zeige(ENTDECKEN) const shop = fx.finde(u.wurzel, 'section[data-fn="shop"]') assert.match(fx.text(shop), /Webshop gibt es für Betriebe\. Stimmt das nicht\? Schreib uns im Chat\./) assert.equal(fx.finde(shop, '[data-aktion="einschalten"]'), null) const leer = umgebung(fx, { d: daten({ projekte: [] }) }) await leer.zeige(ENTDECKEN) assert.match(fx.text(leer.wurzel), /Sobald deine Website angelegt ist, kannst du Funktionen einschalten\./) assert.equal(fx.alle(leer.wurzel, '[data-aktion="einschalten"]').length, 0) })) test('Zwei Websites: Einschalten fragt "Für welche Website?" und bietet nur Websites ohne die Funktion', mit(async (fx) => { const u = umgebung(fx, { d: daten({ projekte: [projekt('p1', 'Bäckerei', { news: feature('active') }), projekt('p2', 'Café'), projekt('p3', 'Kiosk')] }) }) await u.zeige(ENTDECKEN) const news = fx.finde(u.wurzel, 'section[data-fn="news"]') await fx.klick(fx.finde(news, '[data-aktion="einschalten"]')) const wahl = fx.finde(news, '[role="group"]') assert.equal(wahl.getAttribute('aria-label'), 'Für welche Website?') assert.deepEqual(fx.alle(wahl, 'button').map((b) => b.textContent), ['Café', 'Kiosk']) await fx.klick(fx.alle(wahl, 'button')[1]) assert.ok(fx.finde(u.wurzel, 'section.kn-fn-einschalten[data-key="news"][data-projekt="p3"]')) })) test('Zwei Websites: ein zweiter Tipp schließt die Auswahl wieder', mit(async (fx) => { const u = umgebung(fx, { d: daten({ projekte: [projekt('p1', 'Bäckerei'), projekt('p2', 'Café')] }) }) await u.zeige(ENTDECKEN) const news = fx.finde(u.wurzel, 'section[data-fn="news"]') await fx.klick(fx.finde(news, '[data-aktion="einschalten"]')) assert.ok(fx.finde(news, '[role="group"]')) await fx.klick(fx.finde(news, '[data-aktion="einschalten"]')) assert.equal(fx.finde(news, '[role="group"]'), null) })) test('Laden und Fehler: erst "Wird geladen …", dann Text und "Nochmal versuchen"; Antwort ohne Abo-Modell', mit(async (fx) => { let los; const warten = new Promise((r) => { los = r }); let n = 0 const u = umgebung(fx, { api: async (pfad) => { if (pfad === '/api/invoices') return { invoices: [], incomplete: false }; if (++n === 1) { await warten; throw Object.assign(new Error('x'), { status: 500, body: {} }) } return n === 2 ? daten() : { ...daten(), modell: undefined } } }) const lauf = u.zeige(ENTDECKEN) assert.match(fx.text(u.wurzel), /Wird geladen …/) los(); await lauf assert.match(fx.text(u.wurzel), /Die Funktionen konnten gerade nicht geladen werden\./) await fx.klick(fx.finde(u.wurzel, '[data-aktion="nochmal"]')) assert.equal(fx.alle(u.wurzel, 'section[data-fn]').length, 6) await u.zeige(ENTDECKEN) assert.match(fx.text(u.wurzel), /Die Funktionen sind gerade nicht verfügbar\. Lade die Seite neu\./) })) test('veraltete Antwort überschreibt die neuere nicht', mit(async (fx) => { let langsam; const erster = new Promise((r) => { langsam = r }); let n = 0 const mit1 = (name) => daten({ katalogEdit: { news: { name } }, projekte: [projekt('p1', 'B', { news: feature('active') })] }) const u = umgebung(fx, { api: async (pfad) => (pfad === '/api/invoices' ? { invoices: [], incomplete: false } : ++n === 1 ? (await erster, mit1('Alt-Pinnwand')) : mit1('Neu-Pinnwand')) }) const a = u.zeige(MEINE); const b = u.zeige(MEINE) await b; langsam(); await a assert.match(fx.text(u.wurzel), /Neu-Pinnwand/) assert.doesNotMatch(fx.text(u.wurzel), /Alt-Pinnwand/) })) test('Fremdtext und fehlendes Bild: Namen nur als Text, ohne Bild bleibt der Eintrag lesbar', mit(async (fx) => { const u = umgebung(fx, { d: daten({ katalogEdit: { news: { name: 'News', beschreibung: 'x' } }, projekte: [projekt('p1', 'Café')] }) }) await u.zeige(ENTDECKEN) assert.equal(fx.alle(u.wurzel, 'b, i, u').length, 0) const news = fx.finde(u.wurzel, 'section[data-fn="news"]') await fx.sende(fx.finde(news, 'img'), 'error') assert.equal(fx.finde(news, 'img'), null) assert.match(fx.text(news), /News<\/b>/) })) test('Bildadresse: nur /neu/bilder/.webp; alles andere erscheint ohne Bild', mit(async (fx) => { for (const bild of ['javascript:alert(1)', 'https://example.org/x.webp', '//example.org/x.webp', '/neu/bilder/../x.webp', '', null]) { const u = umgebung(fx, { d: daten({ katalogEdit: { news: { bild } } }) }) await u.zeige(ENTDECKEN) assert.equal(fx.finde(fx.finde(u.wurzel, 'section[data-fn="news"]'), 'img'), null, String(bild)) } })) test('Meine mit zwei Websites zeigt den Websitenamen in der zweiten Zeile, mit einer den Status', mit(async (fx) => { const einzel = umgebung(fx, { d: daten({ projekte: [projekt('p1', 'Bäckerei', { news: feature('active') })] }) }) await einzel.zeige(MEINE) assert.match(fx.text(fx.finde(einzel.wurzel, 'li[data-fn]')), /News-Pinnwand.*Läuft auf deiner Website.*Aktiv/) const zwei = umgebung(fx, { d: daten({ projekte: [projekt('p1', 'Bäckerei', { news: feature('active') }), projekt('p2', 'Café')] }) }) await zwei.zeige(MEINE) assert.match(fx.text(fx.finde(zwei.wurzel, 'li[data-fn]')), /News-Pinnwand.*Bäckerei.*Aktiv/) }))