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 <noreply@anthropic.com>
This commit is contained in:
1 parent
39225594e5
commit
aaed273b62
9 files changed
+472
-18
No files matched your search
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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<object[]>} 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
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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/)
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
+174
-1
@@ -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')
|
||||
})
|
||||
@@ -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`)
|
||||
}
|
||||
@@ -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 `<praefix>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)) })
|
||||
|
||||
Reference in new issue
Block a user