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,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