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>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-01 18:50:36 +00:00
1 parent fbc40252a3
commit 78170567b4
17 files changed
+616 -70

No files matched your search

+184 -5
View File
@@ -24,7 +24,7 @@ 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 { updateDocument, deleteDocument } = await import('../server/services/appwriteAdmin.js')
const { setMailTransportForTests } = await import('../server/services/mailer.js')
const mails = mailSink()
@@ -35,8 +35,9 @@ after(() => {
})
const puts = []
const stops = []
const rebuilds = []
an.analyseDeps.client = { putSite: async (siteKey, body) => puts.push({ siteKey, ...body }) }
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 })
@@ -67,6 +68,7 @@ aw.seed('websiteProjects', { $id: 'p5', customerId: 'c3', projectName: 'Ohne Abo
beforeEach(() => {
puts.length = 0
stops.length = 0
rebuilds.length = 0
mails.sent.length = 0
})
@@ -156,8 +158,8 @@ test('Ausschalten: off, offSince, Sammeldienst inaktiv, Neubau; erneut = nichts'
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.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 })
@@ -466,7 +468,7 @@ test('Entfernen-Ticket: Kunde, Mitarbeiter, zweimal = ein Ticket, Aus/An/Aus = z
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' })
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)
})
@@ -524,3 +526,180 @@ test('rebuildSafe-Ergebnis: kein_repo, preview_aus, fehler => Veröffentlichen-T
}
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))
})
+11 -7
View File
@@ -33,11 +33,13 @@ after(() => {
})
const puts = []
const stops = []
const statsCalls = []
let statsFail = false
const realClient = createAnalyticsClient({ heatSecret: 'h'.repeat(32), adminToken: 't' })
an.analyseDeps.client = {
putSite: async (siteKey, body) => puts.push({ siteKey, ...body }),
stopSite: async (siteKey) => stops.push(siteKey),
getStats: async (siteKey, range) => {
statsCalls.push({ siteKey, range })
if (statsFail) throw Object.assign(new Error('x'), { status: 502 })
@@ -71,6 +73,7 @@ const featureDoc = (c, p) => sf.getFeatureDoc(c, p, 'analyse')
beforeEach(() => {
puts.length = 0
stops.length = 0
statsCalls.length = 0
statsFail = false
config.analyse.enabled = true
@@ -132,7 +135,7 @@ test('Aktivieren und Deaktivieren (Kunde)', async () => {
assert.equal(view.accentColor, '#112233')
const off = await post(`${A}/deactivate`, { projectId: 'p1' }, { cookie })
assert.deepEqual(off.body, { ok: true, state: 'off' })
assert.equal(puts[1].active, false)
assert.equal(stops.length, 1)
const notElig = await post(`${A}/activate`, { projectId: 'p2', consent: consent() }, { cookie })
assert.equal(notElig.status, 409)
})
@@ -270,18 +273,19 @@ test('Fix a: Kunde ändert gesperrtes Dokument nicht, Mitarbeiter schon', async
assert.equal(staff.doc.state, 'off')
})
test('Fix b: Abschalten durch Mitarbeiter meldet Sammeldienst active:false', async () => {
test('Fix b: Abschalten durch Mitarbeiter meldet Sammeldienst per stopSite', async () => {
await adminPost('p6', { on: true, ...tf() })
const cur = await featureDoc('c1', 'p6')
puts.length = 0
stops.length = 0
await sf.disable(cur.$id, { by: 'Test' })
assert.equal(puts.length, 1)
assert.deepEqual(puts[0], { siteKey: cur.siteKey, projectId: 'p6', domains: ['neu.project.webklar.com'], active: false })
assert.deepEqual(stops, [cur.siteKey])
assert.equal(puts.length, 0)
// Ausfall des Sammeldienstes verhindert das Abschalten nicht
const real = an.analyseDeps.client.putSite
an.analyseDeps.client.putSite = async () => { throw new Error('weg') }
const real = an.analyseDeps.client.stopSite
an.analyseDeps.client.stopSite = async () => { throw new Error('weg') }
const again = await sf.disable(cur.$id, { by: 'Test' })
an.analyseDeps.client.putSite = real
an.analyseDeps.client.stopSite = real
assert.equal(again.state, 'disabled')
})
+12
View File
@@ -92,3 +92,15 @@ test('heatToken: von der Analytics-Verifikation akzeptiert, 30 Min gueltig', ()
test('heatToken ohne heatSecret => 503', () => {
assert.throws(() => mk({ heatSecret: '' }).heatToken({ siteKey: 'abcdefghij123456', path: '/', vp: 'd' }), (e) => e.status === 503)
})
test('stopSite: POST /off mit Bearer, 404 zaehlt als erledigt, andere Fehler werfen', async () => {
reset()
await mk().stopSite('abcdefghij123456')
assert.equal(calls[0].method, 'POST')
assert.equal(calls[0].url, '/a/admin/sites/abcdefghij123456/off')
assert.equal(calls[0].auth, 'Bearer tok-secret')
reset(() => ({ status: 404, body: { error: 'x' } }))
assert.deepEqual(await mk().stopSite('abcdefghij123456'), { ok: true, missing: true })
reset(() => ({ status: 500, body: {} }))
await assert.rejects(mk().stopSite('abcdefghij123456'), (e) => e.status === 500)
})
+12
View File
@@ -60,3 +60,15 @@ test('Vertrag: putSite -> Ereignisse -> getStats -> heatToken -> deleteSite', as
await assert.rejects(client.getStats(SITE, '7'), (e) => e.status === 404)
assert.equal(db.prepare('SELECT COUNT(*) AS n FROM events_raw').get().n, 0)
})
test('Vertrag: stopSite setzt active=0 und offSince, unbekannt zaehlt als erledigt', async () => {
const KEY2 = 'fedcba9876543210fedc'
await client.putSite(KEY2, { projectId: 'p2', domains: [DOMAIN], active: true })
await client.stopSite(KEY2)
const row = db.prepare('SELECT active, offSince FROM sites WHERE siteKey = ?').get(KEY2)
assert.equal(row.active, 0)
assert.ok(row.offSince)
await client.stopSite(KEY2) // zweiter Aufruf aendert nichts
assert.equal(db.prepare('SELECT offSince FROM sites WHERE siteKey = ?').get(KEY2).offSince, row.offSince)
assert.deepEqual(await client.stopSite('00000000000000000000'), { ok: true, missing: true })
})
+31
View File
@@ -30,6 +30,10 @@ before(async () => {
{ repoFullName: 'knso/alt', projectName: 'alt', subdomain: 'alt' },
{ repoFullName: 'knso/synced-sub', projectName: 'synced-sub', status: 'synced', subdomain: 'synced-sub' },
{ repoFullName: 'knso/lead', projectName: 'lead', status: 'deployed', subdomain: 'lead' },
{ $id: 'wp_an', repoFullName: 'knso/alt', projectName: 'Analyse-Projekt', subdomain: 'analyseprojekt', isPublic: true, projectType: 'website' },
],
portalFeatures: [
{ $id: 'wp_an_feat', customerId: 'cx', projectId: 'wp_an', featureKey: 'analyse', state: 'active', enabled: true, siteKey: 'wpansitekey01234567' },
],
},
})
@@ -111,3 +115,30 @@ test('Repo-Liste hat eine Obergrenze', async () => {
assert.equal(repos.length, 2000)
assert.equal(capGitea.calls.filter((c) => c.path === '/api/v1/repos/search').length, 40)
})
test('updateWebsiteProject: neue Subdomain => putSite mit neuer Domain; nicht öffentlich => stopSite; Hook-Fehler ändert das Ergebnis nicht', async () => {
const { updateWebsiteProject } = await import('../server/services/giteaAdmin.js')
const { analyseDeps } = await import('../server/services/analyse.js')
const calls = []
const real = analyseDeps.client
analyseDeps.client = {
putSite: async (k, b) => calls.push(['put', k, b]),
stopSite: async (k) => calls.push(['stop', k]),
}
try {
const r1 = await updateWebsiteProject('wp_an', { subdomain: 'analyseneu', projectType: 'website' })
assert.equal(r1.subdomain, 'analyseneu')
assert.deepEqual(calls, [['put', 'wpansitekey01234567', { projectId: 'wp_an', domains: ['analyseneu.project.webklar.com'], active: true }]])
calls.length = 0
const r2 = await updateWebsiteProject('wp_an', { projectType: 'preview' })
assert.equal(r2.isPublic, false)
assert.deepEqual(calls, [['stop', 'wpansitekey01234567']])
const { updateDocument } = await import('../server/services/appwriteAdmin.js')
await updateDocument(config.collections.portalFeatures, 'wp_an_feat', { state: 'active', enabled: true })
analyseDeps.client = { putSite: async () => { throw new Error('weg') }, stopSite: async () => { throw new Error('weg') } }
const r3 = await updateWebsiteProject('wp_an', { subdomain: 'nochneu', projectType: 'website' })
assert.equal(r3.subdomain, 'nochneu')
} finally {
analyseDeps.client = real
}
})
+57
View File
@@ -15,6 +15,7 @@ const aw = await startAppwriteMock({
subscriptions: schemaKeys('subscriptions'),
contractEvents: schemaKeys('contractEvents'),
hourCredits: schemaKeys('hourCredits'),
portalFeatures: [...schemaKeys('portalFeatures'), 'siteKey', 'offSince', 'activatedAt', 'accentColor', 'removalTicketId'],
},
})
const st = await startFakeStripe()
@@ -28,6 +29,7 @@ const { withdrawalDeadline } = await import('../server/services/deadlines.js')
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
const { setMailTransportForTests } = await import('../server/services/mailer.js')
const { analyseDeps } = await import('../server/services/analyse.js')
const app = await startTestApp({ '/api/abo': aboRoutes })
// Mails landen in der Senke statt bei SMTP (F25): K03/K04 an den Kunden, T08/T09 ans Team sind pruefbar
const mails = mailSink()
@@ -396,6 +398,61 @@ test('Widerruf Wechsel in der Frist: Ereignis widerrufen, kein automatischer Pre
assert.doesNotMatch(t09().text, /Abo in Stripe sofort beenden/)
})
// Analyse hat nur mit Wartungs-Abo Bestand: der Widerruf des Wechsels beendet sie (Messdienst aus, K19)
async function withActiveAnalyse(fn) {
const stops = []
const real = analyseDeps.client
analyseDeps.client = { stopSite: async (k) => { stops.push(k) } }
aw.seed('portalFeatures', { $id: 'wf_feat', customerId: 'c1', projectId: 'wf_p', featureKey: 'analyse', state: 'active', enabled: true, siteKey: 'wfsitekey0123456789' })
try {
return await fn(stops)
} finally {
analyseDeps.client = real
}
}
const wechselIn = async (days) => {
const consentAt = daysAgo(days)
await recordContractEvent({
customerId: 'c1', subscriptionId: 'sub_h', type: 'wechsel', productKey: 'wartung',
details: { from: 'hosting', switchedAt: consentAt, effectiveAt: consentAt, paidAt: consentAt },
consent: { ...consent, consentAt }, status: 'erledigt',
})
}
test('Widerruf Wechsel in der Frist beendet die Analyse des Kunden (stopSite, off, K19); Fehler lässt den Widerruf auf 200', async () => {
await resetSubH({ units: 0 })
await wechselIn(3)
await withActiveAnalyse(async (stops) => {
const res = await withdraw({ order: 'wechsel' })
assert.equal(res.status, 200)
assert.deepEqual(stops, ['wfsitekey0123456789'])
const d = aw.docs('portalFeatures').get('wf_feat')
assert.equal(d.state, 'off')
assert.ok(d.offSince)
assert.ok(bySubject('Deine Website-Analyse wurde beendet'))
})
// wirft der Messdienst, bleibt der Widerruf 200
await resetSubH({ units: 0 })
await wechselIn(3)
await withActiveAnalyse(async () => {
analyseDeps.client = { stopSite: async () => { throw new Error('Messdienst weg') } }
const res = await withdraw({ order: 'wechsel' })
assert.equal(res.status, 200)
assert.equal((await events('wechsel'))[0].status, 'widerrufen')
})
})
test('Widerruf Wechsel nach Fristende oder Zusatzspeicher ändert die Analyse nicht', async () => {
await resetSubH({ units: 0 })
await wechselIn(30)
await withActiveAnalyse(async (stops) => {
assert.equal((await withdraw({ order: 'wechsel' })).status, 200)
assert.equal((await withdraw({ order: 'zusatzspeicher' })).status, 200)
assert.deepEqual(stops, [])
assert.equal(aw.docs('portalFeatures').get('wf_feat').state, 'active')
})
})
test('Widerruf nach Fristende: 200, nichts geändert', async () => {
await resetSubH({ units: 0 })
const consentAt = daysAgo(30)
+2 -2
View File
@@ -315,13 +315,13 @@ test('retention: Analyse-Dokument schaltet auch den Sammeldienst ab', async () =
aw.seed('portalFeatures', { $id: 'ra_feat', customerId: 'ra', projectId: 'rap', featureKey: 'analyse', state: 'active', enabled: true, siteKey: 'abcdefghij234567' })
const puts = []
const ok = analyseDeps.client
analyseDeps.client = { putSite: async (k, b) => { puts.push([k, b]) } }
analyseDeps.client = { putSite: async (k, b) => { puts.push(['put', k, b]) }, stopSite: async (k) => { puts.push(['stop', k]) } }
try {
const plan = await findDueDeletions(JAN_2026)
assert.ok(plan.news.find((n) => n.customerId === 'ra').featureIds.includes('ra_feat'))
const result = await applyDeletions(plan)
assert.deepEqual(result.failed, [])
assert.deepEqual(puts, [['abcdefghij234567', { projectId: 'rap', domains: ['altprojekt.project.webklar.com'], active: false }]])
assert.deepEqual(puts, [['stop', 'abcdefghij234567']])
assert.equal([...aw.docs('portalFeatures').values()].find((d) => d.$id === 'ra_feat').state, 'disabled')
} finally {
analyseDeps.client = ok