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', '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'), analyseConsents: CONSENT_KEYS, }, }) 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 cl = await import('../server/services/analyseConsent.js') const priv = await import('../server/services/analysePrivacy.js') const { updateDocument, deleteDocument } = 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 stops = [] const rebuilds = [] an.analyseDeps.client = { putSite: async (siteKey, body) => puts.push({ siteKey, ...body }), stopSite: async (siteKey) => stops.push(siteKey) } an.analyseDeps.rebuild = async (project) => rebuilds.push(project.$id) const consent = () => ({ accepted: true, avvVersion: config.analyse.avvVersion }) const actor = { name: 'Kunde Eins', email: 'eins@example.invalid' } const cust = (extra = {}) => ({ consent: consent(), actor, ...extra }) const textform = { date: '2026-10-02', channel: 'email' } const NOW = new Date('2026-10-05T10:00:00.000Z') const consents = (p) => cl.listConsents(p) const analyseTickets = () => [...aw.docs('workorders').values()].filter((t) => String(t.topic).includes('Hinweis auf Analyse')) const topics = () => [...aw.docs('workorders').values()].map((t) => String(t.topic)) 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: 'c4', name: '', email: 'vier@example.invalid' }) aw.seed('customers', { $id: 'c5', name: 'Kunde Fuenf', email: 'x' }) aw.seed('customers', { $id: 'c3', name: 'Kunde Drei', email: 'drei@example.invalid' }) aw.seed('subscriptions', wartungDoc) aw.seed('subscriptions', hosting2Doc) for (const c of ['c4', 'c5']) aw.seed('subscriptions', { ...wartungDoc, $id: `sub_w_${c}`, stripeSubscriptionId: `sub_w_${c}`, customerId: c }) 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: 'p11', customerId: 'c4', projectName: 'Ohne Name', subdomain: 'ohnename', isPublic: true, repoFullName: 'WEBklar/ohnename' }) aw.seed('websiteProjects', { $id: 'p12', customerId: 'c5', projectName: 'Schlechte Mail', subdomain: 'schlechtemail', isPublic: true }) aw.seed('websiteProjects', { $id: 'p5', customerId: 'c3', projectName: 'Ohne Abo', subdomain: 'ohneabo', isPublic: true }) beforeEach(() => { puts.length = 0 stops.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', ...cust() }), { status: 403 }) await assert.rejects(an.activateAnalyse({ customerId: 'c3', projectId: 'p5', ...cust() }), { status: 403 }) await assert.rejects(an.activateAnalyse({ customerId: 'c1', projectId: 'p2', ...cust() }), { 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', ...cust() }), { 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', ...cust() }) 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, 'Kunde Eins ') 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.doesNotMatch(tickets[0].details, /Satz \+ Link in die bestehende Datenschutzerklärung einfügen/) for (const part of ['Textbaustein', 'offensichtlicher Widerspruch', 'externer Generator', 'Stundenkontingent', 'Wir messen mit einer eigenen Reichweitenmessung ohne Cookies', 'Solange Sie im Banner nichts ausgewählt haben', 'Solange Du im Banner nichts ausgewählt hast']) { assert.ok(tickets[0].details.includes(part), part) } assert.ok(tickets[0].details.length < 8000, 'passt in workorders.details (8000)') assert.equal(topics().filter((t) => t.includes('veröffentlichen')).length, 0, 'Neubau lief: kein Veröffentlichen-Ticket') 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']) const k18 = mails.sent[0] assert.deepEqual(k18.attachments.map((a) => a.filename), ['AVV-Website-Analyse-2026-10-02.pdf', 'Leistungsbeschreibung-Website-Analyse-2026-10-02.pdf']) for (const a of k18.attachments) assert.equal(Buffer.from(a.content, 'base64').subarray(0, 4).toString(), '%PDF') assert.match(k18.html, /2026-10-02/) assert.ok(k18.html.includes(`${config.publicBaseUrl}/rechtliches/avv-website-analyse-2026-10-02.html`)) assert.ok(k18.html.includes(`${config.publicBaseUrl}/rechtliches/leistungsbeschreibung-website-analyse-2026-10-02.html`)) assert.match(k18.html, /durch Kunde Eins <eins@example\.invalid>/) assert.match(k18.html, /\d\d\.\d\d\.\d{4}, \d\d:\d\d Uhr/) assert.match(k18.text, /Das Einwilligungsbanner erscheint mit der nächsten Veröffentlichung/) assert.doesNotMatch(k18.html, /in Textform am/) }) 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', ...cust() }) assert.equal(same.changed, false) assert.equal(rebuilds.length, 0) const recolored = await an.activateAnalyse({ customerId: 'c1', projectId: 'p1', accentColor: '#CC0000', ...cust() }) 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) assert.equal((await consents('p1')).length, 1, 'Farbwechsel/zweites Aktivieren schreibt keine Historie') }) 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, 0) assert.deepEqual(stops, [d.siteKey]) 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, neues Ticket und Historie', async () => { const before = await doc('c1', 'p1') const { doc: d } = await an.activateAnalyse({ customerId: 'c1', projectId: 'p1', consent: consent(), by: 'Kenso', textform, audienceConfirmed: true, now: NOW }) 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(d.avvConsentDate, '2026-10-02') assert.equal(d.avvConsentChannel, 'email') assert.equal(analyseTickets().length, 2, 'neues Ticket nach Aus/An') }) 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']) for (const m of mails.sent) { assert.match(m.text, /keine Daten mehr gespeichert/) assert.match(m.text, /46\. Tag/) assert.doesNotMatch(m.text, /Der Banner und die Messung sind von Deiner Website entfernt/) } 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', ...cust() }), /Ticketsystem down/) assert.equal((await doc('c1', 'p6')).state, 'active') assert.equal(mails.sent.length, 0) const r = await an.activateAnalyse({ customerId: 'c1', projectId: 'p6', ...cust() }) assert.equal(r.doc.state, 'active') assert.ok(r.doc.privacyTicketId && r.doc.activatedMailAt) assert.equal(analyseTickets().length, before + 1) assert.deepEqual(rebuilds, ['p6', 'p6'], 'Neubau kommt vor dem Ticket, die Wiederholung baut erneut') assert.equal(mails.sent.length, 1) // dritter Aufruf: nichts mehr offen await an.activateAnalyse({ customerId: 'c1', projectId: 'p6', ...cust() }) assert.equal(analyseTickets().length, before + 1) assert.deepEqual(rebuilds, ['p6', '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', ...cust() }), { 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', ...cust() }) 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', ...cust() }), an.activateAnalyse({ customerId: 'c1', projectId: 'p9', ...cust() }), ]) 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', ...cust() }), { status: 403, message: 'Analyse wurde gesperrt – bitte melde dich bei uns' }) const r = await an.activateAnalyse({ customerId: 'c1', projectId: 'p10', consent: consent(), by: 'Kenso', textform, audienceConfirmed: true, now: NOW }) 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']) }) // ---- Rechtsrunde Task 5: Verantwortlichen-Angaben, Historie, Textform ---- test('analysePrivacyData: Firma/Name, E-Mail, Bereinigung, Adresse', () => { const d = priv.analysePrivacyData assert.deepEqual(d({ companyName: '', name: '', email: 'a@b.de' }).missing, ['firma']) const nameOnly = d({ name: 'Max Muster', email: 'a@b.de' }) assert.equal(nameOnly.company, 'Max Muster') assert.equal(nameOnly.ok, true) assert.deepEqual(nameOnly.missing, []) assert.deepEqual(d({ name: 'Max', email: 'abc' }).missing, ['email']) assert.deepEqual(d({ name: '', email: '' }).missing, ['firma', 'email']) assert.deepEqual(d(null).missing, ['firma', 'email']) assert.equal(d({ companyName: 'Muster\nGmbH\t& Co', email: 'a@b.de' }).company, 'Muster GmbH & Co') assert.equal(d({ name: 'M', email: 'a@b.de', street: 'Weg 1' }).address, 'Weg 1') assert.equal(d({ name: 'M', email: 'a@b.de', street: 'Weg 1', postalCode: '12345', city: 'Berlin' }).address, 'Weg 1, 12345 Berlin') assert.equal(d({ name: 'x'.repeat(300), email: 'a@b.de' }).company.length, 200) assert.deepEqual(d({ name: 'M', email: `${'a'.repeat(200)}@b.de` }).missing, ['email']) assert.deepEqual(d({ name: 'M', email: 'a b@c.de' }).missing, ['email']) }) test('validateTextform: Datum, Zukunft, Fassung, Weg', () => { const ctx = { today: '2026-10-05', minDate: '2026-10-02' } const bad = (t) => assert.throws(() => an.validateTextform(t, ctx), { status: 400 }) bad({ date: '2026-10-01', channel: 'chat' }) bad({ date: '2026-10-06', channel: 'chat' }) bad({ date: '2026-02-30', channel: 'chat' }) bad({ date: '02.10.2026', channel: 'chat' }) bad({ date: '2026-10-02', channel: 'telefon' }) bad({ date: '2026-10-02' }) bad(undefined) assert.deepEqual(an.validateTextform({ date: '2026-10-02', channel: 'email' }, ctx), { date: '2026-10-02', channel: 'email' }) assert.deepEqual(an.validateTextform({ date: '2026-10-05', channel: 'chat' }, ctx), { date: '2026-10-05', channel: 'chat' }) }) test('Einschalten ohne Firma/Name oder mit ungültiger E-Mail: 409, nichts passiert', async () => { const before = analyseTickets().length for (const [c, p] of [['c4', 'p11'], ['c5', 'p12']]) { await assert.rejects(an.activateAnalyse({ customerId: c, projectId: p, ...cust() }), { status: 409, message: 'Für die Zusatzseite fehlen in Deinen Kundendaten Firma oder Name und eine gültige E-Mail-Adresse. Schreib uns im Chat, wir ergänzen sie.', }) assert.equal((await doc(c, p)).state, 'off') } assert.equal(puts.length, 0) assert.equal(analyseTickets().length, before) assert.equal(mails.sent.length, 0) assert.equal((await consents('p11')).length, 0) }) test('Kunde ohne actor-Namen oder Mitarbeiter ohne Textform: 400', async () => { aw.seed('websiteProjects', { $id: 'p13', customerId: 'c1', projectName: 'Nachweis', subdomain: 'nachweis', isPublic: true }) await assert.rejects(an.activateAnalyse({ customerId: 'c1', projectId: 'p13', consent: consent() }), { status: 400 }) await assert.rejects(an.activateAnalyse({ customerId: 'c1', projectId: 'p13', consent: consent(), actor: {} }), { status: 400 }) await assert.rejects(an.activateAnalyse({ customerId: 'c1', projectId: 'p13', consent: consent(), by: 'Kenso', now: NOW }), { status: 400 }) await assert.rejects(an.activateAnalyse({ customerId: 'c1', projectId: 'p13', by: 'Kenso', textform, now: NOW }), { status: 400 }) // ohne Zielgruppen-Bestätigung await assert.rejects(an.activateAnalyse({ customerId: 'c1', projectId: 'p13', by: 'Kenso', audienceConfirmed: true, textform: { date: '2026-10-01', channel: 'chat' }, now: NOW }), { status: 400 }) assert.equal(puts.length, 0) assert.equal((await consents('p13')).length, 0) }) test('Historie: Kunde, Aus, An durch Mitarbeiter in zeitlicher Folge; Portal-Dokument gleich', async () => { aw.seed('websiteProjects', { $id: 'p14', customerId: 'c1', projectName: 'Historie', subdomain: 'historie', isPublic: true }) const { doc: d1 } = await an.activateAnalyse({ customerId: 'c1', projectId: 'p14', ...cust() }) let h = await consents('p14') assert.equal(h.length, 1) assert.equal(h[0].event, 'on') assert.equal(h[0].via, 'portal') assert.equal(h[0].avvVersion, '2026-10-02') assert.equal(h[0].acceptedBy, 'Kunde Eins ') assert.equal(h[0].customerId, 'c1') assert.equal(h[0].siteKey, d1.siteKey) assert.match(h[0].acceptedAt, /^\d{4}-\d\d-\d\dT[\d:.]+Z$/) assert.equal(h[0].acceptedAt, d1.avvAcceptedAt) assert.equal(d1.avvAcceptedBy, 'Kunde Eins ') assert.equal(d1.audienceConfirmed, true) assert.equal(d1.audienceTextVersion, '2026-10-02') assert.equal(h[0].audienceConfirmed, true) assert.equal(h[0].audienceTextVersion, '2026-10-02') assert.equal(d1.avvConsentDate, '') assert.equal(d1.avvConsentChannel, '') // Farbwechsel und erneutes Aktivieren: keine neue Zeile await an.activateAnalyse({ customerId: 'c1', projectId: 'p14', accentColor: '#222222', ...cust() }) await an.activateAnalyse({ customerId: 'c1', projectId: 'p14', ...cust() }) assert.equal((await consents('p14')).length, 1) // Marker zurücksetzen beim Wiedereinschalten await updateDocument(config.collections.portalFeatures, d1.$id, { removalTicketId: 'r1', publishTicketId: 'u1' }) await an.deactivateAnalyse({ customerId: 'c1', projectId: 'p14', now: NOW, actor }) const { doc: d3 } = await an.activateAnalyse({ customerId: 'c1', projectId: 'p14', consent: consent(), by: 'Kenso', textform, audienceConfirmed: true, now: NOW }) assert.equal(d3.privacyTicketId && d3.privacyTicketId !== d1.privacyTicketId, true, 'neues Datenschutz-Ticket') assert.equal(d3.removalTicketId, '') assert.ok(d3.publishTicketId && d3.publishTicketId !== 'u1', 'p14 ohne Repo: neues Veröffentlichen-Ticket statt altem Marker') h = await consents('p14') assert.deepEqual(h.map((x) => x.event), ['on', 'off', 'on']) assert.deepEqual(h.map((x) => x.via), ['portal', 'portal', 'woms']) assert.equal(h[1].acceptedBy, 'Kunde Eins ') assert.equal(h[2].acceptedBy, 'Kenso') assert.equal(h[2].textformDate, '2026-10-02') assert.equal(h[2].textformChannel, 'email') assert.equal(h[2].audienceConfirmed, true) assert.equal(d3.audienceConfirmed, true) assert.equal(d3.avvConsentDate, '2026-10-02') assert.equal(d3.avvAcceptedBy, 'Kenso') }) test('analyseConsent exportiert nur recordConsent und listConsents', () => { assert.deepEqual(Object.keys(cl).sort(), ['listConsents', 'recordConsent']) }) test('recordConsent scheitert: putSite wird zurückgenommen, Dokument bleibt aus', async () => { aw.seed('websiteProjects', { $id: 'p15', customerId: 'c1', projectName: 'Rollback', subdomain: 'rollback', isPublic: true }) const prev = an.analyseDeps.recordConsent an.analyseDeps.recordConsent = async () => { throw new Error('Historie weg') } await assert.rejects(an.activateAnalyse({ customerId: 'c1', projectId: 'p15', ...cust() }), /Historie weg/) an.analyseDeps.recordConsent = prev assert.equal(puts.length, 2) assert.equal(puts[0].active, true) assert.equal(puts[1].active, false) assert.equal((await doc('c1', 'p15')).state, 'off', 'Dokument zurückgesetzt') assert.equal((await doc('c1', 'p15')).offSince, '') assert.equal(analyseTickets().filter((t) => t.details.includes('Rollback')).length, 0) }) test('Ausschalten schreibt off-Historie; Fehler dort verhindert das Ausschalten nicht', async () => { aw.seed('websiteProjects', { $id: 'p16', customerId: 'c1', projectName: 'Aus', subdomain: 'aus', isPublic: true }) await an.activateAnalyse({ customerId: 'c1', projectId: 'p16', ...cust() }) const prev = an.analyseDeps.recordConsent an.analyseDeps.recordConsent = async () => { throw new Error('Historie weg') } const r = await an.deactivateAnalyse({ customerId: 'c1', projectId: 'p16', now: NOW, actor }) an.analyseDeps.recordConsent = prev assert.equal(r.doc.state, 'off') assert.deepEqual((await consents('p16')).map((x) => x.event), ['on']) await an.activateAnalyse({ customerId: 'c1', projectId: 'p16', ...cust() }) await an.deactivateAnalyse({ customerId: 'c1', projectId: 'p16', now: NOW, actor }) assert.deepEqual((await consents('p16')).map((x) => x.event), ['on', 'on', 'off']) }) const staff = (extra = {}) => ({ consent: consent(), by: 'Kenso', textform: { date: '2026-10-03', channel: 'email' }, audienceConfirmed: true, now: NOW, ...extra }) test('K18 Mitarbeiter-Weg: in Textform am 03.10.2026 per E-Mail', async () => { aw.seed('websiteProjects', { $id: 'p20', customerId: 'c1', projectName: 'Textform', subdomain: 'textform', isPublic: true, repoFullName: 'WEBklar/textform' }) await an.activateAnalyse({ customerId: 'c1', projectId: 'p20', ...staff() }) assert.equal(mails.sent.length, 1) assert.match(mails.sent[0].html, /in Textform am 03\.10\.2026 per E-Mail/) assert.doesNotMatch(mails.sent[0].html, /Uhr/) }) test('K18: PDFs nicht lesbar => keine Mail, Marker leer, Aktivierung gültig, spätere Wiederholung sendet', async () => { aw.seed('websiteProjects', { $id: 'p21', customerId: 'c1', projectName: 'OhnePdf', subdomain: 'ohnepdf', isPublic: true, repoFullName: 'WEBklar/ohnepdf' }) const { legalPdfAttachments } = await import('../server/services/analyseLegal.js') const ok = an.analyseDeps.legalPdfs an.analyseDeps.legalPdfs = () => legalPdfAttachments('1999-01-01') const r = await an.activateAnalyse({ customerId: 'c1', projectId: 'p21', ...cust() }) assert.equal(r.doc.state, 'active') assert.equal(r.doc.activatedMailAt, '') assert.equal(mails.sent.length, 0) an.analyseDeps.legalPdfs = ok const again = await an.activateAnalyse({ customerId: 'c1', projectId: 'p21', ...cust() }) assert.ok(again.doc.activatedMailAt) assert.equal(mails.sent.length, 1) }) test('Entfernen-Ticket: Kunde, Mitarbeiter, zweimal = ein Ticket, Aus/An/Aus = zwei', async () => { aw.seed('websiteProjects', { $id: 'p22', customerId: 'c1', projectName: 'Bäckerei Aus', subdomain: 'baeckereiaus', isPublic: true, repoFullName: 'WEBklar/aus' }) const removal = () => topics().filter((t) => t === 'Website-Analyse von Bäckerei Aus entfernen') await an.activateAnalyse({ customerId: 'c1', projectId: 'p22', ...cust() }) assert.equal(removal().length, 0) const off1 = await an.deactivateAnalyse({ customerId: 'c1', projectId: 'p22', now: NOW, actor }) assert.ok(off1.doc.removalTicketId) await an.deactivateAnalyse({ customerId: 'c1', projectId: 'p22', now: NOW, actor }) assert.equal(removal().length, 1) const ticket = [...aw.docs('workorders').values()].find((t) => t.topic === 'Website-Analyse von Bäckerei Aus entfernen') assert.match(ticket.details, /fünf Werktagen/) assert.match(ticket.details, /Stundenkontingent/) await an.activateAnalyse({ customerId: 'c1', projectId: 'p22', ...staff() }) const off2 = await an.deactivateAnalyse({ customerId: 'c1', projectId: 'p22', now: new Date(NOW.getTime() + 3600000), by: 'Kenso' }) assert.equal(removal().length, 2) assert.notEqual(off2.doc.removalTicketId, off1.doc.removalTicketId) }) test('Entfernen-Ticket: Ticketfehler verhindert das Ausschalten nicht', async () => { aw.seed('websiteProjects', { $id: 'p23', customerId: 'c1', projectName: 'TicketFehler', subdomain: 'ticketfehler', isPublic: true, repoFullName: 'WEBklar/tf' }) await an.activateAnalyse({ customerId: 'c1', projectId: 'p23', ...cust() }) const original = an.analyseDeps.ticket an.analyseDeps.ticket = async () => { throw new Error('Ticketsystem down') } const r = await an.deactivateAnalyse({ customerId: 'c1', projectId: 'p23', now: NOW, actor }) an.analyseDeps.ticket = original assert.equal(r.doc.state, 'off') assert.equal(r.changed, true) }) test('Abo-Ende über runAboCheck: Entfernen-Ticket genau einmal', async () => { aw.seed('websiteProjects', { $id: 'p24', customerId: 'c3', projectName: 'Abo Ende', subdomain: 'aboende', isPublic: true, repoFullName: 'WEBklar/aboende' }) const d = await sf.ensureFeatureDoc('c3', 'p24', 'analyse') await updateDocument(config.collections.portalFeatures, d.$id, { ...sf.stateFields('active'), accentColor: '#111111' }) await an.runAboCheck({ now: NOW }) await an.runAboCheck({ now: NOW }) assert.equal(topics().filter((t) => t === 'Website-Analyse von Abo Ende entfernen').length, 1) }) test('rebuildSafe-Ergebnis: kein_repo, preview_aus, fehler => Veröffentlichen-Ticket; Erfolg => keins', async () => { const cases = [ ['p30', 'KeinRepo', {}, 'kein_repo'], ['p31', 'PreviewAus', { repoFullName: 'WEBklar/pa', previewMode: 'off' }, 'preview_aus'], ['p32', 'BauFehler', { repoFullName: 'WEBklar/bf' }, 'fehler'], ['p33', 'BauErgebnisFehler', { repoFullName: 'WEBklar/bef' }, 'fehler'], ['p34', 'BauOk', { repoFullName: 'WEBklar/bo' }, null], ] const original = an.analyseDeps.rebuild for (const [id, name, extra, reason] of cases) { aw.seed('websiteProjects', { $id: id, customerId: 'c1', projectName: name, subdomain: name.toLowerCase(), isPublic: true, ...extra }) an.analyseDeps.rebuild = async (p) => { rebuilds.push(p.$id) if (id === 'p32') throw new Error('Runner weg') if (id === 'p33') return { error: 'Build kaputt' } return { ok: true } } rebuilds.length = 0 const r = await an.activateAnalyse({ customerId: 'c1', projectId: id, ...cust() }) const pub = [...aw.docs('workorders').values()].filter((t) => t.topic === `Website-Analyse von ${name} veröffentlichen`) assert.equal(pub.length, reason ? 1 : 0, name) if (reason) { assert.equal(r.doc.publishTicketId, pub[0].$id) assert.ok(pub[0].details.includes(reason === 'kein_repo' ? 'kein Repo' : reason === 'preview_aus' ? 'ausgeschaltet' : 'fehlgeschlagen'), name) const privacy = [...aw.docs('workorders').values()].find((t) => t.topic === `Datenschutzerklärung von ${name} um Hinweis auf Analyse ergänzen`) assert.match(privacy.details, /Veröffentlichung:/) } if (id === 'p30' || id === 'p31') assert.deepEqual(rebuilds, [], 'Rebuild-Stub nicht aufgerufen') await an.activateAnalyse({ customerId: 'c1', projectId: id, ...cust() }) assert.equal([...aw.docs('workorders').values()].filter((t) => t.topic === `Website-Analyse von ${name} veröffentlichen`).length, reason ? 1 : 0, 'genau eines je Aktivierung') } an.analyseDeps.rebuild = original }) // ---- Task 9: alle Beendigungswege ---- const FULL = { avvVersion: '2026-10-02', avvAcceptedAt: '2026-10-03T08:00:00.000Z', avvAcceptedBy: 'x', activatedAt: '2026-10-03T08:00:00.000Z', activatedMailAt: '2026-10-03T08:00:01.000Z', privacyTicketId: 'pt' } async function seedFeature(customerId, projectId, state, extra = {}) { const d = await sf.ensureFeatureDoc(customerId, projectId, 'analyse') return updateDocument(config.collections.portalFeatures, d.$id, { ...sf.stateFields(state), accentColor: '#111111', ...FULL, ...extra }) } const removalTopics = (name) => topics().filter((t) => t === `Website-Analyse von ${name} entfernen`) test('Kunde gelöscht: endAnalyseForCustomer stoppt jeden aktiven Schlüssel, off, kein Ticket, keine Mail, Fehler werden geschluckt', async () => { aw.seed('customers', { $id: 'k1', name: 'Weg Eins', email: 'weg1@example.invalid' }) aw.seed('websiteProjects', { $id: 'k1p1', customerId: 'k1', projectName: 'KP1', subdomain: 'kp1', isPublic: true, repoFullName: 'WEBklar/kp1' }) aw.seed('websiteProjects', { $id: 'k1p2', customerId: 'k1', projectName: 'KP2', subdomain: 'kp2', isPublic: true }) const a = await seedFeature('k1', 'k1p1', 'active') const b = await seedFeature('k1', 'k1p2', 'active') const real = an.analyseDeps.client.stopSite an.analyseDeps.client.stopSite = async (k) => { stops.push(k); if (k === b.siteKey) throw new Error('Sammeldienst weg') } const r = await an.endAnalyseForCustomer('k1', { reason: 'kunde_geloescht', mail: false }) an.analyseDeps.client.stopSite = real assert.deepEqual(stops.sort(), [a.siteKey, b.siteKey].sort()) assert.equal((await doc('k1', 'k1p1')).state, 'off') assert.ok((await doc('k1', 'k1p1')).offSince) assert.equal((await doc('k1', 'k1p2')).state, 'active', 'Fehler bei einem Dokument: Abgleich holt nach') assert.deepEqual(r.ended, [a.$id]) assert.equal(removalTopics('KP1').length, 0) assert.equal(rebuilds.length, 0) assert.equal(mails.sent.length, 0) }) test('Abgleich: Projekt gelöscht, Kunde gelöscht => stopSite, off, offSince, kein Ticket', async () => { aw.seed('customers', { $id: 'k2', name: 'Weg Zwei', email: 'weg2@example.invalid' }) aw.seed('websiteProjects', { $id: 'k2p', customerId: 'k2', projectName: 'KP3', subdomain: 'kp3', isPublic: true }) aw.seed('websiteProjects', { $id: 'k3p', customerId: 'kgibtsnicht', projectName: 'KP4', subdomain: 'kp4', isPublic: true }) const gone = await seedFeature('k2', 'k2p', 'active') const orphan = await seedFeature('kgibtsnicht', 'k3p', 'active') await deleteDocument(config.collections.websiteProjects, 'k2p') const now = new Date('2026-11-02T03:00:00.000Z') await an.runAnalyseReconcile({ now }) assert.ok(stops.includes(gone.siteKey) && stops.includes(orphan.siteKey)) for (const [c, p] of [['k2', 'k2p'], ['kgibtsnicht', 'k3p']]) { const d = await doc(c, p) assert.equal(d.state, 'off') assert.equal(d.offSince, now.toISOString()) } assert.equal(removalTopics('KP3').length + removalTopics('KP4').length, 0) assert.equal(mails.sent.length, 0) }) test('Abgleich: geänderte liveDomain => putSite mit neuer Domainliste; off-Dokument => stopSite; K18-Wiederholung genau einmal', async () => { aw.seed('websiteProjects', { $id: 'k4p', customerId: 'c1', projectName: 'KP5', subdomain: 'kp5', liveDomain: 'neu-domain.de', isPublic: true }) const live = await seedFeature('c1', 'k4p', 'active', { activatedMailAt: '' }) aw.seed('websiteProjects', { $id: 'k5p', customerId: 'c1', projectName: 'KP6', subdomain: 'kp6', isPublic: true }) const off = await seedFeature('c1', 'k5p', 'off', { offSince: '2026-10-03T09:00:00.000Z', removalTicketId: 'vorhanden' }) await an.runAnalyseReconcile({ now: NOW }) assert.deepEqual(puts.filter((p) => p.siteKey === live.siteKey), [{ siteKey: live.siteKey, projectId: 'k4p', domains: ['kp5.project.webklar.com', 'neu-domain.de', 'www.neu-domain.de'], active: true }]) assert.ok(stops.includes(off.siteKey)) assert.equal(mails.sent.length, 1) assert.ok((await doc('c1', 'k4p')).activatedMailAt) await an.runAnalyseReconcile({ now: NOW }) assert.equal(mails.sent.length, 1, 'Marker gesetzt: keine zweite K18') }) test('Domain/Öffentlichkeit: analyseOnProjectChanged beendet bei nicht öffentlich (Ticket), putSite bei neuer Subdomain', async () => { aw.seed('websiteProjects', { $id: 'k6p', customerId: 'c1', projectName: 'KP7', subdomain: 'kp7', isPublic: true, repoFullName: 'WEBklar/kp7' }) const d = await seedFeature('c1', 'k6p', 'active') await updateDocument(config.collections.websiteProjects, 'k6p', { subdomain: 'kp7neu' }) await an.analyseOnProjectChanged('k6p') assert.deepEqual(puts, [{ siteKey: d.siteKey, projectId: 'k6p', domains: ['kp7neu.project.webklar.com'], active: true }]) assert.equal(stops.length, 0) puts.length = 0 await updateDocument(config.collections.websiteProjects, 'k6p', { isPublic: false }) await an.analyseOnProjectChanged('k6p') assert.deepEqual(stops, [d.siteKey]) const after = await doc('c1', 'k6p') assert.equal(after.state, 'off') assert.ok(after.offSince) assert.ok(after.removalTicketId) assert.equal(removalTopics('KP7').length, 1) assert.deepEqual(rebuilds, ['k6p']) await an.analyseOnProjectChanged('k6p') // kein aktives Dokument mehr assert.equal(removalTopics('KP7').length, 1) await an.analyseOnProjectChanged('gibts-nicht') }) test('Löschfrist/Sperre: siteFeatures.disable beendet Sammeldienst, baut neu und legt Entfernen-Ticket genau einmal an', async () => { aw.seed('websiteProjects', { $id: 'k7p', customerId: 'c1', projectName: 'KP8', subdomain: 'kp8', isPublic: true, repoFullName: 'WEBklar/kp8' }) const d = await seedFeature('c1', 'k7p', 'active') const r = await sf.disable(d.$id, { adminNote: 'Abgeschaltet: Aufbewahrungsfrist abgelaufen' }) assert.equal(r.state, 'disabled') assert.deepEqual(stops, [d.siteKey]) assert.equal(puts.length, 0) const after = await doc('c1', 'k7p') assert.ok(after.offSince) assert.ok(after.removalTicketId) assert.deepEqual(rebuilds, ['k7p']) assert.equal(removalTopics('KP8').length, 1) await an.runAnalyseReconcile({ now: NOW }) assert.equal(removalTopics('KP8').length, 1) assert.equal((await doc('c1', 'k7p')).state, 'disabled') }) test('disable() idempotent (A11): die Analyse-Nacharbeit läuft bei jedem Aufruf, das Dokument bleibt beim zweiten unverändert', async () => { aw.seed('websiteProjects', { $id: 'a11p', customerId: 'c1', projectName: 'A11P', subdomain: 'a11p', isPublic: true, repoFullName: 'WEBklar/a11p' }) const d = await seedFeature('c1', 'a11p', 'active') const erst = await sf.disable(d.$id, { adminNote: 'eins' }) assert.equal(erst.state, 'disabled') assert.equal(erst.disabledReason, 'team') assert.deepEqual(stops, [d.siteKey]) const davor = await doc('c1', 'a11p') assert.ok(davor.removalTicketId) const zweit = await sf.disable(d.$id, { adminNote: 'zwei', reason: 'abo_ende' }) assert.deepEqual(stops, [d.siteKey, d.siteKey], 'stopSite läuft auch beim zweiten Aufruf, wie vorher') assert.equal(zweit.state, 'disabled') assert.deepEqual(await doc('c1', 'a11p'), davor, 'Frist, Grund, Notiz und Marker bleiben') assert.equal(davor.disabledReason, 'team') assert.equal(davor.adminNote, 'eins') assert.equal(removalTopics('A11P').length, 1, 'das Entfernen-Ticket gibt es genau einmal') }) test('Löschfrist ohne Projekt: stopSite, kein Ticket, kein Neubau, kein Fehler', async () => { aw.seed('websiteProjects', { $id: 'k8p', customerId: 'c1', projectName: 'KP9', subdomain: 'kp9', isPublic: true }) const d = await seedFeature('c1', 'k8p', 'active') await deleteDocument(config.collections.websiteProjects, 'k8p') await sf.disable(d.$id, { adminNote: 'x' }) assert.deepEqual(stops, [d.siteKey]) assert.equal(removalTopics('KP9').length, 0) assert.equal(rebuilds.length, 0) }) test('Abgleich holt fehlendes Entfernen-Ticket nach (off/disabled), idempotent; Altfälle vor der Fassung nicht', async () => { aw.seed('websiteProjects', { $id: 'k9p', customerId: 'c1', projectName: 'KP10', subdomain: 'kp10', isPublic: true }) aw.seed('websiteProjects', { $id: 'k10p', customerId: 'c1', projectName: 'KP11', subdomain: 'kp11', isPublic: true }) aw.seed('websiteProjects', { $id: 'k11p', customerId: 'c1', projectName: 'KP12', subdomain: 'kp12', isPublic: true }) await seedFeature('c1', 'k9p', 'off', { offSince: '2026-10-04T09:00:00.000Z' }) await seedFeature('c1', 'k10p', 'disabled', { offSince: '', disabledAt: '2026-10-04T09:00:00.000Z' }) await seedFeature('c1', 'k11p', 'off', { offSince: '2026-09-20T09:00:00.000Z' }) await an.runAnalyseReconcile({ now: NOW }) await an.runAnalyseReconcile({ now: NOW }) assert.equal(removalTopics('KP10').length, 1) assert.equal(removalTopics('KP11').length, 1) assert.equal(removalTopics('KP12').length, 0, 'vor Livegang beendet') assert.ok((await doc('c1', 'k9p')).removalTicketId) }) test('Ticket angelegt, Speichern der ID scheiterte: Abgleich übernimmt das vorhandene Ticket (kein zweites)', async () => { aw.seed('websiteProjects', { $id: 'k12p', customerId: 'c1', projectName: 'KP13', subdomain: 'kp13', isPublic: true }) const off = '2026-10-04T09:00:00.000Z' await seedFeature('c1', 'k12p', 'off', { offSince: off }) // Zustand nach einem Fehler beim Speichern: Ticket fuer genau diese Abschaltung existiert, removalTicketId fehlt const orig = an.analyseDeps.ticket let created = null an.analyseDeps.ticket = async (a) => (created = await orig(a)) const realUpdate = an.analyseDeps.saveRemovalTicketId an.analyseDeps.saveRemovalTicketId = async () => { throw new Error('Appwrite weg') } await an.runAnalyseReconcile({ now: NOW }) an.analyseDeps.saveRemovalTicketId = realUpdate an.analyseDeps.ticket = orig assert.ok(created, 'Ticket entstand') assert.ok(!(await doc('c1', 'k12p')).removalTicketId, 'ID nicht gespeichert') await an.runAnalyseReconcile({ now: NOW }) assert.equal(removalTopics('KP13').length, 1) assert.equal((await doc('c1', 'k12p')).removalTicketId, created.ticketId) }) test('Aus/An/Aus: zweite Abschaltung bekommt trotz offenem ersten Ticket ein eigenes (Marker je Abschaltung)', async () => { const tickets = [...aw.docs('workorders').values()].filter((t) => t.topic === 'Website-Analyse von Bäckerei Aus entfernen') assert.equal(tickets.length, 2) assert.notEqual(tickets[0].details, tickets[1].details) }) test('deleteCustomerWithPortalAccess: Analyse aus (kein Ticket, keine Mail), Löschen gelingt auch wenn stopSite wirft', async () => { const { deleteCustomerWithPortalAccess } = await import('../server/services/customerAdmin.js') aw.seed('customers', { $id: 'kd1', name: 'Löschen Eins', email: 'loe1@example.invalid' }) aw.seed('customers', { $id: 'kd2', name: 'Löschen Zwei', email: 'loe2@example.invalid' }) aw.seed('websiteProjects', { $id: 'kd1p', customerId: 'kd1', projectName: 'KD1', subdomain: 'kd1', isPublic: true, repoFullName: 'WEBklar/kd1' }) aw.seed('websiteProjects', { $id: 'kd2p', customerId: 'kd2', projectName: 'KD2', subdomain: 'kd2', isPublic: true }) const a = await seedFeature('kd1', 'kd1p', 'active') const b = await seedFeature('kd2', 'kd2p', 'active') await deleteCustomerWithPortalAccess('kd1') assert.deepEqual(stops, [a.siteKey]) assert.equal((await doc('kd1', 'kd1p')).state, 'off') assert.equal(removalTopics('KD1').length, 0) assert.equal(mails.sent.length, 0) assert.equal(rebuilds.length, 0) assert.equal(aw.docs('customers').has('kd1'), false) const real = an.analyseDeps.client.stopSite an.analyseDeps.client.stopSite = async () => { throw new Error('weg') } await deleteCustomerWithPortalAccess('kd2') an.analyseDeps.client.stopSite = real assert.equal(aw.docs('customers').has('kd2'), false) assert.equal((await doc('kd2', 'kd2p')).state, 'active', 'bleibt für den Abgleich (Kunde fehlt)') await an.runAnalyseReconcile({ now: NOW }) assert.equal((await doc('kd2', 'kd2p')).state, 'off') assert.ok(stops.includes(b.siteKey)) }) // ---- Fix round 1 ---- test('Abgleich off-Zweig: wird das Dokument nach dem Lesen wieder aktiv, bleibt der Messdienst an (Sperre + Nachlesen)', async () => { const { withLocks } = await import('../server/services/keyedLock.js') aw.seed('websiteProjects', { $id: 'f1p', customerId: 'c1', projectName: 'F1', subdomain: 'f1', isPublic: true }) const d = await seedFeature('c1', 'f1p', 'off', { offSince: '2026-10-04T09:00:00.000Z', removalTicketId: 'x' }) let run await withLocks(['analyse:f1p'], async () => { run = an.runAnalyseReconcile({ now: NOW }) await new Promise((r) => setTimeout(r, 150)) // Abgleich hat gelesen und wartet auf die Sperre await updateDocument(config.collections.portalFeatures, d.$id, { ...sf.stateFields('active') }) }) await run assert.ok(!stops.includes(d.siteKey), 'frisch aktivierte Analyse nicht abgeschaltet') assert.equal((await doc('c1', 'f1p')).state, 'active') }) test('Abgleich: Appwrite-Lesefehler (nicht 404) beendet nichts', async () => { aw.seed('websiteProjects', { $id: 'f2p', customerId: 'c1', projectName: 'F2', subdomain: 'f2', isPublic: true }) const d = await seedFeature('c1', 'f2p', 'active') const real = globalThis.fetch globalThis.fetch = async (url, opts) => { if (String(url).includes('/collections/websiteProjects/documents/f2p')) return new Response(JSON.stringify({ message: 'kaputt' }), { status: 500, headers: { 'content-type': 'application/json' } }) return real(url, opts) } try { await an.runAnalyseReconcile({ now: NOW }) } finally { globalThis.fetch = real } assert.ok(!stops.includes(d.siteKey)) assert.equal((await doc('c1', 'f2p')).state, 'active') }) test('Abgleich: nicht öffentliches Projekt / ohne Subdomain => beendet mit Entfernen-Ticket, ohne K19', async () => { aw.seed('websiteProjects', { $id: 'f3p', customerId: 'c1', projectName: 'F3', subdomain: 'f3', isPublic: false, repoFullName: 'WEBklar/f3' }) aw.seed('websiteProjects', { $id: 'f4p', customerId: 'c1', projectName: 'F4', isPublic: true }) const a = await seedFeature('c1', 'f3p', 'active') const b = await seedFeature('c1', 'f4p', 'active') await an.runAnalyseReconcile({ now: NOW }) for (const [p, name, key] of [['f3p', 'F3', a.siteKey], ['f4p', 'F4', b.siteKey]]) { const d = await doc('c1', p) assert.equal(d.state, 'off') assert.ok(stops.includes(key)) assert.equal(removalTopics(name).length, 1) assert.ok(d.removalTicketId) } assert.equal(mails.sent.filter((m) => /beendet/.test(m.subject)).length, 0) }) test('Abgleich: stopSite wirft => Dokument bleibt unverändert aktiv', async () => { aw.seed('websiteProjects', { $id: 'f5p', customerId: 'c1', projectName: 'F5', subdomain: 'f5', isPublic: false }) const d = await seedFeature('c1', 'f5p', 'active') const real = an.analyseDeps.client.stopSite an.analyseDeps.client.stopSite = async () => { throw new Error('Messdienst weg') } await an.runAnalyseReconcile({ now: NOW }) an.analyseDeps.client.stopSite = real const after = await doc('c1', 'f5p') assert.equal(after.state, 'active') assert.ok(!after.offSince) assert.equal(removalTopics('F5').length, 0) }) test('Altbestand ohne productKey: Analyse bleibt streng (kein_abo), aboStatus.laeuft grosszuegig', async () => { const { laeuft } = await import('../server/services/aboStatus.js') const { productKey: _entfernt, ...ohneSchluessel } = wartungDoc aw.seed('subscriptions', { ...ohneSchluessel, $id: 'sub_w_alt', stripeSubscriptionId: 'sub_w_alt', customerId: 'c9' }) aw.seed('customers', { $id: 'c9', name: 'Kunde Neun', email: 'neun@example.invalid' }) aw.seed('websiteProjects', { $id: 'p9', customerId: 'c9', projectName: 'Altbestand', subdomain: 'altbestand', isPublic: true }) assert.equal(await laeuft('c9'), true, 'grosszuegige Regel: ohne productKey gilt als Wartung') assert.equal(await laeuft('c9', { altbestand: false }), false, 'strenge Regel der Analyse') assert.deepEqual(await an.analyseEligibility('c9', 'p9'), { ok: false, reason: 'kein_abo' }) await assert.rejects(an.activateAnalyse({ customerId: 'c9', projectId: 'p9', ...cust() }), { status: 403 }) assert.equal(puts.length, 0) })