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:
1 parent
447129d4e2
commit
bfe3895a50
11 files changed
+177
-41
No files matched your search
@@ -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
@@ -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',
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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, '')
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
Reference in new issue
Block a user