Funktionen neu: Einschalt-Logik (Vorprüfung, Fristen, 409-Codes) und Testdaten
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1cd16ff293
commit
437947cdf6
4 files changed
+441
No files matched your search
@@ -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/<key>/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 `#<etage>?projekt=<id>`). */
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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}`)
|
||||
})
|
||||
@@ -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())
|
||||
})
|
||||
@@ -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: { <key>: { …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')],
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user