Files
Webklar-Kundenbereich/test/analyseAboModell.test.js
T
ClaudeandClaude Sonnet 5.5 ddaf2879c4 Kundenansicht neu E-Fix M2: aboModellFuerKunde liest bei Modus off kein Kundendokument
aboModellFuerKunde las das Kundendokument auch bei KUNDENANSICHT_NEU=off, obwohl neueAnsichtFuerKunde dort sofort false liefert. Alt-Kunden
bekamen so eine zusätzliche Lesung und einen neuen Fehlerpfad (ein 503 beim Widerruf des Wechsels ließ die Beenden-Mail der Analyse
entfallen, die Team-Aktivierung und GET /api/admin/analyse/:id antworteten mit 500). Jetzt sofort false im Modus off; Pilot und on
lesen wie bisher (kein Dokument: false). Test in analyseAboModell.test.js (eingeplanter 503 wird im Modus off nicht verbraucht).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 21:41:28 +00:00

222 lines
14 KiB
JavaScript

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)
}
})