From 437947cdf653a7d2585845ec34724f8c1e711407 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 18:01:27 +0000 Subject: [PATCH] =?UTF-8?q?Funktionen=20neu:=20Einschalt-Logik=20(Vorpr?= =?UTF-8?q?=C3=BCfung,=20Fristen,=20409-Codes)=20und=20Testdaten?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- public/neu/funktion-einschalten.js | 198 +++++++++++++++++++++++++++++ test/knEinschaltenLogik.test.js | 128 +++++++++++++++++++ test/knFunktionenVertrag.test.js | 41 ++++++ test/support/knFunktionenDaten.js | 74 +++++++++++ 4 files changed, 441 insertions(+) create mode 100644 public/neu/funktion-einschalten.js create mode 100644 test/knEinschaltenLogik.test.js create mode 100644 test/knFunktionenVertrag.test.js create mode 100644 test/support/knFunktionenDaten.js diff --git a/public/neu/funktion-einschalten.js b/public/neu/funktion-einschalten.js new file mode 100644 index 0000000..ec8f9f8 --- /dev/null +++ b/public/neu/funktion-einschalten.js @@ -0,0 +1,198 @@ +/* Kundenansicht neu (P3): Einschalten einer Funktion im Abo-Modell. + Dieser Teil ist rein (ohne DOM, ohne Netz) und läuft unter Node: Wege nach der Vorprüfung, Fristen, Körper des Absendens, Fehlerfolgen. + Die Vorprüfung spiegelt aboDeckt (server/services/aboFunktionen.js) nur, um den richtigen Ort zu wählen; entschieden wird immer vom Server + (POST /api/funktionen//einschalten, 409 mit Code). Kundentexte sind Festwerte (TEXTE), Fremdtext kommt nie als HTML in die Seite. */ + +/** Etage 2 je Funktion (Schlüssel der Hülle in FUNKTIONS_MODULE, Hash `#?projekt=`). */ +export const ETAGE_FUER = Object.freeze({ news: 'news', termine: 'termine', shop: 'webshop', unterkunft: 'buchungen', zeiten: 'zeiten', analyse: 'analyse' }) + +/** Festtexte der Oberfläche (du-Form, kein Preis). */ +export const TEXTE = Object.freeze({ + stundenkonto: 'Die Einrichtung geht von deinem Stundenkonto ab, auch wenn das Guthaben noch nicht reicht. Extrakosten entstehen keine.', + nichtVerfuegbar: 'Diese Funktion ist für dich gerade nicht verfügbar.', + gekuendigt: 'Dein Abo ist gekündigt. Solange die Kündigung läuft, kannst du nichts Neues einschalten.', + nichtAktiv: 'Dein Abo ist noch nicht aktiv. Sobald die erste Zahlung da ist, kannst du einschalten.', + falschesAbo: 'Dein Abo enthält diese Funktion nicht.', + beginntSpaeter: (datum) => `Dein Abo beginnt am ${datum}. Ab dann kannst du einschalten.`, + fassungVeraltet: 'Die Bedingungen wurden aktualisiert. Bitte lies die Zeile noch einmal.', + allgemein: 'Das Einschalten hat gerade nicht geklappt. Versuch es gleich noch einmal oder schreib uns im Chat.', + nurBetrieb: (name) => `${name} gibt es für Betriebe. Stimmt das nicht? Schreib uns im Chat.`, + registerFehlt: 'Einschalten ist gerade nicht möglich. Schreib uns im Chat.', + kundenansicht: 'In der Kundenansicht kann nichts eingeschaltet werden.', +}) + +/** Die Erklärung der Funktion aus dem Katalog der Antwort (einzige Stelle für das Register); fehlt sie, null. */ +export function consentVon(daten, key) { + const eintrag = Array.isArray(daten?.katalog) ? daten.katalog.find((e) => e?.key === key) : null + const consent = eintrag?.consent + return consent && typeof consent === 'object' && Array.isArray(consent.items) ? consent : null +} + +/** Anfragestatus von Webshop oder Buchungskalender an einer Website (N18, einzige Stelle); sonst ''. */ +export function anfrageStatusVon(projekt, key) { + const status = projekt?.features?.[key]?.anfrageStatus + return typeof status === 'string' ? status : '' +} + +/** + * Wohin führt „Einschalten“? Prüfreihenfolge wie der Server (V5): verfügbar, gekündigt, kein Abo, nicht aktiv, Hosting bei einer + * Wartungs-Funktion, nur Betrieb; danach Etage (Analyse, Fragebogen, Anfrage ohne Zusage) oder die Bestätigungsseite. + * Alles außer 'unternehmen' gilt als privat (E4). + */ +export function einschaltWeg({ eintrag, projekt, abo, kundenart }) { + if (eintrag?.verfuegbar !== true) return { art: 'meldung', text: TEXTE.nichtVerfuegbar } + if (abo?.gekuendigt === true) return { art: 'meldung', text: TEXTE.gekuendigt } + if (!abo || abo.produkt === null || abo.produkt === undefined) return { art: 'bestellen', produkt: eintrag.enthaltenIn } + if (abo.laeuft !== true) return { art: 'meldung', text: TEXTE.nichtAktiv } + if (eintrag.enthaltenIn === 'wartung' && abo.produkt === 'hosting') return { art: 'wechsel' } + if (eintrag.nurBetrieb === true && kundenart !== 'unternehmen') return { art: 'meldung', text: TEXTE.nurBetrieb(eintrag.name) } + if (eintrag.key === 'analyse') return { art: 'etage', etage: ETAGE_FUER.analyse } + if (eintrag.ablauf === 'fragebogen') return { art: 'etage', etage: ETAGE_FUER[eintrag.key] } + if (eintrag.ablauf === 'anfrage' && anfrageStatusVon(projekt, eintrag.key) !== 'angenommen') return { art: 'etage', etage: ETAGE_FUER[eintrag.key] } + return { art: 'seite' } +} + +const TAG = /^\d{4}-\d{2}-\d{2}$/ + +/** Berliner Kalendertag 'JJJJ-MM-TT' eines Zeitpunkts (ISO-Text, Date) oder eines Tagestextes; unlesbar ergibt ''. */ +function berlinTag(wert) { + if (typeof wert === 'string' && TAG.test(wert)) return wert + const ms = wert instanceof Date ? wert.getTime() : (typeof wert === 'string' && wert !== '' ? Date.parse(wert) : Number.NaN) + if (Number.isNaN(ms)) return '' + return new Date(ms).toLocaleDateString('sv-SE', { timeZone: 'Europe/Berlin' }) +} + +/** Der Berliner Tag nach dem Fristende (Zeitpunkt) als 'JJJJ-MM-TT'; unlesbar ergibt ''. */ +export function tagNachFrist(wert) { + const tag = berlinTag(wert) + if (!tag) return '' + const [j, m, t] = tag.split('-').map(Number) + return new Date(Date.UTC(j, m - 1, t + 1)).toISOString().slice(0, 10) +} + +/** 'JJJJ-MM-TT' → 'TT.MM.'; sonst ''. */ +export function datumKurz(tag) { + if (typeof tag !== 'string' || !TAG.test(tag)) return '' + const [, m, t] = tag.split('-') + return `${t}.${m}.` +} + +/** 'JJJJ-MM-TT' → 'TT.MM.JJJJ' wie datumDe des Servers; sonst ''. */ +function datumLang(tag) { + if (typeof tag !== 'string' || !TAG.test(tag)) return '' + const [j, m, t] = tag.split('-') + return `${t}.${m}.${j}` +} + +/** + * Braucht der Kunde das Häkchen „sofortiger Beginn“, und ab wann ginge es ohne? Nur Privatkunden (leer zählt als privat) und nur bis + * zum Fristende (am Ende selbst noch Frist). Eine Antwort des Servers (serverAb: ISO oder Tag) gewinnt. + * @returns {{ noetig: boolean, ab: string }} ab = 'JJJJ-MM-TT' oder '' + */ +export function beginnLage({ kundenart, abo, jetzt, serverAb = '' }) { + const vomServer = berlinTag(serverAb) + if (vomServer) return { noetig: true, ab: vomServer } + const bis = abo?.widerrufsfristBis + const ende = typeof bis === 'string' ? Date.parse(bis) : Number.NaN + if (kundenart !== 'unternehmen' && ende >= jetzt.getTime()) return { noetig: true, ab: tagNachFrist(bis) } + return { noetig: false, ab: '' } +} + +/** Körper des Absendens: Fassung aus dem Register, Haken nur 'beginn'; die Kundenart sendet der Client nie. */ +export function einschaltKoerper({ projectId, consent, beginnGesetzt }) { + return { projectId, zustimmung: { version: consent.version, haken: beginnGesetzt ? ['beginn'] : [] } } +} + +/** Nur https ohne Zugangsdaten oder Pfade unter /rechtliches/ und /vertragsunterlagen/; sonst ''. */ +export function sichereAdresse(url) { + if (typeof url !== 'string' || url === '') return '' + if (/[\s\u0000-\u001f\u007f\\]/.test(url) || url.includes('..') || /%(2e|2f|5c)/i.test(url)) return '' + if (url.startsWith('/rechtliches/') || url.startsWith('/vertragsunterlagen/')) return url + if (!url.startsWith('https://')) return '' + try { + const u = new URL(url) + return u.protocol === 'https:' && u.hostname !== '' && u.username === '' && u.password === '' ? url : '' + } catch { + return '' + } +} + +const UNTERLAGEN_WOERTER = [['Leistungsbeschreibung', 'leistungsbeschreibung'], ['Auftragsverarbeitung', 'avv']] + +/** + * Die Zeile aus dem Register als Stücke [{ text, href? }]: das erste Vorkommen von „Leistungsbeschreibung“ und „Auftragsverarbeitung“ + * wird zum Link, wenn die Unterlage eine sichere Adresse hat. Fehlt das Wort bei einer sicheren Unterlage, hängt ihr Link hinten an + * (zwei Links mit „ · “ dazwischen). Benachbarte Textstücke sind zusammengefasst. + */ +export function linkzeile(text, unterlagen) { + const basis = typeof text === 'string' ? text : '' + const treffer = [] + const anhang = [] + for (const [wort, schluessel] of UNTERLAGEN_WOERTER) { + const href = sichereAdresse(unterlagen?.[schluessel]?.url) + if (!href) continue + const pos = basis.indexOf(wort) + if (pos >= 0) treffer.push({ pos, wort, href }) + else anhang.push({ wort, href }) + } + treffer.sort((a, b) => a.pos - b.pos) + const stuecke = [] + const textStueck = (t) => { + if (t === '') return + const letztes = stuecke.at(-1) + if (letztes && letztes.href === undefined) letztes.text += t + else stuecke.push({ text: t }) + } + let ab = 0 + for (const t of treffer) { + textStueck(basis.slice(ab, t.pos)) + stuecke.push({ text: t.wort, href: t.href }) + ab = t.pos + t.wort.length + } + textStueck(basis.slice(ab)) + anhang.forEach((a, i) => { + textStueck(i === 0 ? ' ' : ' · ') + stuecke.push({ text: a.wort, href: a.href }) + }) + return stuecke +} + +/** + * Was folgt aus einem Fehler des Absendens? fehler = ApiFehler ({ status, code?, message, body }). + * wechsel/bestellen/etage führen weg, beginn und spaeter sperren den Knopf bis zum Datum (beginn: Häkchen möglich), neu-laden lädt die + * Liste neu, schon heißt: ist schon eingeschaltet, meldung zeigt den Text. Alles ohne eigene Regel zeigt den Text des Servers. + */ +export function fehlerAktion(fehler, { eintrag }) { + const body = fehler?.body && typeof fehler.body === 'object' ? fehler.body : {} + const code = fehler?.code || body.code || '' + const servertext = typeof fehler?.message === 'string' && fehler.message !== '' ? fehler.message : TEXTE.allgemein + const meldung = (text) => ({ art: 'meldung', text }) + switch (code) { + case 'nicht_verfuegbar': + return meldung(TEXTE.nichtVerfuegbar) + case 'abo_fehlt': { + if (body.wechsel === true) return { art: 'wechsel' } + if (body.grund === 'kein_abo') return { art: 'bestellen', produkt: eintrag.enthaltenIn } + if (body.grund === 'gekuendigt') return meldung(TEXTE.gekuendigt) + if (body.grund === 'nicht_aktiv') return meldung(TEXTE.nichtAktiv) + if (body.grund === 'falsches_abo') return meldung(TEXTE.falschesAbo) + if (body.grund === 'beginnt_spaeter') { + const tag = berlinTag(body.ab) + return tag ? { art: 'spaeter', ab: body.ab, text: TEXTE.beginntSpaeter(datumLang(tag)) } : meldung(servertext) + } + return meldung(servertext) + } + case 'nur_betrieb': + return meldung(TEXTE.nurBetrieb(eintrag.name)) + case 'bestellung_offen': + return { art: 'etage', etage: ETAGE_FUER[eintrag.key] } + case 'beginn_noetig': + return { art: 'beginn', ab: body.ab } + case 'bereits_eingeschaltet': + return { art: 'schon' } + case 'fassung_veraltet': + return { art: 'neu-laden', text: TEXTE.fassungVeraltet } + default: + return meldung(servertext) + } +} diff --git a/test/knEinschaltenLogik.test.js b/test/knEinschaltenLogik.test.js new file mode 100644 index 0000000..341614c --- /dev/null +++ b/test/knEinschaltenLogik.test.js @@ -0,0 +1,128 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { ETAGE_FUER, consentVon, anfrageStatusVon, einschaltWeg, tagNachFrist, datumKurz, beginnLage, einschaltKoerper, linkzeile, sichereAdresse, fehlerAktion } from '../public/neu/funktion-einschalten.js' +import { daten, katalogEintrag, projekt, feature } from './support/knFunktionenDaten.js' + +const abo = (produkt, extra = {}) => ({ produkt, laeuft: produkt !== null, gekuendigt: false, widerrufsfristBis: null, ...extra }) +const angenommen = (key) => projekt('p1', 'B', { [key]: feature('off', { anfrageStatus: 'angenommen' }) }) +const GEKUENDIGT = 'Dein Abo ist gekündigt. Solange die Kündigung läuft, kannst du nichts Neues einschalten.' +const NICHT_AKTIV = 'Dein Abo ist noch nicht aktiv. Sobald die erste Zahlung da ist, kannst du einschalten.' +const NUR_BETRIEB = 'Webshop gibt es für Betriebe. Stimmt das nicht? Schreib uns im Chat.' + +test('Etagen-Tabelle und Zugriffsfunktionen', () => { + assert.deepEqual(ETAGE_FUER, { news: 'news', termine: 'termine', shop: 'webshop', unterkunft: 'buchungen', zeiten: 'zeiten', analyse: 'analyse' }) + assert.equal(consentVon(daten(), 'news').version, '2026-10-20') + assert.equal(consentVon(daten({ katalogEdit: { analyse: { consent: null } } }), 'analyse'), null) + assert.equal(consentVon(daten(), 'gibtsnicht'), null) + assert.equal(anfrageStatusVon(angenommen('shop'), 'shop'), 'angenommen') + assert.equal(anfrageStatusVon(projekt('p1', 'B'), 'shop'), '') +}) + +test('einschaltWeg: Vorprüfung in der Reihenfolge des Servers (V5)', () => { + const faelle = [ // [erwartet, key, abo, kundenart, projekt, Katalogänderung] + [{ art: 'meldung', text: 'Diese Funktion ist für dich gerade nicht verfügbar.' }, 'news', abo('wartung'), 'unternehmen', undefined, { verfuegbar: false }], + [{ art: 'meldung', text: GEKUENDIGT }, 'news', abo('wartung', { gekuendigt: true })], + [{ art: 'bestellen', produkt: 'wartung' }, 'news', abo(null)], + [{ art: 'bestellen', produkt: 'hosting' }, 'analyse', abo(null)], + [{ art: 'meldung', text: NICHT_AKTIV }, 'news', abo('wartung', { laeuft: false })], + [{ art: 'wechsel' }, 'news', abo('hosting')], + [{ art: 'etage', etage: 'analyse' }, 'analyse', abo('hosting')], + [{ art: 'meldung', text: NUR_BETRIEB }, 'shop', abo('wartung'), 'privat'], + [{ art: 'meldung', text: NUR_BETRIEB }, 'shop', abo('wartung'), ''], // leere Kundenart zählt als privat (E4) + [{ art: 'seite' }, 'news', abo('wartung')], + [{ art: 'seite' }, 'zeiten', abo('wartung')], + [{ art: 'etage', etage: 'termine' }, 'termine', abo('wartung')], + [{ art: 'etage', etage: 'webshop' }, 'shop', abo('wartung')], + [{ art: 'etage', etage: 'buchungen' }, 'unterkunft', abo('wartung')], + [{ art: 'seite' }, 'shop', abo('wartung'), 'unternehmen', angenommen('shop')], + [{ art: 'seite' }, 'unterkunft', abo('wartung'), 'unternehmen', angenommen('unterkunft')], + ] + for (const [erwartet, key, a, kundenart = 'unternehmen', p = projekt('p1', 'B'), edit = {}] of faelle) { + assert.deepEqual(einschaltWeg({ eintrag: katalogEintrag(key, edit), projekt: p, abo: a, kundenart }), erwartet, `${key} ${JSON.stringify(a)} ${kundenart}`) + } +}) + +test('tagNachFrist und datumKurz: Berliner Folgetag, Sommer- und Winterzeit', () => { + assert.equal(tagNachFrist('2026-10-30T22:59:59.999Z'), '2026-10-31') // 23:59:59 MEZ + assert.equal(tagNachFrist('2026-08-14T21:59:59.999Z'), '2026-08-15') // 23:59:59 MESZ + assert.equal(tagNachFrist('2026-12-31T22:59:59.999Z'), '2027-01-01') + assert.deepEqual([tagNachFrist(null), tagNachFrist('kein Datum'), datumKurz('2026-10-31'), datumKurz('')], ['', '', '31.10.', '']) +}) + +test('beginnLage: Häkchen nur für privat bis zum Fristende; die Antwort des Servers gewinnt', () => { + const jetzt = new Date('2026-10-20T10:00:00Z') + const frist = '2026-10-30T22:59:59.999Z' + const lage = (kundenart, widerrufsfristBis, extra = {}) => beginnLage({ kundenart, abo: { widerrufsfristBis }, jetzt, ...extra }) + assert.deepEqual(lage('privat', frist), { noetig: true, ab: '2026-10-31' }) + assert.deepEqual(lage('', frist), { noetig: true, ab: '2026-10-31' }) + assert.deepEqual(lage('unternehmen', frist), { noetig: false, ab: '' }) + assert.deepEqual(lage('privat', null), { noetig: false, ab: '' }) + assert.equal(lage('privat', frist, { jetzt: new Date(frist) }).noetig, true) // genau am Ende noch Frist + assert.equal(lage('privat', frist, { jetzt: new Date('2026-10-30T23:00:00.000Z') }).noetig, false) + assert.deepEqual(lage('unternehmen', null, { serverAb: '2026-11-02T23:00:00.000Z' }), { noetig: true, ab: '2026-11-03' }) // Form von P5: Berliner Mitternacht + assert.equal(lage('privat', null, { serverAb: '2026-11-03' }).ab, '2026-11-03') +}) + +test('einschaltKoerper: Fassung aus dem Register, Haken nur "beginn", nie die Kundenart', () => { + const c = consentVon(daten(), 'news') + assert.deepEqual(einschaltKoerper({ projectId: 'p1', consent: c, beginnGesetzt: false }), { projectId: 'p1', zustimmung: { version: '2026-10-20', haken: [] } }) + const mit = einschaltKoerper({ projectId: 'p1', consent: c, beginnGesetzt: true }) + assert.deepEqual(mit.zustimmung.haken, ['beginn']) + assert.equal('kundenart' in mit, false) +}) + +test('sichereAdresse: nur https und Pfade unter /rechtliches/ oder /vertragsunterlagen/', () => { + for (const gut of ['/rechtliches/avv-2026-10-05.html', '/vertragsunterlagen/funktionen-leistungsbeschreibung.html', 'https://example.org/a.pdf']) assert.equal(sichereAdresse(gut), gut) + for (const schlecht of ['javascript:alert(1)', 'http://example.org/', '//example.org/x', '/rechtliches/../geheim', '/rechtliches/%2e%2e/x', 'https://user:pw@example.org/', '/api/abo', '/rechtliches/a b', '', null, 42]) assert.equal(sichereAdresse(schlecht), '', String(schlecht)) +}) + +test('linkzeile: die zwei Wörter werden Links; unsichere oder fehlende Adressen nicht', () => { + const text = 'Es gelten Leistungsbeschreibung und Auftragsverarbeitung (Fassung 2026-10-05).' + const u = katalogEintrag('termine').unterlagen + assert.deepEqual(linkzeile(text, u), [ + { text: 'Es gelten ' }, { text: 'Leistungsbeschreibung', href: u.leistungsbeschreibung.url }, { text: ' und ' }, + { text: 'Auftragsverarbeitung', href: u.avv.url }, { text: ' (Fassung 2026-10-05).' }, + ]) + assert.deepEqual(linkzeile('Es gilt die Leistungsbeschreibung (Fassung 2026-10-04).', { leistungsbeschreibung: u.leistungsbeschreibung, avv: null }), [ + { text: 'Es gilt die ' }, { text: 'Leistungsbeschreibung', href: u.leistungsbeschreibung.url }, { text: ' (Fassung 2026-10-04).' }, + ]) + assert.deepEqual(linkzeile(text, { leistungsbeschreibung: { url: 'javascript:alert(1)' }, avv: null }), [{ text }]) + assert.deepEqual(linkzeile('Es gelten Leistungsbeschreibung (Fassung 2026-10-05).', u), [ + { text: 'Es gelten ' }, { text: 'Leistungsbeschreibung', href: u.leistungsbeschreibung.url }, { text: ' (Fassung 2026-10-05). ' }, { text: 'Auftragsverarbeitung', href: u.avv.url }, + ]) + // beide Wörter fehlen im Text: beide Links hängen hinten an, mit " · " dazwischen + assert.deepEqual(linkzeile('Es gelten die Unterlagen.', u), [ + { text: 'Es gelten die Unterlagen. ' }, { text: 'Leistungsbeschreibung', href: u.leistungsbeschreibung.url }, { text: ' · ' }, { text: 'Auftragsverarbeitung', href: u.avv.url }, + ]) + assert.deepEqual(linkzeile(text, null), [{ text }]) +}) + +test('fehlerAktion: alle 409-Codes aus V5 und die Randfälle', () => { + const f = (status, code, body = {}, message = 'Servertext') => ({ status, code, message, body: { code, ...body } }) + const AB = '2026-11-02T23:00:00.000Z' + const faelle = [ // [Fehler, Funktion, erwartete Aktion] + [f(409, 'nicht_verfuegbar'), 'termine', { art: 'meldung', text: 'Diese Funktion ist für dich gerade nicht verfügbar.' }], + [f(409, 'abo_fehlt', { grund: 'falsches_abo', wechsel: true }), 'termine', { art: 'wechsel' }], + [f(409, 'abo_fehlt', { grund: 'kein_abo' }), 'termine', { art: 'bestellen', produkt: 'wartung' }], + [f(409, 'abo_fehlt', { grund: 'kein_abo' }), 'analyse', { art: 'bestellen', produkt: 'hosting' }], + [f(409, 'abo_fehlt', { grund: 'gekuendigt' }), 'termine', { art: 'meldung', text: GEKUENDIGT }], + [f(409, 'abo_fehlt', { grund: 'nicht_aktiv' }), 'termine', { art: 'meldung', text: NICHT_AKTIV }], + [f(409, 'abo_fehlt', { grund: 'falsches_abo' }), 'termine', { art: 'meldung', text: 'Dein Abo enthält diese Funktion nicht.' }], + [f(409, 'abo_fehlt', { grund: 'beginnt_spaeter', ab: AB }), 'termine', { art: 'spaeter', ab: AB, text: 'Dein Abo beginnt am 03.11.2026. Ab dann kannst du einschalten.' }], // N20 + [f(409, 'abo_fehlt', { grund: 'beginnt_spaeter' }), 'termine', { art: 'meldung', text: 'Servertext' }], // ohne Datum: Text des Servers + [f(409, 'abo_fehlt', { grund: 'unbekannt' }), 'termine', { art: 'meldung', text: 'Servertext' }], + [f(409, 'nur_betrieb'), 'shop', { art: 'meldung', text: NUR_BETRIEB }], + [f(409, 'bestellung_offen', { offen: [{ schritt: 'zeiten', text: 'x' }] }), 'termine', { art: 'etage', etage: 'termine' }], + [f(409, 'bestellung_offen', { offen: ['Anfrage'] }), 'shop', { art: 'etage', etage: 'webshop' }], + [f(409, 'beginn_noetig', { ab: AB }), 'termine', { art: 'beginn', ab: AB }], + [f(409, 'bereits_eingeschaltet'), 'termine', { art: 'schon' }], + [f(409, 'fassung_veraltet'), 'termine', { art: 'neu-laden', text: 'Die Bedingungen wurden aktualisiert. Bitte lies die Zeile noch einmal.' }], + [f(409, 'bestellung_laeuft'), 'termine', { art: 'meldung', text: 'Servertext' }], // weitere Codes aus P5: Text des Servers + [f(409, 'abgeschaltet'), 'termine', { art: 'meldung', text: 'Servertext' }], + [f(503, 'unterlagen_fehlen'), 'termine', { art: 'meldung', text: 'Servertext' }], + [f(403, 'view_blocked'), 'termine', { art: 'meldung', text: 'Servertext' }], + [{ status: 0, message: 'Keine Verbindung. Bitte versuch es gleich noch einmal.', body: {} }, 'termine', { art: 'meldung', text: 'Keine Verbindung. Bitte versuch es gleich noch einmal.' }], + [{ status: 500, message: '', body: {} }, 'termine', { art: 'meldung', text: 'Das Einschalten hat gerade nicht geklappt. Versuch es gleich noch einmal oder schreib uns im Chat.' }], + ] + for (const [fehler, key, erwartet] of faelle) assert.deepEqual(fehlerAktion(fehler, { eintrag: katalogEintrag(key) }), erwartet, `${fehler.status} ${fehler.code}`) +}) diff --git a/test/knFunktionenVertrag.test.js b/test/knFunktionenVertrag.test.js new file mode 100644 index 0000000..9e37c7f --- /dev/null +++ b/test/knFunktionenVertrag.test.js @@ -0,0 +1,41 @@ +import { test, after } from 'node:test' +import assert from 'node:assert/strict' +import { startAppwriteMock } from './support/appwriteMock.js' +import { startFakeStripe } from './support/fakeStripe.js' +import { useTestEnv } from './support/testEnv.js' + +// Vertrag der Testdaten von P3 (test/support/knFunktionenDaten.js) mit dem echten Katalog des Servers (server/services/aboKatalog.js, V5/N17): +// gleiche Schlüssel je Eintrag, gleiche Reihenfolge, gleiche Items der Erklärung. Werte (Namen, Texte, Fassung) sind Testwerte und nicht Teil des Vertrags. +const aw = await startAppwriteMock() +const st = await startFakeStripe() +after(() => Promise.all([aw.close(), st.close()])) +useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) +const { config } = await import('../server/config.js') +const { katalogFuerKunde } = await import('../server/services/aboKatalog.js') +const { daten, katalogEintrag } = await import('./support/knFunktionenDaten.js') + +const analyseVorher = config.analyse.enabled +after(() => { config.analyse.enabled = analyseVorher }) + +test('Katalog der Testdaten: Schlüssel, Reihenfolge und Erklärungs-Items wie beim Server', async () => { + config.analyse.enabled = true + const echt = await katalogFuerKunde({ email: 'kunde@example.org' }) + const fix = daten().katalog + assert.deepEqual(fix.map((e) => e.key), echt.map((e) => e.key)) + for (const [i, e] of echt.entries()) { + const f = fix[i] + assert.deepEqual(Object.keys(f).sort(), Object.keys(e).sort(), `${e.key}: Felder`) + assert.deepEqual([f.enthaltenIn, f.ablauf, f.nurBetrieb, f.bild], [e.enthaltenIn, e.ablauf, e.nurBetrieb, e.bild], `${e.key}: Ablauf und Abo`) + assert.equal(f.consent === null, e.consent === null, `${e.key}: Register`) + assert.equal(f.hinweis === null, e.hinweis === null, `${e.key}: Hinweis`) + if (e.consent) { + assert.deepEqual(Object.keys(f.consent).sort(), Object.keys(e.consent).sort(), `${e.key}: consent`) + assert.equal(f.consent.knopf, e.consent.knopf) + assert.deepEqual(f.consent.items.map((x) => [x.id, x.haken, x.nurPrivatInFrist ?? false]), e.consent.items.map((x) => [x.id, x.haken, x.nurPrivatInFrist ?? false]), `${e.key}: Items`) + } + assert.deepEqual(Object.keys(f.unterlagen).sort(), Object.keys(e.unterlagen).sort(), `${e.key}: unterlagen`) + // eine Unterlage ist { version, url } oder null (News und Öffnungszeiten haben keine AVV); ob sie fehlt, ist ein Testwert + for (const art of ['leistungsbeschreibung', 'avv']) if (f.unterlagen[art] && e.unterlagen[art]) assert.deepEqual(Object.keys(f.unterlagen[art]).sort(), Object.keys(e.unterlagen[art]).sort(), `${e.key}: ${art}`) + } + assert.deepEqual(Object.keys(katalogEintrag('news')).sort(), Object.keys(echt[0]).sort()) +}) diff --git a/test/support/knFunktionenDaten.js b/test/support/knFunktionenDaten.js new file mode 100644 index 0000000..c3e7784 --- /dev/null +++ b/test/support/knFunktionenDaten.js @@ -0,0 +1,74 @@ +// Testdaten für P3 (Funktionen-Rahmen der Kundenansicht neu). Form laut GET /api/funktionen im Abo-Modell +// (server/routes/funktionen.js, server/services/aboKatalog.js, featureRegistry.js consentAbo, siteFeatures.js featureView). +// Beträge und Preise gibt es hier nicht: der Katalog trägt keinen Preis. + +const KEYS = ['news', 'termine', 'shop', 'unterkunft', 'zeiten', 'analyse'] +const NAMEN = { news: 'News-Pinnwand', termine: 'Terminplaner', shop: 'Webshop', unterkunft: 'Buchungskalender', zeiten: 'Öffnungszeiten live', analyse: 'Analyse' } +const EINRICHTUNG = { termine: 'meist in 1–2 Wochen', unterkunft: 'meist in 1–2 Wochen', shop: 'meist in 2–3 Wochen' } +// Hinweis ohne Haken (N17): nur Webshop, Buchungskalender und Terminplaner; Texte wie config.aboFunktionen.texte.hinweis +const HINWEISE = { + shop: 'Du verkaufst in eigenem Namen und bist für deine Produkte, Preise und Rechtstexte verantwortlich. Die Zahlungen deiner Käufer laufen über dein eigenes Stripe-Konto.', + unterkunft: 'Du vermietest in eigenem Namen und bist für Preise, Stornobedingungen, Rechnungen und Meldepflichten verantwortlich. Online-Zahlungen deiner Gäste laufen über dein eigenes Stripe-Konto.', + termine: 'Der Terminplaner ist nicht für den Gesundheitsbereich gedacht. Das fragen wir im Fragebogen ab.', +} +const BEGINN = 'Bitte beginnt sofort mit der Einrichtung. Mir ist klar: Mit der vollständigen Einrichtung erlischt mein Widerrufsrecht, und wenn ich vorher widerrufe, zahle ich den bis dahin erbrachten Teil.' + +/** Ein Katalogeintrag mit den Standardwerten des Schlüssels; edit überschreibt Felder. */ +export function katalogEintrag(key, edit = {}) { + const hinweis = HINWEISE[key] ?? null + const items = [{ id: 'einschalten-zeile', text: 'Es gelten Leistungsbeschreibung und Auftragsverarbeitung (Fassung 2026-10-05).', haken: false }] + if (hinweis) items.push({ id: 'hinweis', text: hinweis, haken: false }) + items.push({ id: 'beginn', text: BEGINN, haken: true, nurPrivatInFrist: true }) + return { + key, + name: NAMEN[key], + beschreibung: `Beschreibung ${NAMEN[key]}.`, + bild: `/neu/bilder/${key}.webp`, + enthaltenIn: key === 'analyse' ? 'hosting' : 'wartung', + verfuegbar: true, + ablauf: key === 'termine' ? 'fragebogen' : (key === 'shop' || key === 'unterkunft') ? 'anfrage' : 'direkt', + einrichtung: EINRICHTUNG[key] ?? 'meist in wenigen Tagen', + unterlagen: { + leistungsbeschreibung: { version: '2026-10-05', url: `/rechtliches/leistungsbeschreibung-${key}-2026-10-05.html` }, + avv: { version: '2026-10-05', url: `/rechtliches/avv-${key}-2026-10-05.html` }, + }, + nurBetrieb: key === 'shop', + consent: key === 'analyse' ? null : { version: '2026-10-20', knopf: 'Einschalten', items }, + hinweis: key === 'analyse' ? null : hinweis, + ...edit, + } +} + +/** Zustand einer Funktion an einer Website (featureView). */ +export function feature(state = 'off', extra = {}) { + return { state, siteKey: null, ticketWoid: null, activatedAt: null, disabledReason: null, loeschungAm: null, ...extra } +} + +/** Eine Website mit den Zuständen der Funktionen; Webshop und Buchungskalender tragen den Anfragestatus (N18). */ +export function projekt(projectId, projectName, features = {}, analyse = { state: 'off' }) { + return { + projectId, + projectName, + liveUrl: '', + features: { + news: feature('off'), + termine: feature('off'), + shop: feature('off', { anfrageStatus: '' }), + unterkunft: feature('off', { anfrageStatus: '' }), + zeiten: feature('off'), + ...features, + }, + analyse, + } +} + +/** Antwort von GET /api/funktionen im Abo-Modell. katalogEdit: { : { …Felder… } } wird über den Eintrag gelegt. */ +export function daten({ abo = {}, kundenart = 'unternehmen', projekte, katalogEdit = {} } = {}) { + return { + modell: 'abo', + kundenart, + abo: { produkt: 'wartung', laeuft: true, gekuendigt: false, widerrufsfristBis: null, ...abo }, + katalog: KEYS.map((key) => katalogEintrag(key, katalogEdit[key])), + projects: projekte ?? [projekt('p1', 'Bäckerei Sonnenschein')], + } +}