Kundenansicht neu P7: Einschalten zeigt Linkzeile, Häkchen und Unterlagen der Fassung 6 aus P8

Verdrahtet P5 Task 15 und P7 Task 0 (N33): consentAbo liest Linkzeile und Hinweis aus WORTLAUT_V6, unterlagenFuer die fünf Funktionen aus FUNKTIONEN_UNTERLAGEN_V6 (Analyse bleibt bei den Alt-Unterlagen bis zur Freigabe), config.aboFunktionen.texte.hinweis entfällt, pdfAnhaenge liest PDFs der Fassung 6, sichereAdresse lässt /neu/rechtliches/ zu. Der Analyse-Kundenweg setzt wie der Team-Weg den Abo-Marker (unlockedBy und einrichtungAbrechnung abo).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-16 19:56:40 +00:00
1 parent 447129d4e2
commit bfe3895a50
11 files changed
+177 -41

No files matched your search

+2 -2
View File
@@ -109,11 +109,11 @@ 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 ''. */
/** Nur https ohne Zugangsdaten oder Pfade unter /rechtliches/, /vertragsunterlagen/ und /neu/rechtliches/ (Fassung 6); 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('/rechtliches/') || url.startsWith('/vertragsunterlagen/') || url.startsWith('/neu/rechtliches/')) return url
if (!url.startsWith('https://')) return ''
try {
const u = new URL(url)
+6 -12
View File
@@ -844,24 +844,18 @@ export const config = {
ustHinweis,
// Register der kaufbaren Funktionen (siehe oben)
features,
// Abo-Modell der Funktionen (Kundenansicht neu, P5): Fassung der Einschalt-Erklaerung, Reihenfolge des Katalogs, Entwurfstexte
// (Task 15 / P7 Task 0 stellt fassung und texte.beginn auf Fassung 6; config.js importiert keine Dienste) und der Katalogeintrag der
// Analyse (sie steht nicht im Register der kaufbaren Funktionen). Die Items baut featureRegistry.consentAbo bei jedem Aufruf.
// Abo-Modell der Funktionen (Kundenansicht neu, P5): Fassung der Einschalt-Erklaerung, Reihenfolge des Katalogs, Haekchen-Text und
// der Katalogeintrag der Analyse (sie steht nicht im Register der kaufbaren Funktionen). fassung und texte.beginn sind Literale
// der Fassung 6 (N33; config.js importiert keine Dienste, test/aboWortlautV6.test.js und test/knVerdrahtung.test.js pinnen die
// Gleichheit mit FUNKTIONEN_FASSUNG_V6 und WORTLAUT_V6.beginn.einrichtung). Linkzeile und Hinweis stehen in WORTLAUT_V6
// (products.js); die Items baut featureRegistry.consentAbo bei jedem Aufruf.
aboFunktionen: {
fassung: '2026-10-16',
reihenfolge: ['news', 'termine', 'shop', 'unterkunft', 'zeiten', 'analyse'],
texte: {
// Haken "Beginn" (nur Privatkunden innerhalb der Widerrufsfrist); Entwurf, wortgleich zu WORTLAUT_V6.beginn.einrichtung (P8)
// Haken "Beginn" (nur Privatkunden innerhalb der Widerrufsfrist); wortgleich zu WORTLAUT_V6.beginn.einrichtung (P8)
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.',
// Hinweis auf der Einschalt-Seite (ohne Haken): die Pflichten der alten Haken verantwortung, stripe und gesundheit;
// Entwurf, wortgleich zu WORTLAUT_V6.hinweis (P8)
hinweis: {
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.',
},
// Haken der Analyse (Kinder unter 16 Jahren, DSGVO Art. 8)
zielgruppe: 'Meine Website richtet sich nicht gezielt an Kinder unter 16 Jahren.',
analyseKnopf: 'Analyse einschalten',
+3 -1
View File
@@ -612,10 +612,12 @@ router.post('/analyse/activate', analyseGuard, async (req, res) => {
}
const customerId = getSessionCustomerId(req)
await assertOwnProject(customerId, analyseProjectId(projectId), { forNewOrder: true })
// Nachweis der Erklaerung (featureConsents) mit der Kundenart des Dokuments, nie aus dem Body; entsteht nur bei einer neuen Aktivierung
// Nachweis der Erklaerung (featureConsents) mit der Kundenart des Dokuments, nie aus dem Body; entsteht nur bei einer neuen Aktivierung.
// abrechnung 'abo': dasselbe Marker-Paar am Dokument wie beim Team-Weg (admin/analyse.js), unabhaengig vom Body
const abo = neu
? {
aboModell: true,
abrechnung: 'abo',
nachweis: async () =>
speichereNachweisAusAnfrage(req, {
feature: abowegAnalyse(),
+29 -5
View File
@@ -1,17 +1,23 @@
import { readFile } from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { config } from '../config.js'
import { legalPaths } from './analyseLegal.js'
import { FUNKTIONEN_UNTERLAGEN_V6 } from './products.js'
/**
* Einzige Quelle der verlinkten Unterlagen im Abo-Modell (Katalog, Einschalt-Nachweis, Analyse). Blattmodul: importiert
* nur config und analyseLegal.js. Liest bei jedem Aufruf (Tests aendern config zur Laufzeit). Task 15 (Fassung 6) stellt
* nur diese Quelle um; die Aufrufer bleiben unveraendert.
* nur config, analyseLegal.js und products.js (das selbst nur config und den Schalter liest). Liest bei jedem Aufruf (Tests
* aendern config zur Laufzeit). Verdrahtung der Fassung 6 (N33): die fuenf Funktionen des Registers lesen
* FUNKTIONEN_UNTERLAGEN_V6; die Analyse bleibt bis zur Freigabe bei den versionierten Alt-Seiten (legalPaths).
*/
const fassung = (unterlage) => (unterlage ? { version: unterlage.version, url: unterlage.url ?? '' } : null)
/**
* Leistungsbeschreibung und AVV einer Funktion: { leistungsbeschreibung: { version, url } | null, avv: { version, url } | null }.
* Funktionen aus dem Register: deren Eintraege; 'analyse': die versionierten Seiten aus legalPaths(config.analyse.avvVersion).
* Funktionen aus dem Register: die Dokumente der Fassung 6 aus FUNKTIONEN_UNTERLAGEN_V6 (Vorschaupfad /neu/rechtliches/, ohne Preise);
* 'analyse': die versionierten Seiten aus legalPaths(config.analyse.avvVersion) (N33: erst nach der Freigabe auf Fassung 6).
* Ein unbekannter Schluessel ist ein Programmfehler (TypeError).
*/
export function unterlagenFuer(key) {
@@ -21,8 +27,9 @@ export function unterlagenFuer(key) {
return { leistungsbeschreibung: { version, url: pfade.lb }, avv: { version, url: pfade.avv } }
}
if (typeof key !== 'string' || !Object.hasOwn(config.features, key)) throw new TypeError(`Unbekannte Funktion: ${String(key)}`)
const eintrag = config.features[key]
return { leistungsbeschreibung: fassung(eintrag.leistungsbeschreibung), avv: fassung(eintrag.avv) }
const unterlagen = FUNKTIONEN_UNTERLAGEN_V6[key]
if (!unterlagen) throw new TypeError(`Keine Unterlagen der Fassung 6 fuer: ${key}`)
return { leistungsbeschreibung: fassung(unterlagen.lb), avv: fassung(unterlagen.avv) }
}
/** Unterlagen der Analyse fuer Nachweis und Mail: { avvVersion, legal: { lb, avv } } (Adressen der versionierten Seiten). */
@@ -30,3 +37,20 @@ export function analyseUnterlagen() {
const { leistungsbeschreibung, avv } = unterlagenFuer('analyse')
return { avvVersion: avv.version, legal: { lb: leistungsbeschreibung.url, avv: avv.url } }
}
const PUBLIC_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'public')
/**
* PDF-Anhaenge fuer Mails aus Dokumenten der Fassung 6: [{ pdfFile, anhangName }] -> [{ filename: anhangName, content, contentType }].
* Wirft, wenn eine Datei fehlt, leer ist oder nicht mit %PDF- beginnt (nie ein leerer oder falscher Anhang). basis: Wurzel von
* pdfFile (Vorgabe: public/).
*/
export async function pdfAnhaenge(dokumente, { basis = PUBLIC_DIR } = {}) {
const anhaenge = []
for (const dok of dokumente) {
const content = await readFile(path.join(basis, dok.pdfFile))
if (content.length === 0 || content.subarray(0, 5).toString('latin1') !== '%PDF-') throw new Error(`PDF fehlt oder ist ungueltig: ${dok.pdfFile}`)
anhaenge.push({ filename: dok.anhangName, content, contentType: 'application/pdf' })
}
return anhaenge
}
+9 -9
View File
@@ -4,11 +4,12 @@ import { shopFuerKunde } from './shop/schalter.js'
import { unterkunftFuerKunde } from './unterkunft/schalter.js'
import { zeitenFuerKunde } from './zeiten/schalter.js'
import { analyseUnterlagen, unterlagenFuer } from './aboUnterlagen.js'
import { WORTLAUT_V6, mitFassung } from './products.js'
// Helfer fuer das Funktionen-Register config.features (News-Pinnwand, Terminplaner, Webshop, Oeffnungszeiten live; Daten in server/config.js).
// Diese Datei wird von siteFeatures.js importiert und importiert selbst keine Dienste statisch (einzige Ausnahmen:
// termine/schalter.js, shop/schalter.js und zeiten/schalter.js, die nur config lesen, und aboUnterlagen.js, ein Blattmodul,
// das nur config und analyseLegal.js importiert): Standard-Haken laden ihre Dienste per dynamischem import()
// termine/schalter.js, shop/schalter.js und zeiten/schalter.js, die nur config lesen, aboUnterlagen.js, ein Blattmodul,
// das nur config, analyseLegal.js und products.js importiert, und products.js, das nur config und den Schalter liest): Standard-Haken laden ihre Dienste per dynamischem import()
// innerhalb der Funktion (sonst entstuenden Importzyklen).
/** Namen der Haken, die ein Registereintrag hat (optional): Bestell-, Freischalt- und Unterlagenpruefungen, dazu nachFreischaltung. */
@@ -239,18 +240,17 @@ export function katalogFelder(key) {
/**
* Einschalt-Erklaerung einer Funktion im Abo-Modell: { version, knopf: 'Einschalten', items } in Anzeigereihenfolge
* einschalten-zeile (Linkzeile mit Fassung, kein Haken), hinweis (nur shop, unterkunft, termine; kein Haken) und beginn
* (Haken, nur fuer Privatkunden innerhalb der Widerrufsfrist). Baut die Items bei jedem Aufruf aus aboUnterlagen.js und
* config.aboFunktionen.texte. Analyse und unbekannte Schluessel => null.
* (Haken, nur fuer Privatkunden innerhalb der Widerrufsfrist). Baut die Items bei jedem Aufruf: Linkzeile (mit AVV oder ohne) und
* Hinweis aus WORTLAUT_V6 (Fassung 6, P8), beginn und Fassung aus config.aboFunktionen (Literale, Gleichheit gepinnt in
* test/aboWortlautV6.test.js und test/knVerdrahtung.test.js). Analyse und unbekannte Schluessel => null.
*/
export function consentAbo(key) {
if (!featureConfig(key)) return null
const { leistungsbeschreibung, avv } = unterlagenFuer(key)
const { avv } = unterlagenFuer(key)
const { fassung: version, texte } = config.aboFunktionen
const zeile = avv
? `Es gelten Leistungsbeschreibung und Auftragsverarbeitung (Fassung ${leistungsbeschreibung.version}).`
: `Es gilt die Leistungsbeschreibung (Fassung ${leistungsbeschreibung.version}).`
const zeile = mitFassung(avv ? WORTLAUT_V6.linkzeile.einschaltenMitAvv : WORTLAUT_V6.linkzeile.einschaltenOhneAvv, version)
const items = [{ id: 'einschalten-zeile', text: zeile, haken: false }]
if (Object.hasOwn(texte.hinweis, key)) items.push({ id: 'hinweis', text: texte.hinweis[key], haken: false })
if (Object.hasOwn(WORTLAUT_V6.hinweis, key)) items.push({ id: 'hinweis', text: WORTLAUT_V6.hinweis[key], haken: false })
items.push({ id: 'beginn', text: texte.beginn, haken: true, nurPrivatInFrist: true })
return { version, knopf: 'Einschalten', items }
}
+2 -1
View File
@@ -36,6 +36,7 @@ const { setMailTransportForTests } = await import('../server/services/mailer.js'
const { default: funktionenRoutes } = await import('../server/routes/funktionen.js')
const { resetZeitenCacheForTests } = await import('../server/services/zeiten/cache.js')
const { einschaltenDeps } = await import('../server/services/zeiten/einschalten.js')
const { FUNKTIONEN_FASSUNG_V6, FUNKTIONEN_UNTERLAGEN_V6 } = await import('../server/services/products.js')
const app = await startTestApp({ '/api/funktionen': funktionenRoutes })
after(() => Promise.all([app.close(), aw.close(), st.close()]))
@@ -128,7 +129,7 @@ test('mit neuer Ansicht: Modell, Kundenart, Abo, Katalog in fester Reihenfolge',
assert.equal(mit.katalog.at(-1).consent, null)
assert.equal(b.katalog.find((e) => e.key === 'shop').nurBetrieb, true)
assert.doesNotMatch(JSON.stringify(b.katalog), /€|priceLabel/)
assert.deepEqual(t.unterlagen.leistungsbeschreibung, { version: config.termine.avvVersion, url: config.features.termine.leistungsbeschreibung.url })
assert.deepEqual(t.unterlagen.leistungsbeschreibung, { version: FUNKTIONEN_FASSUNG_V6, url: FUNKTIONEN_UNTERLAGEN_V6.termine.lb.url })
assert.equal(mit.katalog.at(-1).unterlagen.avv.url, `/rechtliches/avv-website-analyse-${config.analyse.avvVersion}.html`)
config.analyse.enabled = false
})
+2 -1
View File
@@ -15,11 +15,12 @@ const { renderMail } = await import('../server/services/mailTemplates.js')
const { anleitungsLink } = await import('../server/services/featureRegistry.js')
const { anfrageEntschiedenMail, k38Mail, k39Mail, t24Mail, loeschFristTage, kontostandText } = await import('../server/services/aboFunktionenMails.js')
const { k29Mail, k30Mail } = await import('../server/services/zeiten/mails.js')
const { FUNKTIONEN_FASSUNG_V6 } = await import('../server/services/products.js')
test('k38Mail: Werte und Blöcke je Abrechnung', () => {
const m = k38Mail({ kunde: { name: 'Silke Müller' }, projekt: 'Bäckerei', key: 'termine', abrechnung: 'abo', ticketNr: '#100150', anhang: true })
assert.equal(m.template, 'K38'); assert.deepEqual(m.blocks, { stundenkonto: true, projekt_kosten: false, anhang: true })
assert.deepEqual(m.values, { kunde_name: 'Silke Müller', projekt: 'Bäckerei', funktion: 'Terminplaner', funktion_akk: 'den Terminplaner', einrichtung: 'meist in 1–2 Wochen', ticket: ' (Ticket #100150)', fassung: config.features.termine.leistungsbeschreibung.version, portal_url: `${config.publicBaseUrl}/dashboard.html#termine` })
assert.deepEqual(m.values, { kunde_name: 'Silke Müller', projekt: 'Bäckerei', funktion: 'Terminplaner', funktion_akk: 'den Terminplaner', einrichtung: 'meist in 1–2 Wochen', ticket: ' (Ticket #100150)', fassung: FUNKTIONEN_FASSUNG_V6, portal_url: `${config.publicBaseUrl}/dashboard.html#termine` })
assert.deepEqual(k38Mail({ kunde: 'K', projekt: 'P', key: 'shop', abrechnung: 'projekt' }).blocks, { stundenkonto: false, projekt_kosten: true, anhang: false })
assert.equal(k38Mail({ kunde: 'K', projekt: 'P', key: 'news', abrechnung: 'abo' }).values.ticket, '')
})
+9 -6
View File
@@ -12,6 +12,7 @@ useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { config } = await import('../server/config.js')
const reg = await import('../server/services/featureRegistry.js')
const { unterlagenFuer, analyseUnterlagen } = await import('../server/services/aboUnterlagen.js')
const { WORTLAUT_V6, FUNKTIONEN_FASSUNG_V6, FUNKTIONEN_UNTERLAGEN_V6, mitFassung } = await import('../server/services/products.js')
const KEYS = ['news', 'termine', 'shop', 'unterkunft', 'zeiten']
test('Reihenfolge und Felder je Funktion', () => {
@@ -33,7 +34,7 @@ test('Reihenfolge und Felder je Funktion', () => {
assert.equal(reg.katalogFelder('analyse').beschreibung, 'Sieh, wie viele Besucher auf deine Website kommen und welche Seiten sie ansehen. Die Auswertung findest du direkt im Portal.')
})
const HINWEIS = { // Pflichten der alten Häkchen verantwortung, stripe, gesundheit; wörtlich wie WORTLAUT_V6.hinweis (P8)
const HINWEIS = { // Pflichten der alten Häkchen verantwortung, stripe, gesundheit; wörtlich wie WORTLAUT_V6.hinweis (P8), gleich gepinnt unten
termine: 'Der Terminplaner ist nicht für den Gesundheitsbereich gedacht. Das fragen wir im Fragebogen ab.',
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.',
@@ -50,9 +51,11 @@ test('consentAbo: Linkzeile mit Fassung, Hinweis (termine, shop, unterkunft), be
}
assert.equal(config.aboFunktionen.texte.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.')
assert.equal(V, '2026-10-16')
assert.equal(reg.consentAbo('termine').items[0].text, `Es gelten Leistungsbeschreibung und Auftragsverarbeitung (Fassung ${config.termine.avvVersion}).`)
assert.equal(reg.consentAbo('zeiten').items[0].text, 'Es gilt die Leistungsbeschreibung (Fassung 2026-10-04).')
assert.equal(reg.consentAbo('news').items[0].text, `Es gilt die Leistungsbeschreibung (Fassung ${config.termine.avvVersion}).`)
assert.deepEqual(HINWEIS, { ...WORTLAUT_V6.hinweis }, 'die Hinweise sind die Sätze aus WORTLAUT_V6')
assert.equal(V, FUNKTIONEN_FASSUNG_V6)
assert.equal(reg.consentAbo('termine').items[0].text, mitFassung(WORTLAUT_V6.linkzeile.einschaltenMitAvv, FUNKTIONEN_FASSUNG_V6))
assert.equal(reg.consentAbo('zeiten').items[0].text, mitFassung(WORTLAUT_V6.linkzeile.einschaltenOhneAvv, FUNKTIONEN_FASSUNG_V6))
assert.equal(reg.consentAbo('news').items[0].text, mitFassung(WORTLAUT_V6.linkzeile.einschaltenOhneAvv, FUNKTIONEN_FASSUNG_V6))
assert.ok(!reg.consentAbo('termine').items.some((i) => i.id === 'gesundheit'), 'das Häkchen gesundheit entfällt, der Satz steht als Hinweis')
assert.doesNotMatch(JSON.stringify(Object.values(HINWEIS)), /€|\bSie\b|\bIhre?[nmrs]?\b/)
assert.equal(reg.consentAbo('analyse'), null)
@@ -64,7 +67,7 @@ test('consentAboAktiv und aboweg zeigen nur, was verlangt ist; das alte Register
assert.deepEqual(reg.consentAboAktiv('news').items.map((i) => i.id), ['einschalten-zeile'])
assert.deepEqual(reg.consentAboAktiv('shop', { beginn: true }).items.map((i) => [i.id, i.haken]), [['einschalten-zeile', false], ['hinweis', false], ['beginn', true]])
const w = reg.aboweg(reg.featureConfig('termine'), { beginn: true })
assert.deepEqual([w.consent.version, w.widerruf, w.avv.version, w.leistungsbeschreibung.version, w.key], [config.aboFunktionen.fassung, null, config.termine.avvVersion, config.termine.avvVersion, 'termine'])
assert.deepEqual([w.consent.version, w.widerruf, w.avv.version, w.leistungsbeschreibung.version, w.key], [config.aboFunktionen.fassung, null, FUNKTIONEN_FASSUNG_V6, FUNKTIONEN_FASSUNG_V6, 'termine'])
assert.deepEqual(reg.featureConfig('termine').consent.items.map((i) => i.id), ['unterlagen', 'gesundheit', 'beginn', 'information'])
assert.notEqual(reg.featureConfig('termine').widerruf, null, 'aboweg ist eine Kopie, das Register bleibt')
assert.equal(Object.keys(reg.oeffentlicheAnsicht('termine')).length, 11, 'oeffentlicheAnsicht ohne neue Felder')
@@ -72,7 +75,7 @@ test('consentAboAktiv und aboweg zeigen nur, was verlangt ist; das alte Register
test('unterlagenFuer und analyseUnterlagen: eine Quelle für Katalog, Nachweis und Analyse', () => {
const t = unterlagenFuer('termine')
assert.deepEqual([t.leistungsbeschreibung, t.avv], [{ version: config.termine.avvVersion, url: config.features.termine.leistungsbeschreibung.url }, { version: config.termine.avvVersion, url: config.features.termine.avv.url }])
assert.deepEqual([t.leistungsbeschreibung, t.avv], [{ version: FUNKTIONEN_FASSUNG_V6, url: FUNKTIONEN_UNTERLAGEN_V6.termine.lb.url }, { version: FUNKTIONEN_FASSUNG_V6, url: FUNKTIONEN_UNTERLAGEN_V6.termine.avv.url }])
assert.deepEqual([unterlagenFuer('news').avv, unterlagenFuer('zeiten').avv], [null, null])
assert.ok(unterlagenFuer('news').leistungsbeschreibung.version)
const a = analyseUnterlagen()
+17 -1
View File
@@ -29,6 +29,7 @@ const { default: funktionenRoutes } = await import('../server/routes/funktionen.
const an = await import('../server/services/analyse.js')
const { analyseUnterlagen } = await import('../server/services/aboUnterlagen.js')
const { aboModellFuerKunde } = await import('../server/services/aboFunktionen.js')
const { istAboModell } = await import('../server/services/aboModell.js')
const { setMailTransportForTests } = await import('../server/services/mailer.js')
const app = await startTestApp({ '/api/funktionen': funktionenRoutes })
@@ -152,11 +153,26 @@ test('Nachweis scheitert: alles zurückgenommen (kein on ohne Nachweis), der nä
assert.equal(aw.docs('featureConsents').size, 1)
})
test('alte Ansicht unverändert: ohne audienceConfirmed 200, kein Eintrag in featureConsents', async () => {
test('Abo-Marker (Ruling Nachzüge): der Kundenweg setzt unlockedBy und einrichtungAbrechnung "abo" wie der Team-Weg, nie aus dem Body; Rücknahme stellt ihn her', async () => {
abo(hostingDoc)
aw.failNext({ collection: 'featureConsents', method: 'POST', status: 500 })
assert.equal((await aktivieren({ abrechnung: 'projekt', unlockedBy: 'projekt' })).status, 500)
const roll = aw.docs('portalFeatures').get('f_p1_analyse')
assert.deepEqual([roll.state, roll.unlockedBy ?? null, roll.einrichtungAbrechnung ?? null, istAboModell(roll)], ['off', null, null, false], 'Nachweis scheitert: kein Marker bleibt')
assert.equal((await aktivieren({ abrechnung: 'projekt', unlockedBy: 'projekt' })).status, 200) // Body-Werte zählen nicht
const d = aw.docs('portalFeatures').get('f_p1_analyse')
assert.deepEqual([d.state, d.unlockedBy, d.einrichtungAbrechnung, istAboModell(d)], ['active', 'abo', 'abo', true])
assert.equal((await aktivieren({ accentColor: '#222222' })).status, 200) // zweiter Aufruf einer aktiven Analyse lässt den Marker stehen
assert.deepEqual([aw.docs('portalFeatures').get('f_p1_analyse').unlockedBy, aw.docs('portalFeatures').get('f_p1_analyse').einrichtungAbrechnung], ['abo', 'abo'])
})
test('alte Ansicht unverändert: ohne audienceConfirmed 200, kein Eintrag in featureConsents, kein Abo-Marker', async () => {
config.kundenansichtNeu.mode = 'off'
abo({ ...wartungDoc })
assert.equal((await aktivieren({ audienceConfirmed: undefined })).status, 200)
assert.equal(aw.docs('featureConsents').size, 0)
const d = aw.docs('portalFeatures').get('f_p1_analyse')
assert.deepEqual([d.state, d.unlockedBy ?? null, d.einrichtungAbrechnung ?? null, istAboModell(d)], ['active', null, null, false])
})
test('Tagesprüfung: Hosting-Analyse bleibt für Kunden der neuen Ansicht, sonst endet sie wie bisher (K19)', async () => {
+3 -3
View File
@@ -71,9 +71,9 @@ test('einschaltKoerper: Fassung aus dem Register, Haken nur "beginn", nie die Ku
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('sichereAdresse: nur https und Pfade unter /rechtliches/, /vertragsunterlagen/ oder /neu/rechtliches/', () => {
for (const gut of ['/rechtliches/avv-2026-10-05.html', '/vertragsunterlagen/funktionen-leistungsbeschreibung.html', '/neu/rechtliches/leistungsbeschreibung-terminplaner-2026-10-16.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', '/neu/rechtliches/../geheim', '/neu/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', () => {
+95
View File
@@ -0,0 +1,95 @@
import { test, after } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { mkdtemp, mkdir, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.js'
import { useTestEnv } from './support/testEnv.js'
import { sichereAdresse } from '../public/neu/funktion-einschalten.js'
// Verdrahtung P5 <-> P8 <-> P7 (N33): Einschalten, Katalog und Nachweis zeigen Linkzeile, Haekchen, Hinweis und Unterlagen der
// Fassung 6 aus products.js. Die Gleichheit mit WORTLAUT_V6 und FUNKTIONEN_UNTERLAGEN_V6 ist gepinnt, nicht ein Literal.
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 reg = await import('../server/services/featureRegistry.js')
const { katalogFuerKunde } = await import('../server/services/aboKatalog.js')
const { unterlagenFuer, analyseUnterlagen, pdfAnhaenge } = await import('../server/services/aboUnterlagen.js')
const { WORTLAUT_V6, FUNKTIONEN_FASSUNG_V6, FUNKTIONEN_UNTERLAGEN_V6, mitFassung } = await import('../server/services/products.js')
const KEYS = ['news', 'termine', 'shop', 'unterkunft', 'zeiten']
const unterlage = (d) => (d ? { version: FUNKTIONEN_FASSUNG_V6, url: d.url } : null)
test('config.aboFunktionen trägt die Literale aus P8, config.js importiert keine Dienste', () => {
assert.deepEqual([config.aboFunktionen.fassung, config.aboFunktionen.texte.beginn], [FUNKTIONEN_FASSUNG_V6, WORTLAUT_V6.beginn.einrichtung])
assert.equal(Object.hasOwn(config.aboFunktionen.texte, 'hinweis'), false, 'der Hinweis kommt aus WORTLAUT_V6, nicht aus config')
assert.doesNotMatch(readFileSync(new URL('../server/config.js', import.meta.url), 'utf8'), /from '\.\/services\//)
})
test('consentAbo: Linkzeile mit und ohne AVV aus WORTLAUT_V6, Hinweis und Häkchen aus WORTLAUT_V6, Nachweis gleich', () => {
for (const key of KEYS) {
const mitAvv = FUNKTIONEN_UNTERLAGEN_V6[key].avv !== null
const zeile = mitFassung(WORTLAUT_V6.linkzeile[mitAvv ? 'einschaltenMitAvv' : 'einschaltenOhneAvv'], FUNKTIONEN_FASSUNG_V6)
const c = reg.consentAbo(key)
assert.equal(c.version, FUNKTIONEN_FASSUNG_V6, key)
assert.equal(c.items[0].text, zeile, key)
assert.equal(c.items.at(-1).text, WORTLAUT_V6.beginn.einrichtung, key)
assert.equal(c.items.find((i) => i.id === 'hinweis')?.text, WORTLAUT_V6.hinweis[key], key)
assert.deepEqual(reg.consentAboAktiv(key, { beginn: true }).items.map((i) => i.text), [zeile, ...(WORTLAUT_V6.hinweis[key] ? [WORTLAUT_V6.hinweis[key]] : []), WORTLAUT_V6.beginn.einrichtung], key)
}
assert.equal(reg.consentAbo('analyse'), null)
assert.equal(reg.consentAbo('gibtEsNicht'), null)
})
test('unterlagenFuer und aboweg: die fünf Funktionen aus FUNKTIONEN_UNTERLAGEN_V6, die Analyse bleibt bei den Alt-Unterlagen (N33)', () => {
for (const key of KEYS) {
assert.deepEqual(unterlagenFuer(key), { leistungsbeschreibung: unterlage(FUNKTIONEN_UNTERLAGEN_V6[key].lb), avv: unterlage(FUNKTIONEN_UNTERLAGEN_V6[key].avv) }, key)
const w = reg.aboweg(reg.featureConfig(key))
assert.deepEqual([w.leistungsbeschreibung, w.avv], [unterlage(FUNKTIONEN_UNTERLAGEN_V6[key].lb), unterlage(FUNKTIONEN_UNTERLAGEN_V6[key].avv)], key)
}
const a = analyseUnterlagen()
assert.deepEqual(a, { avvVersion: config.analyse.avvVersion, legal: { lb: `/rechtliches/leistungsbeschreibung-website-analyse-${config.analyse.avvVersion}.html`, avv: `/rechtliches/avv-website-analyse-${config.analyse.avvVersion}.html` } })
assert.equal(reg.consentAboAnalyse().items[0].text, `Es gelten Leistungsbeschreibung und Auftragsverarbeitung (Fassung ${config.analyse.avvVersion}).`)
})
test('Katalog: unterlagen aus FUNKTIONEN_UNTERLAGEN_V6, hinweis aus WORTLAUT_V6, Analyse bleibt bei den Alt-Unterlagen', async () => {
const vorher = config.analyse.enabled
config.analyse.enabled = true
try {
const katalog = await katalogFuerKunde({ email: 'k@example.invalid' })
for (const key of KEYS) {
const e = katalog.find((x) => x.key === key)
assert.deepEqual(e.unterlagen, { leistungsbeschreibung: unterlage(FUNKTIONEN_UNTERLAGEN_V6[key].lb), avv: unterlage(FUNKTIONEN_UNTERLAGEN_V6[key].avv) }, key)
assert.equal(e.hinweis, WORTLAUT_V6.hinweis[key] ?? null, key)
}
const a = katalog.find((x) => x.key === 'analyse')
assert.deepEqual([a.unterlagen.avv.version, a.unterlagen.avv.url, a.hinweis], [config.analyse.avvVersion, `/rechtliches/avv-website-analyse-${config.analyse.avvVersion}.html`, null])
assert.doesNotMatch(JSON.stringify(katalog.filter((e) => e.key !== 'analyse')), /"\/(rechtliches|vertragsunterlagen)\//, 'kein Alt-Dokument mit Preisen')
} finally {
config.analyse.enabled = vorher
}
})
test('sichereAdresse (P3) lässt jede Adresse der Funktionsunterlagen durch, auch unter /neu/rechtliches/', () => {
for (const u of Object.values(FUNKTIONEN_UNTERLAGEN_V6).flatMap((x) => [x.lb, x.avv]).filter(Boolean)) assert.equal(sichereAdresse(u.url), u.url, u.url)
for (const schlecht of ['/neu/rechtliches/../geheim', '/neu/rechtliches/%2e%2e/x', '/neu/rechtliches/a b', '/neu/rechtliches\\x', '/neu/andere/seite.html', '/neu/rechtliches', '//example.org/neu/rechtliches/x']) {
assert.equal(sichereAdresse(schlecht), '', schlecht)
}
})
test('pdfAnhaenge: liest die PDFs; fehlende, leere und Nicht-PDF-Dateien werfen', async () => {
const dir = await mkdtemp(join(process.env.TMPDIR ?? tmpdir(), 'kn-pdf-')) // im eigenen Scratch-Ordner, wird nicht gelöscht
await mkdir(join(dir, 'neu/rechtliches'), { recursive: true })
const dok = (name) => ({ pdfFile: `neu/rechtliches/${name}.pdf`, anhangName: `${name}-2026-10-16.pdf` })
await writeFile(join(dir, dok('ok').pdfFile), '%PDF-1.4 x')
await writeFile(join(dir, dok('leer').pdfFile), '')
await writeFile(join(dir, dok('text').pdfFile), 'hallo')
const ok = await pdfAnhaenge([dok('ok')], { basis: dir })
assert.deepEqual(ok.map((x) => [x.filename, x.contentType]), [['ok-2026-10-16.pdf', 'application/pdf']])
assert.ok(Buffer.isBuffer(ok[0].content) && ok[0].content.length > 0)
for (const name of ['fehlt', 'leer', 'text']) await assert.rejects(() => pdfAnhaenge([dok(name)], { basis: dir }), name)
})