Files
Webklar-Kundenbereich/test/analyse.test.js
T
ClaudeandClaude Opus 5.5 78170567b4 Analyse: alle Beendigungswege schalten den Messdienst ab (Kunde/Projekt weg, Domain, Löschfrist, Widerruf Wechsel)
Messdienst: POST /a/admin/sites/:key/off setzt active=0 und offSince einmalig; Portal-Client stopSite.
analyse.js: endAnalyse als gemeinsamer Kern, endAnalyseForCustomer, analyseOnProjectChanged,
analyseDisabled (Sperre/Löschfrist) und täglicher Abgleich runAnalyseReconcile (Projekt/Kunde weg,
Domains nachziehen, K18-Wiederholung, Messdienst für off/disabled, fehlende Entfernen-Tickets nachholen).
Entfernen-Ticket je Abschaltung gekennzeichnet (kein Doppelticket, auch wenn das Speichern der ID scheiterte).
Hooks: Kunde löschen, updateWebsiteProject, withdrawOrder (Wechsel), siteFeatures.disable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 18:50:36 +00:00

706 lines
43 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 <eins@example.invalid>')
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 &lt;eins@example\.invalid&gt;/)
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 <eins@example.invalid>')
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 <eins@example.invalid>')
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 <eins@example.invalid>')
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('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))
})