Files
Webklar-Kundenbereich/test/analyse.test.js
T

527 lines
32 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 } = 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 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
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, 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, 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: NOW, 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
})