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:
ClaudeandClaude Sonnet 5.5 committed 2026-10-04 11:33:17 +00:00
1 parent 39225594e5
commit aaed273b62
9 files changed
+472 -18

No files matched your search

+19
View File
@@ -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')
+130
View File
@@ -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/)
})
+15 -5
View File
@@ -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
View File
@@ -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')
})
+5 -2
View File
@@ -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`)
}
+15
View File
@@ -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)) })