diff --git a/server/services/analyse.js b/server/services/analyse.js index 915d141..75fa5d6 100644 --- a/server/services/analyse.js +++ b/server/services/analyse.js @@ -566,16 +566,21 @@ export async function runAnalyseReconcile({ now = new Date() } = {}) { if (!doc.activatedMailAt) await sendK18(doc, project) }) } else if ((doc.state === 'off' || doc.state === 'disabled') && SITE_KEY_RE.test(doc.siteKey || '')) { - await analyseDeps.client.stopSite(doc.siteKey) - stats.stopped.push(doc.$id) - const marker = offMarker(doc) - if (!doc.removalTicketId && marker >= config.analyse.avvVersion) { - const project = await projectOf(doc.projectId) - if (project && (await customerOf(doc.customerId))) { - await ensureRemovalTicket(doc, project) - stats.tickets.push(doc.$id) + // Gleiche Sperre wie Ein-/Ausschalten, Dokument nachlesen: eine inzwischen neu aktivierte Analyse bleibt an + await withLocks([`analyse:${doc.projectId}`], async () => { + const fresh = await listDocuments(config.collections.portalFeatures, [Query.equal('featureKey', FEATURE_KEY), Query.equal('projectId', doc.projectId), Query.limit(1)]) + const cur = fresh[0] + if (!cur || cur.state === 'active' || !SITE_KEY_RE.test(cur.siteKey || '')) return + await analyseDeps.client.stopSite(cur.siteKey) + stats.stopped.push(cur.$id) + if (!cur.removalTicketId && offMarker(cur) >= config.analyse.avvVersion) { + const project = await projectOf(cur.projectId) + if (project && (await customerOf(cur.customerId))) { + await ensureRemovalTicket(cur, project) + stats.tickets.push(cur.$id) + } } - } + }) } } catch (err) { console.error(`[analyse] Abgleich für Projekt ${doc.projectId} fehlgeschlagen:`, err.message) diff --git a/test/analyse.test.js b/test/analyse.test.js index e8ca922..d0136d3 100644 --- a/test/analyse.test.js +++ b/test/analyse.test.js @@ -703,3 +703,65 @@ test('deleteCustomerWithPortalAccess: Analyse aus (kein Ticket, keine Mail), Lö assert.equal((await doc('kd2', 'kd2p')).state, 'off') assert.ok(stops.includes(b.siteKey)) }) + +// ---- Fix round 1 ---- +test('Abgleich off-Zweig: wird das Dokument nach dem Lesen wieder aktiv, bleibt der Messdienst an (Sperre + Nachlesen)', async () => { + const { withLocks } = await import('../server/services/keyedLock.js') + aw.seed('websiteProjects', { $id: 'f1p', customerId: 'c1', projectName: 'F1', subdomain: 'f1', isPublic: true }) + const d = await seedFeature('c1', 'f1p', 'off', { offSince: '2026-10-04T09:00:00.000Z', removalTicketId: 'x' }) + let run + await withLocks(['analyse:f1p'], async () => { + run = an.runAnalyseReconcile({ now: NOW }) + await new Promise((r) => setTimeout(r, 150)) // Abgleich hat gelesen und wartet auf die Sperre + await updateDocument(config.collections.portalFeatures, d.$id, { ...sf.stateFields('active') }) + }) + await run + assert.ok(!stops.includes(d.siteKey), 'frisch aktivierte Analyse nicht abgeschaltet') + assert.equal((await doc('c1', 'f1p')).state, 'active') +}) + +test('Abgleich: Appwrite-Lesefehler (nicht 404) beendet nichts', async () => { + aw.seed('websiteProjects', { $id: 'f2p', customerId: 'c1', projectName: 'F2', subdomain: 'f2', isPublic: true }) + const d = await seedFeature('c1', 'f2p', 'active') + const real = globalThis.fetch + globalThis.fetch = async (url, opts) => { + if (String(url).includes('/collections/websiteProjects/documents/f2p')) return new Response(JSON.stringify({ message: 'kaputt' }), { status: 500, headers: { 'content-type': 'application/json' } }) + return real(url, opts) + } + try { + await an.runAnalyseReconcile({ now: NOW }) + } finally { + globalThis.fetch = real + } + assert.ok(!stops.includes(d.siteKey)) + assert.equal((await doc('c1', 'f2p')).state, 'active') +}) + +test('Abgleich: nicht öffentliches Projekt / ohne Subdomain => beendet mit Entfernen-Ticket, ohne K19', async () => { + aw.seed('websiteProjects', { $id: 'f3p', customerId: 'c1', projectName: 'F3', subdomain: 'f3', isPublic: false, repoFullName: 'WEBklar/f3' }) + aw.seed('websiteProjects', { $id: 'f4p', customerId: 'c1', projectName: 'F4', isPublic: true }) + const a = await seedFeature('c1', 'f3p', 'active') + const b = await seedFeature('c1', 'f4p', 'active') + await an.runAnalyseReconcile({ now: NOW }) + for (const [p, name, key] of [['f3p', 'F3', a.siteKey], ['f4p', 'F4', b.siteKey]]) { + const d = await doc('c1', p) + assert.equal(d.state, 'off') + assert.ok(stops.includes(key)) + assert.equal(removalTopics(name).length, 1) + assert.ok(d.removalTicketId) + } + assert.equal(mails.sent.filter((m) => /beendet/.test(m.subject)).length, 0) +}) + +test('Abgleich: stopSite wirft => Dokument bleibt unverändert aktiv', async () => { + aw.seed('websiteProjects', { $id: 'f5p', customerId: 'c1', projectName: 'F5', subdomain: 'f5', isPublic: false }) + const d = await seedFeature('c1', 'f5p', 'active') + const real = an.analyseDeps.client.stopSite + an.analyseDeps.client.stopSite = async () => { throw new Error('Messdienst weg') } + await an.runAnalyseReconcile({ now: NOW }) + an.analyseDeps.client.stopSite = real + const after = await doc('c1', 'f5p') + assert.equal(after.state, 'active') + assert.ok(!after.offSince) + assert.equal(removalTopics('F5').length, 0) +})