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

+12
View File
@@ -110,6 +110,18 @@ function createHandlerServer({ config, db, role, geo, salt, now, onSitesChanged
return send(res, 200, { ok: true });
}
// Beenden ohne Domainliste: active=0 und offSince einmalig (Portal: stopSite)
if (role === 'admin' && method === 'POST' && parts[0] === 'a' && parts[1] === 'admin' && parts[2] === 'sites' && parts.length === 5 && parts[4] === 'off') {
if (!tokenOk(req.headers.authorization, config.adminToken)) return send(res, 401, { error: 'Nicht autorisiert' });
const key = parts[3];
if (!SITE_KEY.test(key)) return send(res, 400, { error: 'Ungültiger Website-Schlüssel' });
const old = db.prepare('SELECT offSince FROM sites WHERE siteKey = ?').get(key);
if (!old) return send(res, 404, { error: 'Website nicht gefunden' });
db.prepare('UPDATE sites SET active = 0, offSince = ? WHERE siteKey = ?').run(old.offSince || new Date().toISOString(), key);
onSitesChanged?.();
return send(res, 200, { ok: true });
}
if (role === 'admin' && method === 'GET' && parts[0] === 'a' && parts[1] === 'admin' && parts[2] === 'stats' && parts.length === 4) {
if (!tokenOk(req.headers.authorization, config.adminToken)) return send(res, 401, { error: 'Nicht autorisiert' });
const key = parts[3];
+41
View File
@@ -166,3 +166,44 @@ test('Datei-DB: zweimal öffnen (idempotent) und WAL', () => {
assert.equal(d2.prepare('PRAGMA journal_mode').get().journal_mode, 'wal');
d2.close();
});
test('POST …/off: 401 ohne Token, 404 unbekannt, setzt active=0 und offSince einmalig', async () => {
const t = await setup();
assert.equal((await t.call('POST', `/a/admin/sites/${KEY}/off`, undefined, null)).status, 401);
assert.equal((await t.call('POST', `/a/admin/sites/${KEY}/off`, undefined, 'falsch')).status, 401);
assert.equal((await t.call('POST', `/a/admin/sites/${KEY}/off`)).status, 404);
assert.equal((await t.call('POST', '/a/admin/sites/kurz/off')).status, 400);
await t.call('PUT', `/a/admin/sites/${KEY}`, valid);
assert.equal(row(t.db).active, 1);
const r = await t.call('POST', `/a/admin/sites/${KEY}/off`);
assert.equal(r.status, 200);
const s1 = row(t.db);
assert.equal(s1.active, 0);
assert.match(s1.offSince, /^\d{4}-\d\d-\d\dT/);
assert.deepEqual(JSON.parse(s1.domains), ['abc.project.webklar.com']);
t.db.prepare('UPDATE sites SET offSince = ? WHERE siteKey = ?').run('2026-01-01T00:00:00.000Z', KEY);
assert.equal((await t.call('POST', `/a/admin/sites/${KEY}/off`)).status, 200);
assert.equal(row(t.db).offSince, '2026-01-01T00:00:00.000Z');
t.close();
});
test('POST …/off: ruft onSitesChanged, danach verwirft /a/e Ereignisse und w.js ist leer', async () => {
const db = openDb(':memory:');
const pub = createPublicServer({ config: {}, db });
const adm = createAdminServer({ config: { adminToken: 't' }, db, onSitesChanged: () => pub.invalidateSites() });
await Promise.all([pub, adm].map((x) => new Promise((r) => x.listen(0, '127.0.0.1', r))));
const pb = `http://127.0.0.1:${pub.address().port}`;
const ab = `http://127.0.0.1:${adm.address().port}`;
await fetch(`${ab}/a/admin/sites/${KEY}`, { method: 'PUT', headers: { Authorization: 'Bearer t', 'Content-Type': 'application/json' },
body: JSON.stringify({ projectId: 'p', domains: ['abc.project.webklar.com'], active: true }) });
const ev = () => fetch(`${pb}/a/e`, { method: 'POST', headers: { 'Content-Type': 'text/plain', Origin: 'https://abc.project.webklar.com', 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0) Chrome/120' },
body: JSON.stringify({ t: 'pv', site: KEY, path: '/', vp: 'd' }) });
await ev();
const before = db.prepare('SELECT COUNT(*) c FROM events_raw').get().c;
assert.equal(before, 1);
assert.equal((await fetch(`${ab}/a/admin/sites/${KEY}/off`, { method: 'POST', headers: { Authorization: 'Bearer t' } })).status, 200);
await ev();
assert.equal(db.prepare('SELECT COUNT(*) c FROM events_raw').get().c, before);
assert.equal((await (await fetch(`${pb}/a/w.js?s=${KEY}`)).text()).length < 200, true);
pub.close(); adm.close();
});
+5 -1
View File
@@ -22,7 +22,7 @@ import { purgeExpiredUploads } from './services/takeoverCredits.js'
import { purgePendingChatUploads } from './services/chatAttachments.js'
import { purgeUnclaimedNewsImages } from './services/newsImages.js'
import { runActivation } from './services/siteFeatures.js'
import { runAboCheck } from './services/analyse.js'
import { runAboCheck, runAnalyseReconcile } from './services/analyse.js'
import giteaWebhookRoutes from './routes/webhook/gitea.js'
import pushTimeRoutes from './routes/pushTime.js'
import adminGiteaRoutes from './routes/admin/gitea.js'
@@ -182,6 +182,10 @@ const server = app.listen(config.port, '0.0.0.0', () => {
runAboCheck({ now: new Date() })
.then((r) => r.deactivated.length && console.log(`[analyse] ${r.deactivated.length} Analyse(n) ohne Abo beendet`))
.catch((err) => console.warn('[analyse] Abo-Prüfung fehlgeschlagen:', err.message))
// danach der Abgleich: gelöschte Projekte/Kunden, geänderte Domains, Messdienst für beendete Analysen, fehlende Tickets
.then(() => runAnalyseReconcile({ now: new Date() }))
.then((r) => r.ended.length && console.log(`[analyse] Abgleich: ${r.ended.length} Analyse(n) beendet`))
.catch((err) => console.warn('[analyse] Abgleich fehlgeschlagen:', err.message))
setTimeout(analyseAboCheck, 2 * 60 * 1000).unref()
setInterval(analyseAboCheck, 24 * 60 * 60 * 1000).unref()
+9
View File
@@ -1048,6 +1048,15 @@ async function withdrawOrder(sub, d, receivedAt) {
}
}
}
if (inTime && type === 'wechsel') {
// Ohne Wartungs-Abo gibt es keine Website-Analyse: Messdienst abschalten (der Widerruf scheitert nie daran)
try {
const { endAnalyseForCustomer } = await import('../services/analyse.js')
await endAnalyseForCustomer(sub.customerId, { reason: 'widerruf_wechsel', mail: true, now: receivedAt })
} catch (err) {
console.error(`[abo] Analyse nach Widerruf des Wechsels (Abo ${sub.$id}) nicht beendet:`, err.message)
}
}
const event = await recordDeclarationEvent(sub, 'widerruf', receivedAt, { order: d.order, eventId: ids[0] ?? null, eventIds: ids, inTime }, 'offen')
return {
sub,
+210 -50
View File
@@ -23,7 +23,7 @@ export const MISSING_PRIVACY_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.'
/** Austauschbar fuer Tests: Sammeldienst-Client, Neubau und Ticket. */
export const analyseDeps = { client: analyticsClient, rebuild: triggerRebuild, ticket: createFeatureTicket, recordConsent, legalPdfs: legalPdfAttachments }
export const analyseDeps = { client: analyticsClient, rebuild: triggerRebuild, ticket: createFeatureTicket, recordConsent, legalPdfs: legalPdfAttachments, saveRemovalTicketId: (docId, ticketId) => updateDocument(config.collections.portalFeatures, docId, { removalTicketId: ticketId }) }
function httpError(status, message) {
const err = new Error(message)
@@ -220,18 +220,65 @@ function publishTicket(customerId, project, siteKey, reason) {
})
}
function removalTicket(customerId, project, siteKey) {
const removalTopic = (project) => `Website-Analyse von ${projectName(project)} entfernen`
/** Zeitpunkt dieser Abschaltung: kennzeichnet das Entfernen-Ticket (ein Ticket je Abschaltung). */
const offMarker = (doc) => String(doc.offSince || doc.disabledAt || '')
function removalTicket(customerId, project, siteKey, offSince) {
const name = projectName(project)
return analyseDeps.ticket({
customerId,
project,
featureKey: FEATURE_KEY,
siteKey,
topic: `Website-Analyse von ${name} entfernen`,
details: removalTicketDetails({ name, domains: analyseHosts(project) }),
topic: removalTopic(project),
details: removalTicketDetails({ name, domains: analyseHosts(project), offSince }),
})
}
/** Schon angelegtes Entfernen-Ticket dieser Abschaltung (Speichern der ID war gescheitert) oder null. Suchfehler werfen: dann lieber kein zweites Ticket. */
async function findRemovalTicket(customerId, project, marker) {
if (!marker) return null
const tickets = await listDocuments(config.collections.workorders, [Query.equal('customerId', customerId), Query.limit(1000)])
return tickets.find((t) => t.topic === removalTopic(project) && String(t.details || '').includes(`Abgeschaltet: ${marker}`)) || null
}
/**
* Entfernen-Ticket fuer die aktuelle Abschaltung sicherstellen (idempotent): vorhandene ID gilt, sonst ein bereits
* angelegtes Ticket uebernehmen, sonst neu anlegen. Gibt das (ggf. aktualisierte) Dokument zurueck. Wirft bei Fehlern.
*/
async function ensureRemovalTicket(doc, project) {
if (doc.removalTicketId) return doc
const marker = offMarker(doc)
const found = await findRemovalTicket(doc.customerId, project, marker)
const ticketId = found ? found.$id : (await removalTicket(doc.customerId, project, doc.siteKey || '', marker)).ticketId
const saved = await analyseDeps.saveRemovalTicketId(doc.$id, ticketId)
return saved || { ...doc, removalTicketId: ticketId }
}
async function projectOf(projectId) {
try {
return await getDocument(config.collections.websiteProjects, projectId)
} catch (err) {
if (err.status === 404) return null
throw err
}
}
/** K18 mit beiden PDFs; sind sie nicht lesbar, geht nichts raus und der Marker bleibt leer (spaetere Wiederholung). */
async function sendK18(doc, project) {
let attachments = null
try {
attachments = await analyseDeps.legalPdfs(doc.avvVersion || config.analyse.avvVersion)
} catch (err) {
console.error(`[analyse] K18-Anhänge für Projekt ${doc.projectId} nicht lesbar:`, err.message)
}
if (attachments && (await mailSafe(doc.customerId, 'K18', project, { ...k18Parts(doc), attachments }))) {
return updateDocument(config.collections.portalFeatures, doc.$id, { activatedMailAt: new Date().toISOString() })
}
return doc
}
export async function activateAnalyse({ customerId, projectId, accentColor, consent, by = 'customer', actor, textform, audienceConfirmed, now = new Date() }) {
const viaCustomer = by === 'customer'
let accepted
@@ -339,22 +386,55 @@ export async function activateAnalyse({ customerId, projectId, accentColor, cons
const { ticketId } = await publishTicket(customerId, project, doc.siteKey, rebuild.reason)
doc = await updateDocument(config.collections.portalFeatures, doc.$id, { publishTicketId: ticketId })
}
if (needMail) {
// K18 mit beiden PDFs; sind sie nicht lesbar, geht nichts raus und der Marker bleibt leer (spaetere Wiederholung)
let attachments = null
try {
attachments = await analyseDeps.legalPdfs(doc.avvVersion || config.analyse.avvVersion)
} catch (err) {
console.error(`[analyse] K18-Anhänge für Projekt ${projectId} nicht lesbar:`, err.message)
}
if (attachments && (await mailSafe(customerId, 'K18', project, { ...k18Parts(doc), attachments }))) {
doc = await updateDocument(config.collections.portalFeatures, doc.$id, { activatedMailAt: new Date().toISOString() })
}
}
if (needMail) doc = await sendK18(doc, project)
return { doc, changed: changed || needTicket || needMail }
})
}
/**
* Gemeinsamer Kern aller Beendigungswege: Messdienst abschalten (stopSite), Dokument auf off + offSince,
* Historie, optional Neubau und Entfernen-Ticket (nur mit Projekt und Kunde). Fehler beim Messdienst werfen
* (Dokument bleibt dann unveraendert, der Abgleich holt nach); Historie, Neubau und Ticket nie.
*/
export async function endAnalyse({ doc, project, reason, now = new Date(), by = 'system', actor, tickets = true, rebuild = true }) {
if (doc.siteKey) await analyseDeps.client.stopSite(doc.siteKey)
const updated = await updateDocument(config.collections.portalFeatures, doc.$id, {
...stateFields('off'),
offSince: now.toISOString(),
})
try {
await analyseDeps.recordConsent({
customerId: doc.customerId,
projectId: doc.projectId,
siteKey: doc.siteKey || '',
event: 'off',
avvVersion: config.analyse.avvVersion,
via: by === 'customer' || by === 'system' ? 'portal' : 'woms',
acceptedAt: now.toISOString(),
acceptedBy: (by === 'customer' && personLabel(actor)) || String(by).slice(0, 100),
textformDate: '',
textformChannel: '',
})
} catch (err) {
// Ausschalten darf nie am Nachweis scheitern
console.warn(`[analyse] Historie (aus) für Projekt ${doc.projectId} fehlgeschlagen:`, err.message)
}
console.log(`[analyse] Projekt ${doc.projectId} beendet (${reason || 'ausgeschaltet'})`)
let result = updated
if (project) {
if (rebuild) await rebuildSafe(project)
if (tickets && (await customerOf(doc.customerId).catch(() => null))) {
try {
result = await ensureRemovalTicket(updated, project)
} catch (err) {
// Ausschalten darf nie am Ticket scheitern; der taegliche Abgleich holt es nach
console.warn(`[analyse] Entfernen-Ticket für Projekt ${doc.projectId} fehlgeschlagen:`, err.message)
}
}
}
return { doc: result, changed: true }
}
export async function deactivateAnalyse({ customerId, projectId, now = new Date(), by = 'customer', actor }) {
return withLocks([`analyse:${projectId}`], async () => {
const project = await loadOwnProject(customerId, projectId)
@@ -363,43 +443,32 @@ export async function deactivateAnalyse({ customerId, projectId, now = new Date(
if (!existing || existing.state === 'off') return { doc: existing, changed: false }
// Von uns gesperrt: der Kunde darf daran nichts aendern (nur Mitarbeiter)
if (existing.state === 'disabled' && by === 'customer') return { doc: existing, changed: false }
if (existing.siteKey) {
await analyseDeps.client.putSite(existing.siteKey, { projectId, domains: analyseDomains(project), active: false })
}
const doc = await updateDocument(config.collections.portalFeatures, existing.$id, {
...stateFields('off'),
offSince: now.toISOString(),
})
try {
await analyseDeps.recordConsent({
customerId,
projectId,
siteKey: existing.siteKey || '',
event: 'off',
avvVersion: config.analyse.avvVersion,
via: by === 'customer' || by === 'system' ? 'portal' : 'woms',
acceptedAt: now.toISOString(),
acceptedBy: (by === 'customer' && personLabel(actor)) || String(by).slice(0, 100),
textformDate: '',
textformChannel: '',
})
} catch (err) {
// Ausschalten darf nie am Nachweis scheitern
console.warn(`[analyse] Historie (aus) für Projekt ${projectId} fehlgeschlagen:`, err.message)
}
await rebuildSafe(project)
let result = doc
try {
const { ticketId } = await removalTicket(customerId, project, existing.siteKey || '')
result = await updateDocument(config.collections.portalFeatures, doc.$id, { removalTicketId: ticketId })
} catch (err) {
// Ausschalten darf nie am Ticket scheitern
console.warn(`[analyse] Entfernen-Ticket für Projekt ${projectId} fehlgeschlagen:`, err.message)
}
return { doc: result, changed: true }
return endAnalyse({ doc: existing, project, reason: 'ausgeschaltet', now, by, actor })
})
}
const featureDocsOf = (extra = []) => listDocuments(config.collections.portalFeatures, [Query.equal('featureKey', FEATURE_KEY), ...extra, Query.limit(1000)])
/**
* Alle aktiven Analysen eines Kunden beenden (Kunde geloescht, Widerruf Wechsel). Ein Fehler bei einem Dokument
* stoppt die uebrigen nicht; was bleibt, beendet der taegliche Abgleich. Bei mail:true bekommt der Kunde K19.
*/
export async function endAnalyseForCustomer(customerId, { reason, mail = false, now = new Date() } = {}) {
const gone = reason === 'kunde_geloescht'
const ended = []
for (const doc of await featureDocsOf([Query.equal('customerId', customerId), Query.equal('state', 'active')])) {
try {
const project = await projectOf(doc.projectId).catch(() => null)
await withLocks([`analyse:${doc.projectId}`], () => endAnalyse({ doc, project, reason, now, by: 'system', tickets: !gone, rebuild: !gone }))
ended.push(doc.$id)
if (mail) await mailSafe(customerId, 'K19', project || {})
} catch (err) {
console.error(`[analyse] Beenden (${reason}) für Projekt ${doc.projectId} fehlgeschlagen:`, err.message)
}
}
return { ended }
}
/** Taeglich: aktive Analyse ohne laufendes Wartungs-Abo wird beendet, Kunde bekommt K19. */
export async function runAboCheck({ now = new Date() } = {}) {
const docs = await listDocuments(config.collections.portalFeatures, [
@@ -423,3 +492,94 @@ export async function runAboCheck({ now = new Date() } = {}) {
}
return { checked: docs.length, deactivated }
}
const INELIGIBLE = (project) => (project.isPublic !== true ? 'nicht_oeffentlich' : !project.subdomain ? 'keine_subdomain' : null)
/** Nach einer Aenderung am Projekt (Subdomain, Oeffentlichkeit): Berechtigung weg => beenden, sonst Domains nachziehen. */
export async function analyseOnProjectChanged(projectId) {
const project = await projectOf(projectId)
const docs = await featureDocsOf([Query.equal('projectId', projectId), Query.equal('state', 'active')])
for (const doc of docs) {
await withLocks([`analyse:${projectId}`], async () => {
if (!project) return endAnalyse({ doc, project: null, reason: 'projekt_geloescht', tickets: false, rebuild: false })
const reason = INELIGIBLE(project)
if (reason) return endAnalyse({ doc, project, reason: 'projekt_nicht_mehr_oeffentlich' })
const domains = analyseDomains(project)
if (domains.length && doc.siteKey) await analyseDeps.client.putSite(doc.siteKey, { projectId, domains, active: true })
})
}
}
/**
* Sperre/Loeschfrist (siteFeatures.disable): Messdienst per stopSite abschalten (auch ohne Projekt), offSince setzen,
* Neubau und Entfernen-Ticket (nur mit Projekt und Kunde). Wirft nie.
*/
export async function analyseDisabled(doc) {
try {
if (doc.siteKey) await analyseDeps.client.stopSite(doc.siteKey)
} catch (err) {
console.warn(`[analyse] Sammeldienst für ${doc.projectId} nicht abgeschaltet:`, err.message)
}
try {
let current = doc
if (!doc.offSince && doc.siteKey) {
current = await updateDocument(config.collections.portalFeatures, doc.$id, { offSince: doc.disabledAt || new Date().toISOString() })
}
const project = await projectOf(doc.projectId).catch(() => null)
if (!project || !current.siteKey || !(await customerOf(doc.customerId).catch(() => null))) return
await rebuildSafe(project)
await ensureRemovalTicket(current, project)
} catch (err) {
console.warn(`[analyse] Nacharbeit nach Sperre für ${doc.projectId} fehlgeschlagen:`, err.message)
}
}
/**
* Taeglicher Abgleich (nach runAboCheck), je Analyse-Dokument unabhaengig:
* - active: Projekt oder Kunde weg => beenden (ohne Ticket/Neubau); nicht mehr berechtigt => beenden; sonst Domains
* nachziehen (putSite) und K18 wiederholen, falls sie fehlt;
* - off/disabled: Messdienst abschalten (idempotent) und fehlendes Entfernen-Ticket nachholen (nur Abschaltungen ab der
* Fassung, also nach Livegang).
*/
export async function runAnalyseReconcile({ now = new Date() } = {}) {
const docs = await featureDocsOf()
const stats = { checked: docs.length, ended: [], stopped: [], tickets: [] }
for (const doc of docs) {
try {
if (doc.state === 'active') {
await withLocks([`analyse:${doc.projectId}`], async () => {
const project = await projectOf(doc.projectId)
if (!project) {
await endAnalyse({ doc, project: null, reason: 'projekt_geloescht', now, tickets: false, rebuild: false })
return stats.ended.push(doc.$id)
}
if (!(await customerOf(doc.customerId))) {
await endAnalyse({ doc, project, reason: 'kunde_geloescht', now, tickets: false, rebuild: false })
return stats.ended.push(doc.$id)
}
if (INELIGIBLE(project)) {
await endAnalyse({ doc, project, reason: 'projekt_nicht_mehr_oeffentlich', now })
return stats.ended.push(doc.$id)
}
const domains = analyseDomains(project)
if (domains.length && doc.siteKey) await analyseDeps.client.putSite(doc.siteKey, { projectId: doc.projectId, domains, active: true })
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)
}
}
}
} catch (err) {
console.error(`[analyse] Abgleich für Projekt ${doc.projectId} fehlgeschlagen:`, err.message)
}
}
return stats
}
+2 -1
View File
@@ -39,7 +39,7 @@ export function privacyTicketDetails({ name, domains, rebuildNote = '' }) {
}
/** Ticket beim Ausschalten (auch Abo-Ende): Quelle § 6 „Beim Ausschalten“. */
export function removalTicketDetails({ name, domains }) {
export function removalTicketDetails({ name, domains, offSince = '' }) {
return [
...headLines({ name, domains }),
`Zusatzseite: https://${lastDomain(domains)}/datenschutz-analyse.html`,
@@ -47,6 +47,7 @@ export function removalTicketDetails({ name, domains }) {
'1. Prüfen, ob die Website neu veröffentlicht wurde (Skript und Zusatzseite weg). Sonst neu veröffentlichen, spätestens innerhalb von fünf Werktagen.',
'2. Hinweis auf die Reichweitenmessung aus der Datenschutzerklärung entfernen, wenn sie in unserem Repo liegt; sonst den Kunden im Chat bitten, ihn zu entfernen.',
'3. Nicht auf das Stundenkontingent anrechnen.',
...(offSince ? ['', `Abgeschaltet: ${offSince}`] : []), // Kennzeichen je Abschaltung (verhindert doppelte Tickets)
].join('\n')
}
+9
View File
@@ -47,6 +47,15 @@ export function createAnalyticsClient(opts = {}) {
putSite(siteKey, { projectId, domains, active }) {
return request('PUT', sitePath(siteKey), { projectId, domains, active })
},
/** Beenden ohne Domainliste (active=0, offSince einmalig); unbekannter Schluessel zaehlt als erledigt. */
async stopSite(siteKey) {
try {
return await request('POST', `${sitePath(siteKey)}/off`)
} catch (err) {
if (err.status === 404) return { ok: true, missing: true }
throw err
}
},
deleteSite(siteKey) {
return request('DELETE', sitePath(siteKey))
},
+8
View File
@@ -274,6 +274,14 @@ export async function deleteCustomerWithPortalAccess(customerId) {
throw error
}
// Website-Analyse zuerst abschalten (kein Ticket, keine Mail); Fehler halten das Loeschen nicht auf, der taegliche Abgleich holt nach
try {
const { endAnalyseForCustomer } = await import('./analyse.js')
await endAnalyseForCustomer(customerId, { reason: 'kunde_geloescht', mail: false })
} catch (err) {
console.error(`[customerAdmin] Analyse von Kunde ${customerId} nicht beendet:`, err.message)
}
const portalAccess = await getPortalAccessByCustomerId(customerId)
if (portalAccess) {
await deleteDocument(
+8
View File
@@ -517,6 +517,14 @@ export async function updateWebsiteProject(projectId, { projectName, subdomain,
console.error('[updateWebsiteProject] TLS-Sync fehlgeschlagen:', err.message)
}
// Website-Analyse: Berechtigung/Domains neu pruefen (dynamischer Import wegen Zyklus); Fehler aendern das Ergebnis nicht
try {
const { analyseOnProjectChanged } = await import('./analyse.js')
await analyseOnProjectChanged(projectId)
} catch (err) {
console.error('[updateWebsiteProject] Analyse-Abgleich fehlgeschlagen:', err.message)
}
return {
project: updated,
deploy: deployResult,
+3 -4
View File
@@ -302,13 +302,12 @@ export async function disable(docId, { by, adminNote } = {}) {
return doc
}
// Analyse gesperrt: Sammeldienst nimmt keine Daten mehr an. Best effort, Zustand ist schon gespeichert.
// Analyse gesperrt (auch Loeschfrist): Sammeldienst abschalten, Neubau und Entfernen-Ticket. Best effort, Zustand ist schon gespeichert.
// Dynamischer Import: analyse.js importiert dieses Modul.
async function stopAnalyseCollecting(doc) {
try {
const { analyseDeps, analyseDomains } = await import('./analyse.js')
const project = await getDocument(config.collections.websiteProjects, doc.projectId)
await analyseDeps.client.putSite(doc.siteKey, { projectId: doc.projectId, domains: analyseDomains(project), active: false })
const { analyseDisabled } = await import('./analyse.js')
await analyseDisabled(doc)
} catch (err) {
console.warn(`[siteFeatures] Sammeldienst für ${doc.projectId} nicht abgeschaltet:`, err.message)
}
+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