import { test, after, beforeEach, afterEach, mock } from 'node:test' import assert from 'node:assert/strict' import { startAppwriteMock } from './support/appwriteMock.js' import { startFakeStripe } from './support/fakeStripe.js' import { mailSink } from './support/mailSink.js' import { schemaKeys } from './support/schema.js' import { useTestEnv } from './support/testEnv.js' // Analyse im Abo-Modell (P5 Task 13): Hosting reicht, die Zielgruppen-Aussage ist serverseitig erzwungen, der Nachweis liegt in // featureConsents, die Tagesprüfung lässt Hosting-Analysen der neuen Ansicht in Ruhe. Die alte Ansicht bleibt unverändert. const ANALYSE_KEYS = ['accentColor', 'avvVersion', 'avvAcceptedAt', 'avvAcceptedBy', 'activatedAt', 'offSince', 'privacyTicketId', 'activatedMailAt', 'avvConsentDate', 'avvConsentChannel', 'removalTicketId', 'publishTicketId', 'audienceConfirmed', 'audienceTextVersion'] const CONSENT_KEYS = ['customerId', 'projectId', 'siteKey', 'event', 'avvVersion', 'via', 'acceptedAt', 'acceptedBy', 'textformDate', 'textformChannel', 'audienceConfirmed', 'audienceTextVersion'] const aw = await startAppwriteMock({ schema: { portalFeatures: [...schemaKeys('portalFeatures'), ...ANALYSE_KEYS], subscriptions: schemaKeys('subscriptions'), contractEvents: schemaKeys('contractEvents'), featureConsents: schemaKeys('featureConsents'), analyseConsents: CONSENT_KEYS, }, }) const st = await startFakeStripe() useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) const { config } = await import('../server/config.js') const { startTestApp, get, post } = await import('./support/testApp.js') const { customerCookie, customers, hostingDoc, seedFixtures, subscriptionDoc, wartungDoc } = await import('./support/fixtures.js') const { deleteDocument } = await import('../server/services/appwriteAdmin.js') const { default: funktionenRoutes } = await import('../server/routes/funktionen.js') 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 }) setMailTransportForTests(mailSink()) const vorher = { neu: { mode: config.kundenansichtNeu.mode, pilotEmails: [...config.kundenansichtNeu.pilotEmails] }, analyse: config.analyse.enabled, } after(() => { Object.assign(config.kundenansichtNeu, vorher.neu) config.analyse.enabled = vorher.analyse setMailTransportForTests(null) return Promise.all([app.close(), aw.close(), st.close()]) }) const putSiteAufrufe = [] an.analyseDeps.client = { putSite: async (siteKey, body) => putSiteAufrufe.push({ siteKey, ...body }), stopSite: async () => {}, getStats: async () => ({ pv: 5 }), heatToken: () => 'token', } an.analyseDeps.rebuild = async () => {} config.analyse.enabled = true const JETZT = new Date('2026-10-17T08:00:00.000Z') const c1 = customerCookie('c1') const abo = (doc) => aw.seed('subscriptions', subscriptionDoc('sub_h', doc)) const aktiveAnalyse = (c, p) => aw.seed('portalFeatures', { $id: `f_${p}_analyse`, customerId: c, projectId: p, featureKey: 'analyse', state: 'active', enabled: true, siteKey: 'abcdefghijklmnop' }) const consent = () => ({ accepted: true, avvVersion: analyseUnterlagen().avvVersion }) // aw.docs() liefert eine Kopie: Dokumente werden ueber die API geloescht const leere = async (...collections) => { for (const col of collections) for (const id of aw.docs(col).keys()) await deleteDocument(col, id) } beforeEach(async () => { putSiteAufrufe.length = 0 await leere('portalFeatures', 'featureConsents', 'analyseConsents', 'subscriptions', 'websiteProjects', 'contractEvents', 'workorders') seedFixtures(aw) aw.seed('websiteProjects', { $id: 'p1', customerId: 'c1', projectName: 'Bäckerei', subdomain: 'baeckerei', liveDomain: 'baeckerei.de', isPublic: true }) config.kundenansichtNeu.mode = 'pilot' config.kundenansichtNeu.pilotEmails = ['k@example.invalid'] config.analyse.enabled = true mock.method(console, 'error', () => {}) mock.method(console, 'warn', () => {}) mock.method(console, 'log', () => {}) }) afterEach(() => mock.restoreAll()) test('Eignung: Hosting zählt nur im Abo-Modell; gekündigt und kein Abo nicht', async () => { abo(hostingDoc) assert.deepEqual(await an.analyseEligibility('c1', 'p1'), { ok: false, reason: 'kein_abo' }) // alte Regel const neu = await an.analyseEligibility('c1', 'p1', { aboModell: true }) assert.deepEqual([neu.ok, neu.project.$id], [true, 'p1']) abo({ ...hostingDoc, cancelAtPeriodEnd: true }) assert.deepEqual(await an.analyseEligibility('c1', 'p1', { aboModell: true }), { ok: false, reason: 'kein_abo' }) await leere('subscriptions') assert.equal((await an.analyseEligibility('c1', 'p1', { aboModell: true })).reason, 'kein_abo') abo({ ...wartungDoc, status: 'trialing', trialEnd: '2099-01-01T05:00:00.000Z' }) // Probezeit: die Analyse-Eignung meldet weiter kein_abo assert.equal((await an.analyseEligibility('c1', 'p1', { aboModell: true })).reason, 'kein_abo') assert.equal(an.eligibilityMessage('kein_abo', { aboModell: true }), 'Die Website-Analyse gibt es nur mit einem laufenden Hosting- oder Wartungs-Abo.') assert.equal(an.eligibilityMessage('kein_abo'), 'Die Website-Analyse gibt es nur mit einem laufenden Wartungs-Abo.') assert.equal(an.eligibilityMessage('nicht_oeffentlich', { aboModell: true }), an.ELIGIBILITY_MESSAGES.nicht_oeffentlich) assert.equal(an.ELIGIBILITY_MESSAGES_ABO.kein_abo, 'Die Website-Analyse gibt es nur mit einem laufenden Hosting- oder Wartungs-Abo.') }) const aktivieren = (extra = {}, cookie = c1) => post('/api/funktionen/analyse/activate', { projectId: 'p1', consent: consent(), audienceConfirmed: true, ...extra }, { cookie }) const keinSchreiben = () => assert.deepEqual([aw.docs('featureConsents').size, putSiteAufrufe.length, aw.docs('portalFeatures').get('f_p1_analyse')?.state ?? 'off'], [0, 0, 'off']) test('Liste und Aktivieren über die Routen: neue Ansicht mit Hosting ja, alte Ansicht nein', async () => { abo(hostingDoc) const b = await get('/api/funktionen', { cookie: c1 }) const a = b.projects.find((p) => p.projectId === 'p1').analyse assert.deepEqual([a.eligible, a.reason, a.avvVersion, a.legal], [true, null, analyseUnterlagen().avvVersion, analyseUnterlagen().legal]) // Ansicht zeigt die Fassung, die der Server prüft assert.equal((await aktivieren()).status, 200) config.kundenansichtNeu.mode = 'off' const alt = (await get('/api/funktionen', { cookie: c1 })).projects.find((p) => p.projectId === 'p1').analyse assert.deepEqual([alt.eligible, alt.reasonText], [false, 'Die Website-Analyse gibt es nur mit einem laufenden Wartungs-Abo.']) }) test('Zielgruppe serverseitig erzwungen: ohne audienceConfirmed true 400 zielgruppe_fehlt, nichts geschrieben', async () => { abo(hostingDoc) for (const audienceConfirmed of [undefined, false, 'true', 1, null]) { const r = await aktivieren({ audienceConfirmed }) assert.deepEqual([r.status, r.body], [400, { error: 'Bitte bestätige, dass sich deine Website nicht gezielt an Kinder unter 16 Jahren richtet.', code: 'zielgruppe_fehlt' }], String(audienceConfirmed)) keinSchreiben() } assert.equal((await aktivieren({ consent: { accepted: true, avvVersion: '1999-01-01' } })).status, 400) // falsche Fassung: weiter abgelehnt, nichts geschrieben keinSchreiben() }) test('Nachweis der Aktivierung: Linkzeile, Zielgruppen-Zeile, Knopf, Fassungen und die Kundenart des Dokuments', async () => { abo(hostingDoc) for (const [kundenart, soll] of [['unternehmen', 'unternehmen'], ['privat', 'privat'], [undefined, 'privat'], ['kaputt', 'privat']]) { await leere('portalFeatures', 'featureConsents') aw.seed('customers', { ...customers.c1, ...(kundenart === undefined ? {} : { kundenart }) }) const r = await aktivieren({ kundenart: 'unternehmen', unternehmer: true }) assert.equal(r.status, 200, String(kundenart)) // Body-Werte zur Kundenart zählen nicht const [n] = [...aw.docs('featureConsents').values()] assert.equal(aw.docs('featureConsents').size, 1) assert.deepEqual([n.featureKey, n.projectId, n.customerId, n.kundenart, n.stripeSessionId], ['analyse', 'p1', 'c1', soll, ''], String(kundenart)) assert.deepEqual(JSON.parse(n.texte), [ { id: 'einschalten-zeile', text: `Es gelten Leistungsbeschreibung und Auftragsverarbeitung (Fassung ${analyseUnterlagen().avvVersion}).`, haken: false }, { id: 'zielgruppe', text: 'Meine Website richtet sich nicht gezielt an Kinder unter 16 Jahren.', haken: true }, { id: 'knopf', text: 'Analyse einschalten', haken: false }, ]) assert.deepEqual(JSON.parse(n.fassungen), { zustimmung: config.aboFunktionen.fassung, leistungsbeschreibung: analyseUnterlagen().avvVersion, avv: analyseUnterlagen().avvVersion }) assert.equal(aw.docs('portalFeatures').get('f_p1_analyse').audienceConfirmed, true) } assert.equal((await aktivieren({ accentColor: '#222222' })).status, 200) assert.equal(aw.docs('featureConsents').size, 1) // nur die Farbe einer aktiven Analyse: kein zweiter Nachweis }) test('Nachweis scheitert: alles zurückgenommen (kein on ohne Nachweis), der nächste Versuch klappt', async () => { abo(hostingDoc) aw.failNext({ collection: 'featureConsents', method: 'POST', status: 500 }) const r = await aktivieren() assert.equal(r.status, 500) assert.deepEqual([aw.docs('featureConsents').size, aw.docs('portalFeatures').get('f_p1_analyse').state, putSiteAufrufe.map((a) => a.active)], [0, 'off', [true, false]]) // Messdienst angemeldet und wieder zurückgenommen assert.equal((await aktivieren()).status, 200) assert.equal(aw.docs('featureConsents').size, 1) }) 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 () => { const stelle = (mode, pilotEmails = []) => Object.assign(config.kundenansichtNeu, { mode, pilotEmails }) const lauf = async () => { aktiveAnalyse('c1', 'p1') return (await an.runAboCheck({ now: JETZT })).deactivated.length } // Kunde c1: k@example.invalid abo(hostingDoc) for (const [mode, liste, deaktiviert] of [['on', [], 0], ['pilot', ['k@example.invalid'], 0], ['pilot', ['*'], 0], ['pilot', ['k2@example.invalid'], 1], ['pilot', [], 1], ['off', ['*'], 1]]) { stelle(mode, liste) assert.equal(await lauf(), deaktiviert, `${mode} ${liste}`) } aw.seed('customers', { ...customers.c1, email: ' K@Example.Invalid ' }) stelle('pilot', ['k@example.invalid']) assert.equal(await lauf(), 0, 'E-Mail des Kunden mit Großbuchstaben und Leerzeichen trifft die Liste') aw.seed('customers', customers.c1) stelle('on') abo({ ...wartungDoc, cancelAtPeriodEnd: true }) assert.equal(await lauf(), 0, 'gekündigt, aber noch laufend') abo({ ...wartungDoc, status: 'canceled' }) assert.equal(await lauf(), 1, 'beendet') assert.equal(await aboModellFuerKunde('gibtEsNicht'), false) // kein Kundendokument: kein Abo-Modell assert.equal(await aboModellFuerKunde(''), false) }) test('M2: aboModellFuerKunde liest bei Modus off kein Kundendokument (keine zusätzliche Lesung, kein neuer Fehlerpfad für Alt-Kunden)', async () => { const vorher = { mode: config.kundenansichtNeu.mode, pilotEmails: [...config.kundenansichtNeu.pilotEmails] } try { Object.assign(config.kundenansichtNeu, { mode: 'off', pilotEmails: ['*'] }) // ein transienter Lesefehler (503) trifft im Modus off nie: es wird gar nicht gelesen, die Antwort ist sofort false aw.failNext({ collection: 'customers', method: 'GET', status: 503 }) assert.equal(await aboModellFuerKunde('c1'), false) // der eingeplante Fehler ist nicht verbraucht: die erste Lesung im Pilot trifft ihn (Lesefehler werden weitergereicht) Object.assign(config.kundenansichtNeu, { mode: 'pilot', pilotEmails: ['k@example.invalid'] }) await assert.rejects(() => aboModellFuerKunde('c1'), (err) => err.status === 500 && err.code === 503) // adminFetch macht aus jedem 5xx den Status 500, code bleibt 503 // danach wie bisher: Pilot und on lesen das Kundendokument (kein Dokument: false) assert.equal(await aboModellFuerKunde('c1'), true) assert.equal(await aboModellFuerKunde('gibtEsNicht'), false) Object.assign(config.kundenansichtNeu, { mode: 'on', pilotEmails: [] }) assert.equal(await aboModellFuerKunde('c1'), true) assert.equal(await aboModellFuerKunde('gibtEsNicht'), false) } finally { Object.assign(config.kundenansichtNeu, vorher) } })