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:
1 parent
fbc40252a3
commit
78170567b4
17 files changed
+616
-70
No files matched your search
+184
-5
@@ -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))
|
||||
})
|
||||
Reference in new issue
Block a user