From aaed273b626531ac4c994fb6afa5cc6c456b231f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 11:33:17 +0000 Subject: [PATCH] Funktionen: disable() idempotent, disabledReason, Schema-Skript feature-setup disable() laeuft unter einer Sperre je Dokument und aendert ein schon abgeschaltetes Dokument nicht mehr (disabledAt als Beginn der 30-Tage-Frist, Grund und Notiz bleiben); nur Altbestand ohne disabledAt bekommt dieses Feld. Neu: DISABLED_REASONS und der Parameter reason (Vorgabe team, unbekannt = 400), disableCustomerFeatures reicht ihn durch, die Kundenloeschung nutzt kunde_geloescht. activate() leert zusaetzlich disabledReason und aboEndeMailAt und laesst zahlungMeldung unberuehrt. scripts/feature-setup.mjs legt portalFeatures.disabledReason, aboEndeMailAt und zahlungMeldung idempotent an (--check meldet Luecken mit Exit 1); schemaKeys('portalFeatures') zieht die Schluessel mit. Co-Authored-By: Claude Sonnet 5.5 --- scripts/feature-setup.mjs | 75 +++++++++++++ server/services/customerAdmin.js | 2 +- server/services/siteFeatures.js | 47 +++++++-- test/analyse.test.js | 19 ++++ test/featureSetup.test.js | 130 +++++++++++++++++++++++ test/funktionenTermine.test.js | 20 +++- test/siteFeatures.test.js | 175 ++++++++++++++++++++++++++++++- test/support/schema.js | 7 +- test/termineJobs.test.js | 15 +++ 9 files changed, 472 insertions(+), 18 deletions(-) create mode 100644 scripts/feature-setup.mjs create mode 100644 test/featureSetup.test.js diff --git a/scripts/feature-setup.mjs b/scripts/feature-setup.mjs new file mode 100644 index 0000000..2d15f29 --- /dev/null +++ b/scripts/feature-setup.mjs @@ -0,0 +1,75 @@ +#!/usr/bin/env node +/** + * Portal-Funktionen (News, Terminplaner): legt die Attribute an der Collection portalFeatures an, die der + * Verkauf des Terminplaners braucht (Abschaltgrund, Marker der Abo-Ende-Mail, Marker der Zahlungsmeldung). + * Idempotent: Vorhandenes wird uebersprungen. Vor dem Code-Deploy ausfuehren (activate() schreibt die neuen + * Felder; ohne Schema antwortet Appwrite mit "Unknown attribute"). + * scripts/setup-abo-collections.mjs bleibt unveraendert (Top-Level-Skript ohne Pruefmodus). + * Usage: + * node scripts/feature-setup.mjs --check (nur melden, nichts anlegen; Exit-Code 1 bei Luecken) + * node scripts/feature-setup.mjs (Fehlendes anlegen) + * + * config.js und der Appwrite-Client werden erst in main() geladen: test/support/schema.js importiert die + * Attributliste, bevor die Tests ihre Umgebung (useTestEnv) setzen koennen. + */ +import { pathToFileURL } from 'node:url' + +/** + * Neue Attribute von portalFeatures (alle optional): + * - disabledReason: 'team' | 'abo_ende' | 'abo_ende_vor_einrichtung' | 'kunde_geloescht' (laengster Wert 24 Zeichen) + * - aboEndeMailAt: Zeitpunkt der versandten Mail "Terminplaner beendet" (Marker, nach dem Versand gesetzt) + * - zahlungMeldung: '' | 'bereits_aktiv' | 'abgeschaltet' | 'abo_ende' | 'gemeldet' (Marker einer Zahlung ohne Ticket) + */ +export const PORTAL_FEATURE_ATTRIBUTES = [ + { type: 'string', key: 'disabledReason', size: 32, required: false }, + { type: 'datetime', key: 'aboEndeMailAt', required: false }, + { type: 'string', key: 'zahlungMeldung', size: 24, required: false }, +] + +export async function main({ check = false, client, log = console.log } = {}) { + const { config } = await import('../server/config.js') + let api = client + if (!api) { + const { createAppwriteClient } = await import('./analyse-setup.mjs') + api = createAppwriteClient() + } + const featuresId = config.collections.portalFeatures + const missing = [] + + // Attribute einer Collection abgleichen: meldet Vorhandenes bzw. (mit --check) jede Luecke und liefert die fehlenden + async function syncAttributes(collectionId, attributes) { + const have = new Set((await api.listAttributes(collectionId)).map((a) => a.key)) + for (const attr of attributes) { + if (have.has(attr.key)) log(`= ${collectionId}.${attr.key} (existiert)`) + else if (check) log(`- ${collectionId}.${attr.key} FEHLT`) + } + return attributes.filter((a) => !have.has(a.key)) + } + + const featureAbsent = await syncAttributes(featuresId, PORTAL_FEATURE_ATTRIBUTES) + featureAbsent.forEach((a) => missing.push({ ...a, collection: featuresId })) + + if (check) { + log(missing.length ? `${missing.length} Eintrag/Eintraege fehlen.` : 'Alles vorhanden.') + return { missing } + } + + for (const attr of featureAbsent) { + await api.createAttribute(featuresId, attr) + log(`+ Attribut ${featuresId}.${attr.key}`) + } + for (const attr of featureAbsent) await api.waitForAttribute(featuresId, attr.key) + log('Fertig.') + return { missing } +} + +const invokedDirectly = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href +if (invokedDirectly) { + const check = process.argv.includes('--check') + main({ check }) + .then(({ missing }) => process.exit(check && missing.length ? 1 : 0)) + .catch((e) => { + console.error(e.message) + process.exit(1) + }) +} diff --git a/server/services/customerAdmin.js b/server/services/customerAdmin.js index e91a689..74e330a 100644 --- a/server/services/customerAdmin.js +++ b/server/services/customerAdmin.js @@ -286,7 +286,7 @@ export async function deleteCustomerWithPortalAccess(customerId) { // Fehler halten das Loeschen nicht auf (wie bei der Analyse). try { const { disableCustomerFeatures } = await import('./siteFeatures.js') - await disableCustomerFeatures(customerId, 'termine', { adminNote: 'Kunde gelöscht' }) + await disableCustomerFeatures(customerId, 'termine', { adminNote: 'Kunde gelöscht', reason: 'kunde_geloescht' }) } catch (err) { console.error(`[customerAdmin] Terminplaner von Kunde ${customerId} nicht abgeschaltet:`, err.message) } diff --git a/server/services/siteFeatures.js b/server/services/siteFeatures.js index 85350dd..e315591 100644 --- a/server/services/siteFeatures.js +++ b/server/services/siteFeatures.js @@ -13,6 +13,8 @@ export const FEATURE_LABELS = { termine: { name: 'Terminplaner', keine: 'keinen Terminplaner' }, } export const STATES = ['off', 'ordered', 'setup', 'active', 'disabled'] +/** Warum eine Funktion abgeschaltet ist (portalFeatures.disabledReason); 'team' ist der Vorgabewert (Abschalten im WOMS, Aufbewahrungsfrist). */ +export const DISABLED_REASONS = ['team', 'abo_ende', 'abo_ende_vor_einrichtung', 'kunde_geloescht'] export const DISABLED_MESSAGE = 'Die News-Pinnwand wurde für diese Website abgeschaltet. Schreib uns im Chat, wenn du sie wieder möchtest.' @@ -295,18 +297,44 @@ export async function activate(docId, { by, adminNote } = {}) { return updateDocument(config.collections.portalFeatures, docId, { ...stateFields('active'), activatedAt: new Date().toISOString(), - // Eine wieder eingeschaltete Funktion darf nie in den 30-Tage-Loeschlauf des Terminplaners fallen (disabledAt-Marker) + // Eine wieder eingeschaltete Funktion darf nie in den 30-Tage-Loeschlauf des Terminplaners fallen (disabledAt-Marker); + // Grund und Marker der Abo-Ende-Mail gehoeren zur Abschaltung und enden mit ihr. zahlungMeldung bleibt bewusst unberuehrt: + // ein noch offener Zahlungsfall darf durch eine Aktivierung nicht verloren gehen. disabledAt: null, + disabledReason: null, + aboEndeMailAt: null, ...(note ? { adminNote: note } : {}), }) } -export async function disable(docId, { by, adminNote } = {}) { - const note = adminNote ?? byNote(by, 'Abgeschaltet') - const doc = await updateDocument(config.collections.portalFeatures, docId, { - ...stateFields('disabled'), - disabledAt: new Date().toISOString(), - ...(note ? { adminNote: note } : {}), +function assertReason(reason) { + if (!DISABLED_REASONS.includes(reason)) throw httpError(400, 'Unbekannter Abschaltgrund') +} + +/** + * Funktion abschalten. Idempotent: steht das Dokument schon auf `disabled`, bleibt alles wie es ist (disabledAt als Beginn der + * 30-Tage-Loeschfrist, Grund, Notiz) und es gibt keinen Schreibzugriff; ein Doppelklick im WOMS, die Kundenloeschung oder ein + * zweiter Lauf verschieben die Frist also nie. Einzige Ausnahme Altbestand: fehlt einem `disabled`-Dokument disabledAt, wird nur + * dieses Feld gesetzt (sonst fiele es nie in die Loeschung). Die Nacharbeit der Analyse (Sammeldienst) laeuft bei jedem Aufruf. + * @param {string} docId + * @param {{ by?: string, adminNote?: string, reason?: 'team'|'abo_ende'|'abo_ende_vor_einrichtung'|'kunde_geloescht' }} [options] + * @throws {Error} 400 bei unbekanntem Grund (ohne Schreibzugriff), 404 bei unbekanntem Dokument + */ +export async function disable(docId, { by, adminNote, reason = 'team' } = {}) { + assertReason(reason) + const doc = await withLocks([`feature-disable:${docId}`], async () => { + const current = await getDocument(config.collections.portalFeatures, docId) + if (current.state === 'disabled') { + if (current.disabledAt) return current + return updateDocument(config.collections.portalFeatures, docId, { disabledAt: new Date().toISOString() }) + } + const note = adminNote ?? byNote(by, 'Abgeschaltet') + return updateDocument(config.collections.portalFeatures, docId, { + ...stateFields('disabled'), + disabledAt: new Date().toISOString(), + disabledReason: reason, + ...(note ? { adminNote: note } : {}), + }) }) if (doc?.featureKey === 'analyse' && doc.siteKey) await stopAnalyseCollecting(doc) return doc @@ -318,8 +346,9 @@ export async function disable(docId, { by, adminNote } = {}) { * abgeschaltet und danach der erste Fehler weitergereicht. * @returns {Promise} die abgeschalteten Dokumente */ -export async function disableCustomerFeatures(customerId, key, { by, adminNote } = {}) { +export async function disableCustomerFeatures(customerId, key, { by, adminNote, reason = 'team' } = {}) { assertKey(key) + assertReason(reason) const docs = (await listCustomerFeatureDocs(customerId)).filter( (d) => d.featureKey === key && d.state !== 'off' && d.state !== 'disabled' ) @@ -327,7 +356,7 @@ export async function disableCustomerFeatures(customerId, key, { by, adminNote } let failure = null for (const doc of docs) { try { - disabled.push(await disable(doc.$id, { by, adminNote })) + disabled.push(await disable(doc.$id, { by, adminNote, reason })) } catch (err) { failure ||= err } diff --git a/test/analyse.test.js b/test/analyse.test.js index d0136d3..521636f 100644 --- a/test/analyse.test.js +++ b/test/analyse.test.js @@ -627,6 +627,25 @@ test('Löschfrist/Sperre: siteFeatures.disable beendet Sammeldienst, baut neu un assert.equal((await doc('c1', 'k7p')).state, 'disabled') }) +test('disable() idempotent (A11): die Analyse-Nacharbeit läuft bei jedem Aufruf, das Dokument bleibt beim zweiten unverändert', async () => { + aw.seed('websiteProjects', { $id: 'a11p', customerId: 'c1', projectName: 'A11P', subdomain: 'a11p', isPublic: true, repoFullName: 'WEBklar/a11p' }) + const d = await seedFeature('c1', 'a11p', 'active') + const erst = await sf.disable(d.$id, { adminNote: 'eins' }) + assert.equal(erst.state, 'disabled') + assert.equal(erst.disabledReason, 'team') + assert.deepEqual(stops, [d.siteKey]) + const davor = await doc('c1', 'a11p') + assert.ok(davor.removalTicketId) + + const zweit = await sf.disable(d.$id, { adminNote: 'zwei', reason: 'abo_ende' }) + assert.deepEqual(stops, [d.siteKey, d.siteKey], 'stopSite läuft auch beim zweiten Aufruf, wie vorher') + assert.equal(zweit.state, 'disabled') + assert.deepEqual(await doc('c1', 'a11p'), davor, 'Frist, Grund, Notiz und Marker bleiben') + assert.equal(davor.disabledReason, 'team') + assert.equal(davor.adminNote, 'eins') + assert.equal(removalTopics('A11P').length, 1, 'das Entfernen-Ticket gibt es genau einmal') +}) + 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') diff --git a/test/featureSetup.test.js b/test/featureSetup.test.js new file mode 100644 index 0000000..2c9989b --- /dev/null +++ b/test/featureSetup.test.js @@ -0,0 +1,130 @@ +import { test, after } from 'node:test' +import assert from 'node:assert/strict' +import { startAppwriteMock } from './support/appwriteMock.js' +import { useTestEnv } from './support/testEnv.js' +import { schemaKeys } from './support/schema.js' +// Das Skript laedt config.js erst in main(): schema.js (Testhilfe) importiert es vor useTestEnv() +import { PORTAL_FEATURE_ATTRIBUTES, main } from '../scripts/feature-setup.mjs' + +// Schema-Skript der Funktions-Attribute (scripts/feature-setup.mjs): Mock-Client wie in analyseSetup.test.js +const schema = { portalFeatures: [] } // wird im Schema-Test gefuellt (die Analyse-Schluessel kennt erst das dynamisch geladene Skript) +const aw = await startAppwriteMock({ schema }) +after(() => aw.close()) +useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' }) +const { config } = await import('../server/config.js') +const { ANALYSE_ATTRIBUTES } = await import('../scripts/analyse-setup.mjs') + +function mockClient({ attrs = [] } = {}) { + const calls = { attrs: [], collections: [], indexes: [], waits: [] } + return { + calls, + async collectionExists() { + return true + }, + async ensureCollection(id, name) { + calls.collections.push({ id, name }) + return true + }, + async listAttributes(id) { + assert.equal(id, config.collections.portalFeatures, 'liest nur die Funktions-Collection') + return attrs.map((key) => ({ key })) + }, + async createAttribute(id, attr) { + calls.attrs.push({ id, ...attr }) + }, + async waitForAttribute(id, key) { + calls.waits.push(`${id}.${key}`) + }, + async listIndexes() { + return [] + }, + async createIndex(id, index) { + calls.indexes.push(`${id}.${index.key}`) + }, + } +} +const KEYS = ['disabledReason', 'aboEndeMailAt', 'zahlungMeldung'] +const KEINE = { attrs: [], collections: [], indexes: [], waits: [] } + +test('Schema: drei Attribute mit Typ und Groesse, alle optional', () => { + assert.deepEqual(PORTAL_FEATURE_ATTRIBUTES.map((a) => a.key), KEYS) + const nach = Object.fromEntries(PORTAL_FEATURE_ATTRIBUTES.map((a) => [a.key, a])) + assert.equal(nach.disabledReason.type, 'string') + assert.equal(nach.disabledReason.size, 32) + assert.ok(nach.disabledReason.size >= 'abo_ende_vor_einrichtung'.length, 'der laengste Abschaltgrund passt hinein') + assert.ok(nach.disabledReason.size >= 24) + assert.equal(nach.aboEndeMailAt.type, 'datetime') + assert.equal('size' in nach.aboEndeMailAt, false, 'ein datetime-Attribut hat keine Groesse') + assert.equal(nach.zahlungMeldung.type, 'string') + assert.equal(nach.zahlungMeldung.size, 24) + assert.ok(nach.zahlungMeldung.size >= 'bereits_aktiv'.length) + assert.ok(PORTAL_FEATURE_ATTRIBUTES.every((a) => a.required === false), 'alle optional') +}) + +test('Schema: DISABLED_REASONS passen in disabledReason', async () => { + const { DISABLED_REASONS } = await import('../server/services/siteFeatures.js') + const groesse = PORTAL_FEATURE_ATTRIBUTES.find((a) => a.key === 'disabledReason').size + for (const grund of DISABLED_REASONS) assert.ok(grund.length <= groesse, grund) +}) + +test('main legt nur Fehlendes an und wartet auf die neuen Attribute', async () => { + const client = mockClient({ attrs: ['disabledReason', 'customerId'] }) + const log = [] + const result = await main({ client, log: (zeile) => log.push(zeile) }) + assert.deepEqual(client.calls.attrs.map((a) => `${a.id}.${a.key}`), ['portalFeatures.aboEndeMailAt', 'portalFeatures.zahlungMeldung']) + assert.deepEqual(client.calls.attrs.map((a) => a.type), ['datetime', 'string']) + assert.ok(client.calls.attrs.every((a) => a.required === false)) + assert.equal(client.calls.attrs.find((a) => a.key === 'zahlungMeldung').size, 24) + assert.deepEqual(client.calls.waits, ['portalFeatures.aboEndeMailAt', 'portalFeatures.zahlungMeldung']) + assert.deepEqual([client.calls.collections, client.calls.indexes], [[], []]) + assert.deepEqual(result.missing.map((a) => a.key), ['aboEndeMailAt', 'zahlungMeldung']) + assert.ok(log.some((zeile) => zeile.includes('+ Attribut portalFeatures.aboEndeMailAt'))) + assert.ok(log.some((zeile) => zeile.includes('= portalFeatures.disabledReason (existiert)'))) +}) + +test('main legt bei leerer Collection alle drei an, beim zweiten Lauf nichts mehr (idempotent)', async () => { + const leer = mockClient() + assert.equal((await main({ client: leer, log: () => {} })).missing.length, 3) + assert.deepEqual(leer.calls.attrs.map((a) => a.key), KEYS) + + const voll = mockClient({ attrs: KEYS }) + const result = await main({ client: voll, log: () => {} }) + assert.deepEqual(voll.calls, KEINE) + assert.equal(result.missing.length, 0) +}) + +test('main --check legt nichts an, meldet jede Luecke und liefert sie im Ergebnis', async () => { + const client = mockClient({ attrs: ['disabledReason'] }) + const log = [] + const result = await main({ check: true, client, log: (zeile) => log.push(zeile) }) + assert.deepEqual(client.calls, KEINE) + assert.deepEqual(result.missing.map((a) => a.key), ['aboEndeMailAt', 'zahlungMeldung']) + assert.ok(log.includes('- portalFeatures.aboEndeMailAt FEHLT')) + assert.ok(log.includes('- portalFeatures.zahlungMeldung FEHLT')) + assert.ok(!log.some((zeile) => zeile.includes('disabledReason FEHLT'))) + + const alle = mockClient({ attrs: KEYS }) + const log2 = [] + const heil = await main({ check: true, client: alle, log: (zeile) => log2.push(zeile) }) + assert.equal(heil.missing.length, 0) + assert.ok(!log2.some((zeile) => zeile.includes('FEHLT'))) + assert.deepEqual(alle.calls, KEINE) +}) + +test('schemaKeys(portalFeatures) enthaelt die drei Schluessel; der Analyse-Aufbau bleibt fehlerfrei', async () => { + const keys = schemaKeys('portalFeatures') + for (const key of [...KEYS, 'disabledAt', 'state', 'consentText']) assert.ok(keys.includes(key), key) + assert.equal(new Set(keys).size, keys.length, 'keine doppelten Schluessel') + + // Der Aufbau aus analyse.test.js: [...schemaKeys('portalFeatures'), ...ANALYSE_KEYS] + const analyse = ANALYSE_ATTRIBUTES.map((a) => a.key) + const zusammen = [...keys, ...analyse] + // activatedAt steht schon im Grundschema und in ANALYSE_KEYS (harmlos); die neuen Schluessel gehoeren nur einer Seite + for (const key of KEYS) assert.ok(!analyse.includes(key), `${key} ist kein Analyse-Attribut`) + schema.portalFeatures.push(...zusammen) + const { createDocument, updateDocument } = await import('../server/services/appwriteAdmin.js') + const doc = await createDocument(config.collections.portalFeatures, { customerId: 'c1', disabledReason: 'team', accentColor: '#111111' }, 'f_schema') + const neu = await updateDocument(config.collections.portalFeatures, doc.$id, { aboEndeMailAt: null, zahlungMeldung: '', offSince: null }) + assert.equal(neu.disabledReason, 'team') + await assert.rejects(() => updateDocument(config.collections.portalFeatures, doc.$id, { gibtEsNicht: 1 }), /Unknown attribute/) +}) diff --git a/test/funktionenTermine.test.js b/test/funktionenTermine.test.js index 54cb5b7..1674d14 100644 --- a/test/funktionenTermine.test.js +++ b/test/funktionenTermine.test.js @@ -71,16 +71,24 @@ test('/news bleibt gleichwertig und aendert den Terminplaner nicht', async () => assert.equal(after.state, 'active') }) -test('Abschalten setzt disabledAt, erneutes Aktivieren leert es (R36)', async () => { +test('Abschalten setzt disabledAt und den Grund team, erneutes Aktivieren leert beides (R36)', async () => { const off = await team('c1', 'termine', { projectId: 'pt1', action: 'disable' }) assert.equal(off.status, 200) assert.equal(featuresOf(off, 'pt1').termine.state, 'disabled') const doc = await docOf('pt1', 'termine') assert.equal(doc.enabled, false) assert.ok(Math.abs(Date.parse(doc.disabledAt) - Date.now()) < 5000) + assert.equal(doc.disabledReason, 'team', 'das WOMS schaltet mit dem Vorgabegrund ab') + // zweiter Klick (Doppelklick im WOMS): Beginn der 30 Tage, Grund und Notiz bleiben + const nochmal = await team('c1', 'termine', { projectId: 'pt1', action: 'disable', note: 'zweiter Klick' }) + assert.equal(nochmal.status, 200) + const gleich = await docOf('pt1', 'termine') + assert.deepEqual([gleich.disabledAt, gleich.disabledReason, gleich.adminNote], [doc.disabledAt, 'team', doc.adminNote]) const on = await team('c1', 'termine', { projectId: 'pt1', action: 'activate' }) assert.equal(featuresOf(on, 'pt1').termine.state, 'active') - assert.equal((await docOf('pt1', 'termine')).disabledAt, null) + const aktiv = await docOf('pt1', 'termine') + assert.equal(aktiv.disabledAt, null) + assert.equal(aktiv.disabledReason, null) }) test('unbekannter Schluessel -> 404, auch /kalender; nichts wird angelegt', async () => { @@ -170,7 +178,7 @@ test('GET /api/funktionen: Projekte tragen features.termine und News wie bisher' }) // --------------------------------------------------------- Kunde loeschen --- -test('Kunde loeschen schaltet seine Terminplaner ab (Notiz Kunde geloescht), nur seine', async () => { +test('Kunde loeschen schaltet seine Terminplaner ab (Notiz Kunde geloescht, Grund kunde_geloescht), nur seine', async () => { aw.seed('customers', { $id: 'kt1', name: 'Weg Salon', email: 'weg@example.invalid' }) aw.seed('websiteProjects', { $id: 'ktp1', customerId: 'kt1', projectName: 'Weg Eins' }) aw.seed('websiteProjects', { $id: 'ktp2', customerId: 'kt1', projectName: 'Weg Zwei' }) @@ -187,10 +195,12 @@ test('Kunde loeschen schaltet seine Terminplaner ab (Notiz Kunde geloescht), nur assert.equal(aw.docs('customers').has('kt1'), false) for (const projekt of ['ktp1', 'ktp2']) { const doc = await sf.getFeatureDoc('kt1', projekt, 'termine') - assert.deepEqual([doc.state, doc.enabled, doc.adminNote], ['disabled', false, 'Kunde gelöscht'], projekt) + assert.deepEqual([doc.state, doc.enabled, doc.adminNote, doc.disabledReason], ['disabled', false, 'Kunde gelöscht', 'kunde_geloescht'], projekt) assert.ok(Math.abs(Date.parse(doc.disabledAt) - Date.now()) < 5000, projekt) } - assert.equal((await sf.getFeatureDoc('kt1', 'ktp3', 'termine')).disabledAt, frueher, 'Beginn der 30 Tage bleibt') + const schonAusNachher = await sf.getFeatureDoc('kt1', 'ktp3', 'termine') + assert.equal(schonAusNachher.disabledAt, frueher, 'Beginn der 30 Tage bleibt') + assert.equal(schonAusNachher.disabledReason, 'team', 'ein schon abgeschalteter Terminplaner behaelt seinen Grund') assert.equal((await sf.getFeatureDoc('kt1', 'ktp1', 'news')).state, 'active', 'News ist nicht Sache des Terminplaners') assert.equal((await sf.getFeatureDoc('c1', 'pt1', 'termine')).state, fremd.state, 'anderer Kunde') }) diff --git a/test/siteFeatures.test.js b/test/siteFeatures.test.js index 818cb9a..5f891fe 100644 --- a/test/siteFeatures.test.js +++ b/test/siteFeatures.test.js @@ -1,4 +1,4 @@ -import { test, after } from 'node:test' +import { test, after, mock } from 'node:test' import assert from 'node:assert/strict' import { startAppwriteMock } from './support/appwriteMock.js' import { startFakeStripe } from './support/fakeStripe.js' @@ -264,3 +264,176 @@ test('disableCustomerFeatures: schaltet nur die genannte Funktion des Kunden ab, assert.equal((await sf.getFeatureDoc('c1', 'pt1', 'termine')).disabledAt, marker) await assert.rejects(() => sf.disableCustomerFeatures('c1', 'kalender', {}), { status: 400 }) }) + +// ------------------------------------------------ disable(): Grund und Idempotenz (A11) --- +const PF = '/collections/portalFeatures/documents' +const termineDoc = (id, over = {}) => ({ $id: id, customerId: 'c1', projectId: `p_${id}`.slice(0, 36), featureKey: 'termine', state: 'active', enabled: true, siteKey: 'abcdefghijklmnop', ...over }) +const lesen = (id) => aw.docs('portalFeatures').get(id) +const warte = (ms = 5) => new Promise((resolve) => setTimeout(resolve, ms)) + +/** Fuehrt fn aus und zaehlt die schreibenden Anfragen an portalFeatures (PATCH, POST, DELETE). */ +async function schreibzugriffe(fn) { + const echt = globalThis.fetch + let schreibend = 0 + const spion = mock.method(globalThis, 'fetch', async (url, opts = {}) => { + if (String(url).includes(PF) && String(opts.method || 'GET').toUpperCase() !== 'GET') schreibend += 1 + return echt(url, opts) + }) + try { + const ergebnis = await fn() + return { ergebnis, schreibend } + } finally { + spion.mock.restore() + } +} + +test('DISABLED_REASONS ist die feste Liste aus dem Plankopf', () => { + assert.deepEqual(sf.DISABLED_REASONS, ['team', 'abo_ende', 'abo_ende_vor_einrichtung', 'kunde_geloescht']) +}) + +test('disable ohne Grund speichert team, mit Grund den Grund, ein unbekannter Grund ist 400 ohne Schreibzugriff', async () => { + aw.seed('portalFeatures', termineDoc('f_a11_grund0')) + const ohne = await sf.disable('f_a11_grund0', { by: 'kenso' }) + assert.equal(ohne.state, 'disabled') + assert.equal(ohne.disabledReason, 'team') + assert.equal(ohne.adminNote, 'Abgeschaltet von kenso') + + for (const grund of sf.DISABLED_REASONS) { + aw.seed('portalFeatures', termineDoc(`f_a11_${grund}`)) + const doc = await sf.disable(`f_a11_${grund}`, { reason: grund }) + assert.equal(doc.disabledReason, grund, grund) + assert.equal(lesen(doc.$id).disabledReason, grund, `${grund}: gespeichert`) + } + const ohneNotiz = lesen('f_a11_abo_ende') + assert.equal(ohneNotiz.adminNote, undefined, 'ohne by und adminNote keine Notiz (wie bisher)') + + aw.seed('portalFeatures', termineDoc('f_a11_frist')) + for (const falsch of ['frist', '', null, 'TEAM']) { + const { schreibend } = await schreibzugriffe(() => assert.rejects(() => sf.disable('f_a11_frist', { reason: falsch }), { status: 400 })) + assert.equal(schreibend, 0, `Grund ${JSON.stringify(falsch)}: kein Schreibzugriff`) + } + const unberuehrt = lesen('f_a11_frist') + assert.equal(unberuehrt.state, 'active') + assert.equal(unberuehrt.enabled, true) + assert.equal(unberuehrt.disabledAt, undefined) + assert.equal(unberuehrt.disabledReason, undefined) +}) + +test('disable eines unbekannten Dokuments bleibt 404', async () => { + await assert.rejects(() => sf.disable('f_a11_gibtsnicht', { reason: 'abo_ende' }), { status: 404 }) +}) + +test('disable ist idempotent: das zweite Mal bleibt alles, es gibt keinen Schreibzugriff und das Dokument kommt zurueck', async () => { + aw.seed('portalFeatures', termineDoc('f_a11_idem')) + const erst = await sf.disable('f_a11_idem', { reason: 'abo_ende', adminNote: 'Abo beendet' }) + const davor = lesen('f_a11_idem') + await warte() + const { ergebnis: zweit, schreibend } = await schreibzugriffe(() => sf.disable('f_a11_idem', { by: 'kenso', reason: 'kunde_geloescht', adminNote: 'Kunde gelöscht' })) + assert.equal(schreibend, 0, 'schon abgeschaltet: nichts wird geschrieben') + const danach = lesen('f_a11_idem') + assert.deepEqual(danach, davor, 'das ganze Dokument unveraendert') + assert.equal(danach.disabledAt, erst.disabledAt) + assert.equal(danach.disabledReason, 'abo_ende') + assert.equal(danach.adminNote, 'Abo beendet') + assert.equal(danach.$updatedAt, davor.$updatedAt) + assert.deepEqual(zweit, davor, 'liefert das (gelesene) Dokument') + // auch ohne Angaben, auch das dritte Mal + assert.deepEqual(await sf.disable('f_a11_idem', {}), davor) +}) + +test('disable: zwei gleichzeitige Aufrufe schreiben einmal, beide sehen dasselbe disabledAt und denselben Grund', async () => { + aw.seed('portalFeatures', termineDoc('f_a11_parallel')) + const { ergebnis, schreibend } = await schreibzugriffe(() => + Promise.all([ + sf.disable('f_a11_parallel', { reason: 'abo_ende', adminNote: 'zuerst' }), + sf.disable('f_a11_parallel', { reason: 'kunde_geloescht', adminNote: 'danach' }), + ]) + ) + assert.equal(schreibend, 1) + const [a, b] = ergebnis + assert.equal(a.disabledAt, b.disabledAt) + assert.equal(b.disabledReason, 'abo_ende', 'der erste Aufruf gewinnt') + assert.equal(b.adminNote, 'zuerst') + assert.equal(lesen('f_a11_parallel').disabledAt, a.disabledAt) + // viele gleichzeitige Aufrufe (Doppelklick, Kundenloeschung und Abgleich zugleich) + aw.seed('portalFeatures', termineDoc('f_a11_parallel2')) + const viele = await schreibzugriffe(() => Promise.all([1, 2, 3, 4, 5].map(() => sf.disable('f_a11_parallel2', {})))) + assert.equal(viele.schreibend, 1) + assert.equal(new Set(viele.ergebnis.map((d) => d.disabledAt)).size, 1) +}) + +test('disable: Altbestand disabled ohne disabledAt bekommt nur disabledAt (jetzt), Grund und Notiz bleiben', async () => { + aw.seed('portalFeatures', termineDoc('f_a11_alt', { state: 'disabled', enabled: false, adminNote: 'Altbestand' })) + const { ergebnis, schreibend } = await schreibzugriffe(() => sf.disable('f_a11_alt', { reason: 'abo_ende', adminNote: 'neu' })) + assert.equal(schreibend, 1) + assert.ok(Math.abs(Date.parse(ergebnis.disabledAt) - Date.now()) < 5000, 'disabledAt ist jetzt') + assert.equal(ergebnis.adminNote, 'Altbestand') + assert.ok(!ergebnis.disabledReason, 'der Grund bleibt leer (Altbestand wie team behandelt, nie angefasst)') + assert.equal(ergebnis.state, 'disabled') + const gesetzt = lesen('f_a11_alt') + assert.equal(gesetzt.disabledAt, ergebnis.disabledAt) + assert.ok(!gesetzt.disabledReason) + // der zweite Aufruf schreibt nichts mehr + const { ergebnis: zweit, schreibend: nochmal } = await schreibzugriffe(() => sf.disable('f_a11_alt', { reason: 'kunde_geloescht', adminNote: 'x' })) + assert.equal(nochmal, 0) + assert.equal(zweit.disabledAt, gesetzt.disabledAt) + assert.deepEqual(lesen('f_a11_alt'), gesetzt) +}) + +test('activate leert disabledAt, disabledReason und aboEndeMailAt und laesst zahlungMeldung unveraendert', async () => { + aw.seed( + 'portalFeatures', + termineDoc('f_a11_activate', { + state: 'disabled', + enabled: false, + disabledAt: '2026-09-01T10:00:00.000Z', + disabledReason: 'abo_ende', + aboEndeMailAt: '2026-09-01T10:05:00.000Z', + zahlungMeldung: 'gemeldet', + }) + ) + const on = await sf.activate('f_a11_activate', { by: 'kenso' }) + assert.equal(on.state, 'active') + assert.equal(on.enabled, true) + assert.equal(on.disabledAt, null) + assert.equal(on.disabledReason, null) + assert.equal(on.aboEndeMailAt, null) + assert.equal(on.zahlungMeldung, 'gemeldet', 'ein offener Zahlungsfall geht durch eine Aktivierung nicht verloren') + assert.deepEqual( + [lesen('f_a11_activate').disabledAt, lesen('f_a11_activate').disabledReason, lesen('f_a11_activate').aboEndeMailAt, lesen('f_a11_activate').zahlungMeldung], + [null, null, null, 'gemeldet'] + ) + // Abschalten und Einschalten im Kreis: danach beginnt eine neue Abschaltung mit neuem Grund + const wieder = await sf.disable('f_a11_activate', { reason: 'kunde_geloescht' }) + assert.equal(wieder.disabledReason, 'kunde_geloescht') + assert.ok(wieder.disabledAt) +}) + +test('disableCustomerFeatures reicht den Grund an alle Dokumente durch, ein schon abgeschaltetes behaelt Grund und disabledAt', async () => { + const frueher = '2026-09-01T10:00:00.000Z' + aw.seed('portalFeatures', termineDoc('f_a11_k_aktiv', { customerId: 'c_a11', projectId: 'p_a11_1' })) + aw.seed('portalFeatures', termineDoc('f_a11_k_setup', { customerId: 'c_a11', projectId: 'p_a11_2', state: 'setup', enabled: false })) + aw.seed('portalFeatures', termineDoc('f_a11_k_bestellt', { customerId: 'c_a11', projectId: 'p_a11_3', state: 'ordered', enabled: false })) + aw.seed('portalFeatures', termineDoc('f_a11_k_schonaus', { customerId: 'c_a11', projectId: 'p_a11_4', state: 'disabled', enabled: false, disabledAt: frueher, disabledReason: 'team', adminNote: 'vom Team' })) + aw.seed('portalFeatures', termineDoc('f_a11_k_off', { customerId: 'c_a11', projectId: 'p_a11_5', state: 'off', enabled: false })) + aw.seed('portalFeatures', termineDoc('f_a11_k_news', { customerId: 'c_a11', projectId: 'p_a11_1', featureKey: 'news' })) + + const done = await sf.disableCustomerFeatures('c_a11', 'termine', { adminNote: 'Kunde gelöscht', reason: 'kunde_geloescht' }) + assert.deepEqual(done.map((d) => d.$id).sort(), ['f_a11_k_aktiv', 'f_a11_k_bestellt', 'f_a11_k_setup']) + for (const id of ['f_a11_k_aktiv', 'f_a11_k_setup', 'f_a11_k_bestellt']) { + const doc = lesen(id) + assert.deepEqual([doc.state, doc.enabled, doc.disabledReason, doc.adminNote], ['disabled', false, 'kunde_geloescht', 'Kunde gelöscht'], id) + assert.ok(doc.disabledAt, id) + } + const schonAus = lesen('f_a11_k_schonaus') + assert.deepEqual([schonAus.disabledReason, schonAus.disabledAt, schonAus.adminNote], ['team', frueher, 'vom Team']) + assert.equal(lesen('f_a11_k_off').state, 'off') + assert.equal(lesen('f_a11_k_news').state, 'active', 'andere Funktion') + + // ohne Grund: team; ein unbekannter Grund ist 400 und schreibt nichts + aw.seed('portalFeatures', termineDoc('f_a11_k2', { customerId: 'c_a11b', projectId: 'p_a11_6' })) + await assert.rejects(() => sf.disableCustomerFeatures('c_a11b', 'termine', { reason: 'frist' }), { status: 400 }) + assert.equal(lesen('f_a11_k2').state, 'active') + await sf.disableCustomerFeatures('c_a11b', 'termine', {}) + assert.equal(lesen('f_a11_k2').disabledReason, 'team') +}) diff --git a/test/support/schema.js b/test/support/schema.js index 6a995b7..981137c 100644 --- a/test/support/schema.js +++ b/test/support/schema.js @@ -1,5 +1,6 @@ import { readFileSync } from 'node:fs' import { TERMINE_COLLECTIONS } from '../../server/services/termine/schema.js' +import { PORTAL_FEATURE_ATTRIBUTES } from '../../scripts/feature-setup.mjs' // Setup-Skripte, die Collections in Appwrite anlegen (Blockformat: 'id', ... \n ],) const SETUP_SCRIPTS = ['setup-abo-collections.mjs', 'setup-finanzen.mjs'] @@ -8,7 +9,8 @@ const SETUP_SCRIPTS = ['setup-abo-collections.mjs', 'setup-finanzen.mjs'] * Attribut-Schlüssel einer Collection, wie sie scripts/setup-abo-collections.mjs bzw. * scripts/setup-finanzen.mjs in Appwrite anlegen. Tests geben sie dem Appwrite-Nachbau als Schema: * Schreibt der Code ein Feld, das das Setup-Skript nicht anlegt, scheitert der Test wie Appwrite in - * Produktion. Die Termine-Collections kommen aus server/services/termine/schema.js (scripts/termine-setup.mjs). + * Produktion. Die Termine-Collections kommen aus server/services/termine/schema.js (scripts/termine-setup.mjs); + * portalFeatures bekommt zusaetzlich die Attribute aus scripts/feature-setup.mjs (PORTAL_FEATURE_ATTRIBUTES). */ export function schemaKeys(collection) { if (Object.hasOwn(TERMINE_COLLECTIONS, collection)) return TERMINE_COLLECTIONS[collection].attributes.map((a) => a.key) @@ -17,7 +19,8 @@ export function schemaKeys(collection) { const start = src.indexOf(`'${collection}',`) if (start < 0) continue const block = src.slice(start, src.indexOf('\n ],', start)) - return [...block.matchAll(/key: '(\w+)'/g)].map((m) => m[1]) + const keys = [...block.matchAll(/key: '(\w+)'/g)].map((m) => m[1]) + return collection === 'portalFeatures' ? [...keys, ...PORTAL_FEATURE_ATTRIBUTES.map((a) => a.key)] : keys } throw new Error(`Collection ${collection} fehlt im Setup-Skript`) } diff --git a/test/termineJobs.test.js b/test/termineJobs.test.js index de419a3..d16f1f0 100644 --- a/test/termineJobs.test.js +++ b/test/termineJobs.test.js @@ -33,6 +33,7 @@ const { neuesToken, verwaltungsToken, buchungsNummer } = await import('../server const { speichereEinstellung, resetKontextForTests } = await import('../server/services/termine/kontext.js') const { belegeZellen } = await import('../server/services/termine/zellen.js') const { bestaetige } = await import('../server/services/termine/ablauf.js') +const { disable } = await import('../server/services/siteFeatures.js') const J = await import('../server/services/termine/jobs.js') const { minutenJob, fuenfMinutenJob, taeglicherJob, tagesbericht, wendeLoeschfristenAn, loescheAbgeschalteteFunktionen, resetJobsForTests } = J after(() => setMailTransportForTests(null)) @@ -1373,6 +1374,20 @@ test('loescheAbgeschalteteFunktionen: scheitert das Löschen einer Buchung, blei assert.equal(aw.docs('termineConfig').get(F1), undefined) }) +test('loescheAbgeschalteteFunktionen: ein erneutes disable() (Doppelklick im WOMS, Kundenlöschung) verschiebt die 30 Tage nicht', async () => { + const seit = plus(JETZT, -31 * TAG) + await richteFunktionEin(F1, { state: 'disabled', disabledAt: iso(seit), disabledReason: 'abo_ende' }) + await legeBuchung('b_fest', { start: plus(JETZT, TAG) }) + await disable(F1, { by: 'Team' }) + await disable(F1, { reason: 'kunde_geloescht', adminNote: 'Kunde gelöscht' }) + const doc = aw.docs('portalFeatures').get(F1) + assert.equal(Date.parse(doc.disabledAt), seit.getTime(), 'disabledAt (Beginn der Frist) bleibt') + assert.equal(doc.disabledReason, 'abo_ende') + assert.deepEqual(await loescheAbgeschalteteFunktionen(JETZT), { funktionen: 1, buchungen: 1, gedeckelt: 0 }, 'die Löschung läuft weiter') + assert.equal(buchung('b_fest'), undefined) + assert.equal(aw.docs('termineConfig').get(F1), undefined, 'Einstellung gelöscht') +}) + /** Eine abgeschaltete Funktion (seit 40 Tagen, Standard F1) mit n Buchungen `000…`; mit zellen bekommt jede ihre Zellen. */ async function abgeschaltetMitBuchungen(n, { zellen = true, featureId = F1, praefix = 'b_ab' } = {}) { await richteFunktionEin(featureId, { state: 'disabled', disabledAt: iso(plus(JETZT, -40 * TAG)) })