diff --git a/public/neu/funktion-einschalten.js b/public/neu/funktion-einschalten.js index 95f06a1..325e541 100644 --- a/public/neu/funktion-einschalten.js +++ b/public/neu/funktion-einschalten.js @@ -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) diff --git a/server/config.js b/server/config.js index 1355576..426d262 100644 --- a/server/config.js +++ b/server/config.js @@ -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', diff --git a/server/routes/funktionen.js b/server/routes/funktionen.js index d005f02..bab1078 100644 --- a/server/routes/funktionen.js +++ b/server/routes/funktionen.js @@ -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(), diff --git a/server/services/aboUnterlagen.js b/server/services/aboUnterlagen.js index 691a649..f1b5788 100644 --- a/server/services/aboUnterlagen.js +++ b/server/services/aboUnterlagen.js @@ -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 +} diff --git a/server/services/featureRegistry.js b/server/services/featureRegistry.js index 8a3aea9..0f463c6 100644 --- a/server/services/featureRegistry.js +++ b/server/services/featureRegistry.js @@ -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 } } diff --git a/test/aboFunktionenListe.test.js b/test/aboFunktionenListe.test.js index 3cb73f0..b3de5de 100644 --- a/test/aboFunktionenListe.test.js +++ b/test/aboFunktionenListe.test.js @@ -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 }) diff --git a/test/aboFunktionenMails.test.js b/test/aboFunktionenMails.test.js index 1675043..8e4f0c4 100644 --- a/test/aboFunktionenMails.test.js +++ b/test/aboFunktionenMails.test.js @@ -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, '') }) diff --git a/test/aboKatalogRegister.test.js b/test/aboKatalogRegister.test.js index cc480f4..16004d0 100644 --- a/test/aboKatalogRegister.test.js +++ b/test/aboKatalogRegister.test.js @@ -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() diff --git a/test/analyseAboModell.test.js b/test/analyseAboModell.test.js index 8be7ba4..b31098b 100644 --- a/test/analyseAboModell.test.js +++ b/test/analyseAboModell.test.js @@ -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 () => { diff --git a/test/knEinschaltenLogik.test.js b/test/knEinschaltenLogik.test.js index 341614c..c4544b1 100644 --- a/test/knEinschaltenLogik.test.js +++ b/test/knEinschaltenLogik.test.js @@ -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', () => { diff --git a/test/knVerdrahtung.test.js b/test/knVerdrahtung.test.js new file mode 100644 index 0000000..5d2c097 --- /dev/null +++ b/test/knVerdrahtung.test.js @@ -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) +})