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
+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')
|
||||
})
|
||||
Reference in new issue
Block a user