import { test, after, beforeEach } from 'node:test' import assert from 'node:assert/strict' import { startAppwriteMock } from './support/appwriteMock.js' import { startFakeStripe } from './support/fakeStripe.js' import { useTestEnv } from './support/testEnv.js' import { schemaKeys } from './support/schema.js' import { mailSink } from './support/mailSink.js' // Das Setup-Skript laedt config.js: erst nach useTestEnv() importieren, daher die Attributnamen hier fest const ANALYSE_KEYS = ['accentColor', 'avvVersion', 'avvAcceptedAt', 'avvAcceptedBy', 'activatedAt', 'offSince', 'privacyTicketId', 'activatedMailAt'] const aw = await startAppwriteMock({ schema: { portalFeatures: [...schemaKeys('portalFeatures'), ...ANALYSE_KEYS], subscriptions: schemaKeys('subscriptions'), }, }) const st = await startFakeStripe() useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) const { config } = await import('../server/config.js') const { wartungDoc, hosting2Doc } = await import('./support/fixtures.js') const an = await import('../server/services/analyse.js') const sf = await import('../server/services/siteFeatures.js') const { updateDocument } = await import('../server/services/appwriteAdmin.js') const { setMailTransportForTests } = await import('../server/services/mailer.js') const mails = mailSink() setMailTransportForTests(mails) after(() => { setMailTransportForTests(null) return Promise.all([aw.close(), st.close()]) }) const puts = [] const rebuilds = [] an.analyseDeps.client = { putSite: async (siteKey, body) => puts.push({ siteKey, ...body }) } an.analyseDeps.rebuild = async (project) => rebuilds.push(project.$id) const consent = () => ({ accepted: true, avvVersion: config.analyse.avvVersion }) const analyseTickets = () => [...aw.docs('workorders').values()].filter((t) => String(t.topic).includes('Hinweis auf Analyse')) const doc = (c, p) => sf.getFeatureDoc(c, p, 'analyse') aw.seed('customers', { $id: 'c1', name: 'Kunde Eins', email: 'eins@example.invalid', location: 'Berlin' }) aw.seed('customers', { $id: 'c2', name: 'Kunde Zwei', email: 'zwei@example.invalid' }) aw.seed('customers', { $id: 'c3', name: 'Kunde Drei', email: 'drei@example.invalid' }) aw.seed('subscriptions', wartungDoc) aw.seed('subscriptions', hosting2Doc) aw.seed('websiteProjects', { $id: 'p1', customerId: 'c1', projectName: 'Bäckerei', subdomain: 'baeckerei', liveDomain: 'https://Baeckerei.DE/start', isPublic: true, repoFullName: 'WEBklar/baeckerei' }) aw.seed('websiteProjects', { $id: 'p2', customerId: 'c1', projectName: 'Privat', subdomain: 'privat', isPublic: false }) aw.seed('websiteProjects', { $id: 'p3', customerId: 'c1', projectName: 'Ohne Sub', isPublic: true }) aw.seed('websiteProjects', { $id: 'p4', customerId: 'c2', projectName: 'Hosting-Kunde', subdomain: 'hosting', isPublic: true }) aw.seed('websiteProjects', { $id: 'p5', customerId: 'c3', projectName: 'Ohne Abo', subdomain: 'ohneabo', isPublic: true }) beforeEach(() => { puts.length = 0 rebuilds.length = 0 mails.sent.length = 0 }) test('Berechtigung: Wartungs-Abo, öffentlich, Subdomain, Eigentum', async () => { assert.equal((await an.analyseEligibility('c1', 'p1')).ok, true) assert.deepEqual(await an.analyseEligibility('c1', 'p2'), { ok: false, reason: 'nicht_oeffentlich' }) assert.deepEqual(await an.analyseEligibility('c1', 'p3'), { ok: false, reason: 'keine_subdomain' }) assert.deepEqual(await an.analyseEligibility('c2', 'p4'), { ok: false, reason: 'kein_abo' }, 'Hosting-Abo reicht nicht') assert.deepEqual(await an.analyseEligibility('c3', 'p5'), { ok: false, reason: 'kein_abo' }) assert.deepEqual(await an.analyseEligibility('c1', 'p4'), { ok: false, reason: 'nicht_dein_projekt' }) assert.deepEqual(await an.analyseEligibility('c1', 'gibts-nicht'), { ok: false, reason: 'nicht_dein_projekt' }) }) test('Einschalten abgelehnt: Hosting-Abo, kein Abo, nicht öffentlich, AVV fehlt, Farbe', async () => { await assert.rejects(an.activateAnalyse({ customerId: 'c2', projectId: 'p4', consent: consent() }), { status: 403 }) await assert.rejects(an.activateAnalyse({ customerId: 'c3', projectId: 'p5', consent: consent() }), { status: 403 }) await assert.rejects(an.activateAnalyse({ customerId: 'c1', projectId: 'p2', consent: consent() }), { status: 409 }) await assert.rejects(an.activateAnalyse({ customerId: 'c1', projectId: 'p1' }), { status: 400, message: 'Bitte den Auftragsverarbeitungsvertrag bestätigen' }) await assert.rejects(an.activateAnalyse({ customerId: 'c1', projectId: 'p1', consent: { accepted: true, avvVersion: 'alt' } }), { status: 400 }) await assert.rejects(an.activateAnalyse({ customerId: 'c1', projectId: 'p1', consent: { accepted: false, avvVersion: config.analyse.avvVersion } }), { status: 400 }) await assert.rejects(an.activateAnalyse({ customerId: 'c1', projectId: 'p1', accentColor: 'rot', consent: consent() }), { status: 400 }) assert.equal(puts.length, 0) assert.equal(analyseTickets().length, 0) }) test('Einschalten: Dokument, Sammeldienst, Neubau, Ticket, Mail K18', async () => { const { doc: d, changed } = await an.activateAnalyse({ customerId: 'c1', projectId: 'p1', accentColor: '#1a73e8', consent: consent() }) assert.equal(changed, true) assert.equal(d.featureKey, 'analyse') assert.equal(d.state, 'active') assert.equal(d.enabled, true) assert.match(d.siteKey, /^[a-z0-9]{16,40}$/) assert.equal(d.accentColor, '#1a73e8') assert.equal(d.avvVersion, config.analyse.avvVersion) assert.equal(d.avvAcceptedBy, 'customer') assert.ok(d.avvAcceptedAt && d.activatedAt) assert.deepEqual(puts, [{ siteKey: d.siteKey, projectId: 'p1', domains: ['baeckerei.project.webklar.com', 'baeckerei.de', 'www.baeckerei.de'], active: true }]) assert.deepEqual(rebuilds, ['p1']) const tickets = analyseTickets() assert.equal(tickets.length, 1) assert.equal(tickets[0].topic, 'Datenschutzerklärung von Bäckerei um Hinweis auf Analyse ergänzen') assert.match(tickets[0].details, /Funktion: analyse/) assert.match(tickets[0].details, /Domain\(s\): baeckerei\.project\.webklar\.com, baeckerei\.de/) assert.match(tickets[0].details, /https:\/\/baeckerei\.de\/datenschutz-analyse\.html/) assert.match(tickets[0].details, /Satz \+ Link in die bestehende Datenschutzerklärung einfügen/) assert.equal((await doc('c1', 'p1')).privacyTicketId, tickets[0].$id) assert.equal(mails.sent.length, 1) assert.equal(mails.sent[0].subject, 'Deine Website-Analyse ist aktiv – Bäckerei') assert.deepEqual(mails.sent[0].to.map((t) => t.address), ['eins@example.invalid']) }) test('Doppelt einschalten: kein zweites Ticket, keine zweite Mail, nur Farbe', async () => { const before = await doc('c1', 'p1') const same = await an.activateAnalyse({ customerId: 'c1', projectId: 'p1', accentColor: '#1a73e8', consent: consent() }) assert.equal(same.changed, false) assert.equal(rebuilds.length, 0) const recolored = await an.activateAnalyse({ customerId: 'c1', projectId: 'p1', accentColor: '#CC0000', consent: consent() }) assert.equal(recolored.doc.accentColor, '#cc0000') assert.equal(recolored.doc.siteKey, before.siteKey) assert.deepEqual(rebuilds, ['p1']) assert.equal(analyseTickets().length, 1) assert.equal(mails.sent.length, 0) assert.equal(puts.length, 0) }) test('Ausschalten: off, offSince, Sammeldienst inaktiv, Neubau; erneut = nichts', async () => { const now = new Date('2026-10-05T10:00:00.000Z') const { doc: d, changed } = await an.deactivateAnalyse({ customerId: 'c1', projectId: 'p1', now }) assert.equal(changed, true) assert.equal(d.state, 'off') assert.equal(d.enabled, false) assert.equal(d.offSince, '2026-10-05T10:00:00.000Z') assert.equal(puts.length, 1) assert.equal(puts[0].active, false) assert.deepEqual(rebuilds, ['p1']) assert.equal((await an.deactivateAnalyse({ customerId: 'c1', projectId: 'p1', now })).changed, false) await assert.rejects(an.deactivateAnalyse({ customerId: 'c2', projectId: 'p1' }), { status: 403 }) }) test('Wieder einschalten: gleicher siteKey, offSince leer, kein neues Ticket', async () => { const before = await doc('c1', 'p1') const { doc: d } = await an.activateAnalyse({ customerId: 'c1', projectId: 'p1', consent: consent(), by: 'Kenso' }) assert.equal(d.siteKey, before.siteKey) assert.equal(d.state, 'active') assert.equal(d.offSince, '') assert.equal(d.accentColor, '#111111') assert.equal(d.avvAcceptedBy, 'Kenso') assert.equal(analyseTickets().length, 1) }) test('Abo-Ende-Job: aktive Analyse ohne Wartungs-Abo wird beendet, K19', async () => { // c2 hat nur Hosting-Abo, c3 gar keins: beide Analysen von Hand "aktiv" (z. B. Abo wurde gewechselt) for (const [c, p] of [['c2', 'p4'], ['c3', 'p5']]) { const d = await sf.ensureFeatureDoc(c, p, 'analyse') await updateDocument(config.collections.portalFeatures, d.$id, { ...sf.stateFields('active'), accentColor: '#111111' }) } const now = new Date('2026-11-01T03:15:00.000Z') const result = await an.runAboCheck({ now }) assert.equal(result.checked, 3) assert.equal(result.deactivated.length, 2) assert.equal((await doc('c1', 'p1')).state, 'active', 'Wartungs-Abo bleibt aktiv') for (const [c, p] of [['c2', 'p4'], ['c3', 'p5']]) { const d = await doc(c, p) assert.equal(d.state, 'off') assert.equal(d.offSince, now.toISOString()) } assert.deepEqual(mails.sent.map((m) => m.subject).sort(), ['Deine Website-Analyse wurde beendet – Hosting-Kunde', 'Deine Website-Analyse wurde beendet – Ohne Abo']) mails.sent.length = 0 assert.equal((await an.runAboCheck({ now })).deactivated.length, 0, 'zweiter Lauf: nichts mehr zu tun') assert.equal(mails.sent.length, 0) }) test('News-Jobs ignorieren Analyse-Dokumente', async () => { const d = await sf.ensureFeatureDoc('c1', 'p2', 'analyse') aw.seed('workorders', { $id: 't_an', status: 'Closed', woid: '1', topic: 'x' }) await updateDocument(config.collections.portalFeatures, d.$id, { ...sf.stateFields('setup'), ticketId: 't_an' }) assert.deepEqual(await sf.activateClosedTickets({ customerId: 'c1' }), []) assert.deepEqual(await sf.runActivation({ customerId: 'c1' }), []) assert.equal((await doc('c1', 'p2')).state, 'setup') const active = await doc('c1', 'p1') assert.equal(await sf.findActiveBySiteKey(active.siteKey.slice(0, 16)), null, 'Analyse-siteKey ist kein News-Schlüssel') }) test('selbstheilend: Ticket scheitert einmal, Wiederholung holt Ticket, Neubau und Mail genau einmal nach', async () => { aw.seed('websiteProjects', { $id: 'p6', customerId: 'c1', projectName: 'Heilung', subdomain: 'heilung', isPublic: true, repoFullName: 'WEBklar/heilung' }) const original = an.analyseDeps.ticket let failOnce = true an.analyseDeps.ticket = async (...args) => { if (failOnce) { failOnce = false; throw new Error('Ticketsystem down') } return original(...args) } const before = analyseTickets().length await assert.rejects(an.activateAnalyse({ customerId: 'c1', projectId: 'p6', consent: consent() }), /Ticketsystem down/) assert.equal((await doc('c1', 'p6')).state, 'active') assert.equal(rebuilds.length, 0) assert.equal(mails.sent.length, 0) const r = await an.activateAnalyse({ customerId: 'c1', projectId: 'p6', consent: consent() }) assert.equal(r.doc.state, 'active') assert.ok(r.doc.privacyTicketId && r.doc.activatedMailAt) assert.equal(analyseTickets().length, before + 1) assert.deepEqual(rebuilds, ['p6']) assert.equal(mails.sent.length, 1) // dritter Aufruf: nichts mehr offen await an.activateAnalyse({ customerId: 'c1', projectId: 'p6', consent: consent() }) assert.equal(analyseTickets().length, before + 1) assert.deepEqual(rebuilds, ['p6']) assert.equal(mails.sent.length, 1) an.analyseDeps.ticket = original }) test('putSite scheitert: Dokument bleibt unverändert', async () => { aw.seed('websiteProjects', { $id: 'p7', customerId: 'c1', projectName: 'Down', subdomain: 'down', isPublic: true }) const ok = an.analyseDeps.client an.analyseDeps.client = { putSite: async () => { throw Object.assign(new Error('Auswertung nicht erreichbar'), { status: 502 }) } } await assert.rejects(an.activateAnalyse({ customerId: 'c1', projectId: 'p7', consent: consent() }), { status: 502 }) assert.equal(await doc('c1', 'p7') === null || (await doc('c1', 'p7')).state === 'off', true) assert.equal(analyseTickets().filter((t) => t.details.includes('Down')).length, 0) an.analyseDeps.client = ok }) test('Neubau scheitert: Analyse bleibt aktiv, kein Fehler', async () => { aw.seed('websiteProjects', { $id: 'p8', customerId: 'c1', projectName: 'Bau', subdomain: 'bau', isPublic: true, repoFullName: 'WEBklar/bau' }) const ok = an.analyseDeps.rebuild an.analyseDeps.rebuild = async () => { throw new Error('Runner weg') } const r = await an.activateAnalyse({ customerId: 'c1', projectId: 'p8', consent: consent() }) assert.equal(r.doc.state, 'active') assert.equal(mails.sent.length, 1) an.analyseDeps.rebuild = ok }) test('parallel einschalten: ein Ticket, eine Mail', async () => { aw.seed('websiteProjects', { $id: 'p9', customerId: 'c1', projectName: 'Parallel', subdomain: 'parallel', isPublic: true }) const before = analyseTickets().length mails.sent.length = 0 await Promise.all([ an.activateAnalyse({ customerId: 'c1', projectId: 'p9', consent: consent() }), an.activateAnalyse({ customerId: 'c1', projectId: 'p9', consent: consent() }), ]) assert.equal(analyseTickets().length, before + 1) assert.equal(mails.sent.length, 1) }) test('gesperrte Analyse: Kunde 403, Mitarbeiter darf', async () => { aw.seed('websiteProjects', { $id: 'p10', customerId: 'c1', projectName: 'Gesperrt', subdomain: 'gesperrt', isPublic: true }) const g = await sf.ensureFeatureDoc('c1', 'p10', 'analyse') await sf.disable(g.$id, {}) await assert.rejects(an.activateAnalyse({ customerId: 'c1', projectId: 'p10', consent: consent() }), { status: 403, message: 'Analyse wurde gesperrt – bitte melde dich bei uns' }) const r = await an.activateAnalyse({ customerId: 'c1', projectId: 'p10', consent: consent(), by: 'Kenso' }) assert.equal(r.doc.state, 'active') }) test('analyseDomains: www-/Apex-Gegenstück der Live-Domain, ohne Doppelte, max. 5, nur gültige Hosts', () => { const d = (live, sub = 'x') => an.analyseDomains({ subdomain: sub, liveDomain: live }) assert.deepEqual(d('example.de'), ['x.project.webklar.com', 'example.de', 'www.example.de']) assert.deepEqual(d('https://www.Example.de/start'), ['x.project.webklar.com', 'www.example.de', 'example.de']) assert.deepEqual(d(''), ['x.project.webklar.com']) assert.deepEqual(d('kaputt!'), ['x.project.webklar.com']) assert.ok(an.analyseDomains({ subdomain: 'x', liveDomain: 'a.b.c.example.de' }).length <= 5) // gespeicherter Host bleibt die Live-Domain (Heatmap-Link, Ticket) assert.deepEqual(an.analyseHosts({ subdomain: 'x', liveDomain: 'example.de' }), ['x.project.webklar.com', 'example.de']) })