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:
ClaudeandClaude Sonnet 5.5 committed 2026-10-16 18:01:27 +00:00
1 parent 1cd16ff293
commit 437947cdf6
4 files changed
+441

No files matched your search

+128
View File
@@ -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}`)
})
+41
View File
@@ -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())
})
+74
View File
@@ -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')],
}
}