Abo-Baustein aboStatus.js: Wechsel-Regel in laeuft/bestellbar, aboLage; Analyse nutzt ihn

aboStatus.js (Kernversion aus B5 ausgebaut): ein privater Lader lage(customerId, { altbestand })
ueber getCurrentSubscription und listContractEvents, alle Praedikate bauen darauf. laeuft kennt
altbestand (Vorgabe true: productKey || 'wartung' plus Wechsel-Regel; false: die heutige Regel der
Analyse, keine Ereignisabfrage). Wechsel-Regel: sagt der Spiegel "Wartung laeuft" und ist das
juengste wechsel-Ereignis des Abos widerrufen, laeuft das Wartungs-Abo nicht (kein_abo beim
Bestell-Gate, Freischaltung bleibt in setup). Neu aboLage ({ laeuft, art, endedAt }) und
aboMemo().lage; der Memo merkt eine Ablehnung fuer den Lauf. analyse.js nutzt
laeuft(id, { altbestand: false }); Verhalten der Analyse unveraendert (+1 Pin-Test).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-04 13:50:51 +00:00
1 parent cf9f9b30a0
commit 144b053851
6 files changed
+449 -58

No files matched your search

+299 -18
View File
@@ -5,16 +5,16 @@ import { startFakeStripe } from './support/fakeStripe.js'
import { useTestEnv } from './support/testEnv.js'
import { schemaKeys } from './support/schema.js'
// Abo-Baustein (server/services/aboStatus.js, Kernversion aus Block B): laeuft / bestellbar / aboMemo.
// Abo-Baustein (server/services/aboStatus.js, B5 Kernversion, E1 ausgebaut): laeuft / bestellbar / aboLage / aboMemo.
// Fail-closed: ein Lesefehler wirft, er wird nie zu false bzw. ok:false. Die Umgebung steht vor dem ersten
// Import von server/config.js.
const aw = await startAppwriteMock({ schema: { subscriptions: schemaKeys('subscriptions') } })
const aw = await startAppwriteMock({ schema: { subscriptions: schemaKeys('subscriptions'), contractEvents: schemaKeys('contractEvents') } })
const st = await startFakeStripe()
after(() => Promise.all([aw.close(), st.close()]))
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { seedFixtures, subscriptionDoc, wartungDoc } = await import('./support/fixtures.js')
const { seedFixtures, subscriptionDoc, wartungDoc, hosting2Doc } = await import('./support/fixtures.js')
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
const { laeuft, bestellbar, aboMemo } = await import('../server/services/aboStatus.js')
const { laeuft, bestellbar, aboLage, aboMemo } = await import('../server/services/aboStatus.js')
const DAY_MS = 86400000
const inTagen = (tage) => new Date(Date.now() + tage * DAY_MS).toISOString()
@@ -22,9 +22,46 @@ const inTagen = (tage) => new Date(Date.now() + tage * DAY_MS).toISOString()
// seedFixtures gibt c1 nur ein Hosting-Abo (sub_h): fuer Wartungsfaelle ueberschreibt der Test es mit einem Wartungs-Abo.
const wartung = (patch = {}) => aw.seed('subscriptions', subscriptionDoc('sub_h', { ...wartungDoc, ...patch }))
// Jeder Test beginnt mit den Standard-Abos (c1 Hosting, c2 Hosting, c3 keins); geloescht wird ueber die API
let ereignisNr = 0
// Vertragsereignis des Abos sub_h von c1; $createdAt steigt mit jedem Aufruf, damit "das juengste" eindeutig ist
const ereignis = (type, status, patch = {}) => {
ereignisNr += 1
aw.seed('contractEvents', {
$id: `ev_${ereignisNr}`,
$createdAt: new Date(Date.UTC(2026, 8, 1, 0, 0, ereignisNr)).toISOString(),
customerId: 'c1',
subscriptionId: 'sub_h',
type,
productKey: 'wartung',
details: '{}',
status,
...patch,
})
}
const wechsel = (status, patch) => ereignis('wechsel', status, patch)
/** Anzahl der Anfragen an den Appwrite-Nachbau, die fn ausloest (optional nur die, deren URL zu muster passt). */
async function zaehleAnfragen(fn, muster = null) {
const echt = globalThis.fetch
let n = 0
globalThis.fetch = (url, optionen) => {
if (!muster || muster.test(String(url))) n += 1
return echt(url, optionen)
}
try {
await fn()
} finally {
globalThis.fetch = echt
}
return n
}
const ereignisAnfragen = (fn) => zaehleAnfragen(fn, /contractEvents/)
// Jeder Test beginnt mit den Standard-Abos (c1 Hosting, c2 Hosting, c3 keins) und ohne Ereignisse; geloescht wird ueber die API
beforeEach(async () => {
for (const id of aw.docs('subscriptions').keys()) await deleteDocument('subscriptions', id)
for (const collection of ['subscriptions', 'contractEvents']) {
for (const id of aw.docs(collection).keys()) await deleteDocument(collection, id)
}
seedFixtures(aw)
})
@@ -135,7 +172,7 @@ test('aboMemo: ein anderer Kunde wird neu gelesen, ein anderer aboMemo() ebenfal
// anderer Kunde im selben Memo: eigene Lesung (trifft den eingeplanten Fehler)
aw.failNext({ collection: 'subscriptions', status: 500 })
await assert.rejects(() => memo.laeuft('c3'), /eingeplanter Fehler 500/)
assert.equal(await memo.laeuft('c3'), false, 'c3 hat kein Abo')
assert.equal(await aboMemo().laeuft('c3'), false, 'c3 hat kein Abo (die Ablehnung bleibt in diesem Memo stehen, E1)')
// anderer aboMemo() fuer denselben Kunden: neu lesen, der alte Speicher gilt nicht
aw.failNext({ collection: 'subscriptions', status: 500 })
@@ -143,17 +180,6 @@ test('aboMemo: ein anderer Kunde wird neu gelesen, ein anderer aboMemo() ebenfal
assert.equal(await memo.laeuft('c1'), true, 'der erste Memo behaelt seinen Eintrag')
})
test('aboMemo: ein Lesefehler bleibt nicht im Speicher haengen, der naechste Aufruf liest neu', async () => {
wartung({ status: 'active' })
const memo = aboMemo()
aw.failNext({ collection: 'subscriptions', status: 500 })
await assert.rejects(() => memo.laeuft('c1'), /eingeplanter Fehler 500/)
aw.failNext({ collection: 'subscriptions', status: 500 })
await assert.rejects(() => memo.bestellbar('c1'), /eingeplanter Fehler 500/, 'wirft auch bestellbar, nie ok:false')
assert.equal(await memo.laeuft('c1'), true)
assert.deepEqual(await memo.bestellbar('c1'), { ok: true })
})
test('aboMemo: gleichzeitige Aufrufe desselben Kunden lesen nur einmal', async () => {
wartung({ status: 'active' })
aw.resetListCalls()
@@ -162,3 +188,258 @@ test('aboMemo: gleichzeitige Aufrufe desselben Kunden lesen nur einmal', async (
assert.deepEqual(antworten, [true, { ok: true }, true])
assert.equal(aw.listCalls('subscriptions').length, 1)
})
// ------------------------------------------------------------------------------------------ E1: ausgebaut ---
test('laeuft: Status-Tabelle (active, trialing, past_due laufen; canceled, unpaid, incomplete_expired nicht)', async () => {
for (const status of ['active', 'trialing', 'past_due']) {
wartung({ status })
assert.equal(await laeuft('c1'), true, status)
}
for (const status of ['canceled', 'unpaid', 'incomplete_expired']) {
wartung({ status })
assert.equal(await laeuft('c1'), false, status)
}
})
test('laeuft: Hosting aktiv und kein Dokument => false; vorgemerkte Kuendigung zaehlt noch als laufend', async () => {
assert.equal(await laeuft('c1'), false, 'Hosting aktiv (Standard von c1)')
assert.equal(await laeuft('c3'), false, 'kein Dokument')
wartung({ status: 'active', cancelAtPeriodEnd: true, cancelAt: inTagen(12) })
assert.equal(await laeuft('c1'), true)
})
test('laeuft: zwei Dokumente (altes canceled, neues active) => true, in beiden Anlegereihenfolgen', async () => {
aw.seed('subscriptions', subscriptionDoc('sub_alt', { ...wartungDoc, status: 'canceled', $createdAt: inTagen(-100) }))
wartung({ status: 'active', $createdAt: inTagen(-10) })
assert.equal(await laeuft('c1'), true, 'das aktive ist das neuere')
aw.seed('subscriptions', subscriptionDoc('sub_alt', { ...wartungDoc, status: 'canceled', $createdAt: inTagen(-1) }))
assert.equal(await laeuft('c1'), true, 'das beendete ist das neuere, das laufende gilt trotzdem')
})
test('Altbestand-Unterschied: Dokument ohne productKey => laeuft(id) true, laeuft(id, { altbestand: false }) false', async () => {
const { productKey: _entfernt, ...ohneSchluessel } = wartungDoc
aw.seed('subscriptions', subscriptionDoc('sub_h', ohneSchluessel))
assert.equal(await laeuft('c1'), true)
assert.equal(await laeuft('c1', { altbestand: true }), true)
assert.equal(await laeuft('c1', { altbestand: false }), false)
wartung({ productKey: null })
assert.equal(await laeuft('c1'), true, 'productKey null')
assert.equal(await laeuft('c1', { altbestand: false }), false, 'productKey null, strenge Regel')
// mit Schluessel 'wartung' urteilen beide Regeln gleich
wartung({ status: 'active' })
assert.equal(await laeuft('c1'), true)
assert.equal(await laeuft('c1', { altbestand: false }), true)
wartung({ status: 'canceled' })
assert.equal(await laeuft('c1', { altbestand: false }), false)
})
test('Lesefehler: laeuft, bestellbar und aboLage lehnen ab (GET 500 auf subscriptions), nie false oder { ok: false }', async () => {
wartung({ status: 'active' })
for (const frage of [() => laeuft('c1'), () => laeuft('c1', { altbestand: false }), () => bestellbar('c1'), () => aboLage('c1')]) {
aw.failNext({ collection: 'subscriptions', method: 'GET', status: 500 })
await assert.rejects(frage, /eingeplanter Fehler 500/)
}
// der Fehler war einmalig
assert.equal(await laeuft('c1'), true)
})
test('Lesefehler bei den Ereignissen: nur die grosszuegige Regel liest sie und lehnt dann ab, die strenge Regel nicht', async () => {
wartung({ status: 'active' })
aw.failNext({ collection: 'contractEvents', method: 'GET', status: 500 })
assert.equal(await laeuft('c1', { altbestand: false }), true, 'strenge Regel fragt keine Ereignisse')
await assert.rejects(() => laeuft('c1'), /eingeplanter Fehler 500/, 'laeuft liest sie und scheitert')
aw.failNext({ collection: 'contractEvents', method: 'GET', status: 500 })
await assert.rejects(() => bestellbar('c1'), /eingeplanter Fehler 500/)
aw.failNext({ collection: 'contractEvents', method: 'GET', status: 500 })
await assert.rejects(() => aboLage('c1'), /eingeplanter Fehler 500/)
assert.deepEqual(await aboLage('c1'), { laeuft: true, art: 'laeuft', endedAt: null })
})
test('bestellbar: Tabelle der Gruende', async () => {
const faelle = [
['active ohne Kuendigung', { status: 'active' }, { ok: true }],
['active mit cancelAtPeriodEnd', { status: 'active', cancelAtPeriodEnd: true }, { ok: false, grund: 'gekuendigt' }],
['active mit cancelAt in der Zukunft', { status: 'active', cancelAt: inTagen(30) }, { ok: false, grund: 'gekuendigt' }],
['trialing', { status: 'trialing' }, { ok: false, grund: 'nicht_aktiv' }],
['past_due', { status: 'past_due' }, { ok: false, grund: 'nicht_aktiv' }],
['trialing mit Kuendigung', { status: 'trialing', cancelAtPeriodEnd: true }, { ok: false, grund: 'gekuendigt' }],
['past_due mit Kuendigung', { status: 'past_due', cancelAt: inTagen(5) }, { ok: false, grund: 'gekuendigt' }],
['canceled', { status: 'canceled' }, { ok: false, grund: 'kein_abo' }],
]
for (const [name, patch, erwartet] of faelle) {
wartung(patch)
assert.deepEqual(await bestellbar('c1'), erwartet, name)
}
wartung({ productKey: 'hosting' })
assert.deepEqual(await bestellbar('c1'), { ok: false, grund: 'kein_abo' }, 'Hosting')
assert.deepEqual(await bestellbar('c3'), { ok: false, grund: 'kein_abo' }, 'kein Abo')
})
test('aboLage: art laeuft (endedAt null, auch bei vorgemerkter Kuendigung mit canceledAt)', async () => {
wartung({ status: 'active' })
assert.deepEqual(await aboLage('c1'), { laeuft: true, art: 'laeuft', endedAt: null })
wartung({ status: 'past_due', cancelAtPeriodEnd: true, canceledAt: inTagen(-2) })
assert.deepEqual(await aboLage('c1'), { laeuft: true, art: 'laeuft', endedAt: null })
})
test('aboLage: art hosting bei laufendem Hosting-Abo, ohne Ereignis-Abfrage', async () => {
// Standard von c1 und c2: Hosting aktiv
const n = await ereignisAnfragen(async () => {
assert.deepEqual(await aboLage('c1'), { laeuft: false, art: 'hosting', endedAt: null })
assert.deepEqual(await aboLage('c2'), { laeuft: false, art: 'hosting', endedAt: null })
})
assert.equal(n, 0, 'keine Ereignisse gelesen')
assert.equal(await ereignisAnfragen(() => laeuft('c1')), 0)
assert.equal(await ereignisAnfragen(() => bestellbar('c1')), 0)
})
test('aboLage: art beendet mit endedAt (oder canceledAt, oder null), ohne Ereignis-Abfrage', async () => {
const ende = inTagen(-3)
const gekuendigtAm = inTagen(-20)
const n = await ereignisAnfragen(async () => {
wartung({ status: 'canceled', endedAt: ende, canceledAt: gekuendigtAm })
assert.deepEqual(await aboLage('c1'), { laeuft: false, art: 'beendet', endedAt: ende }, 'endedAt vor canceledAt')
wartung({ status: 'canceled', endedAt: null, canceledAt: gekuendigtAm })
assert.deepEqual(await aboLage('c1'), { laeuft: false, art: 'beendet', endedAt: gekuendigtAm }, 'nur canceledAt')
wartung({ status: 'unpaid', endedAt: null, canceledAt: null })
assert.deepEqual(await aboLage('c1'), { laeuft: false, art: 'beendet', endedAt: null }, 'beides leer')
assert.deepEqual(await aboLage('c3'), { laeuft: false, art: 'beendet', endedAt: null }, 'kein Abo')
wartung({ productKey: 'hosting', status: 'canceled', endedAt: ende })
assert.deepEqual(await aboLage('c1'), { laeuft: false, art: 'beendet', endedAt: ende }, 'beendetes Hosting-Abo')
})
assert.equal(n, 0, 'keine Ereignisse gelesen')
})
test('aboLage: hat ein Kunde Hosting aktiv und Wartung beendet, gilt das laufende Hosting-Abo', async () => {
aw.seed('subscriptions', subscriptionDoc('sub_alt', { ...wartungDoc, status: 'canceled', endedAt: inTagen(-30) }))
aw.seed('subscriptions', subscriptionDoc('sub_h', { ...hosting2Doc, customerId: 'c1' }))
assert.deepEqual(await aboLage('c1'), { laeuft: false, art: 'hosting', endedAt: null })
})
test('aboLage: Wechsel-Widerruf => laeuft false, art wechsel_widerrufen; ein neueres erledigtes wechsel-Ereignis hebt es auf', async () => {
wartung({ status: 'active' })
wechsel('widerrufen')
assert.deepEqual(await aboLage('c1'), { laeuft: false, art: 'wechsel_widerrufen', endedAt: null })
wechsel('erledigt')
assert.deepEqual(await aboLage('c1'), { laeuft: true, art: 'laeuft', endedAt: null }, 'das juengste wechsel-Ereignis zaehlt')
})
test('aboLage: nur das juengste wechsel-Ereignis DIESES Abos zaehlt (andere Typen, andere Abos, offene Wechsel)', async () => {
wartung({ status: 'active' })
wechsel('erledigt')
wechsel('widerrufen')
assert.equal((await aboLage('c1')).art, 'wechsel_widerrufen', 'juengstes widerrufen, aelteres erledigt')
ereignis('abschluss', 'widerrufen')
ereignis('speicher', 'widerrufen')
wechsel('widerrufen', { subscriptionId: 'sub_anderes' })
wechsel('erledigt', { subscriptionId: 'sub_anderes' })
assert.equal((await aboLage('c1')).art, 'wechsel_widerrufen', 'juengere Ereignisse anderer Typen und anderer Abos aendern nichts')
wechsel('offen')
assert.deepEqual(await aboLage('c1'), { laeuft: true, art: 'laeuft', endedAt: null }, 'ein offener Wechsel ist kein Widerruf')
})
test('aboLage: Ereignisse anderer Kunden zaehlen nicht', async () => {
wartung({ status: 'active' })
wechsel('widerrufen', { customerId: 'c2' })
assert.equal((await aboLage('c1')).art, 'laeuft')
})
test('Wechsel-Regel in allen Praedikaten: laeuft false, bestellbar kein_abo, aboLage wechsel_widerrufen; die strenge Regel bleibt true und fragt keine Ereignisse', async () => {
wartung({ status: 'active' })
wechsel('widerrufen')
assert.equal(await laeuft('c1'), false)
assert.equal(await laeuft('c1', { altbestand: true }), false)
assert.deepEqual(await bestellbar('c1'), { ok: false, grund: 'kein_abo' })
assert.deepEqual(await aboLage('c1'), { laeuft: false, art: 'wechsel_widerrufen', endedAt: null })
const memo = aboMemo()
assert.equal(await memo.laeuft('c1'), false)
assert.deepEqual(await memo.bestellbar('c1'), { ok: false, grund: 'kein_abo' })
assert.deepEqual(await memo.lage('c1'), { laeuft: false, art: 'wechsel_widerrufen', endedAt: null })
let strenge
const n = await ereignisAnfragen(async () => {
strenge = await laeuft('c1', { altbestand: false })
})
assert.equal(strenge, true, 'Analyse bleibt bei ihrer heutigen Regel')
assert.equal(n, 0, 'strenge Regel fragt keine Ereignisse')
})
test('Wechsel-Regel: Kuendigung und Status spielen keine Rolle, solange der Spiegel Wartung sagt', async () => {
for (const status of ['active', 'trialing', 'past_due']) {
wartung({ status, cancelAtPeriodEnd: true })
wechsel('widerrufen')
assert.equal(await laeuft('c1'), false, status)
assert.deepEqual(await bestellbar('c1'), { ok: false, grund: 'kein_abo' }, status)
}
})
test('Ereignisse werden nur gelesen, wenn der Spiegel "Wartung laeuft" sagt - und dann einmal je Frage', async () => {
wartung({ status: 'active' })
assert.equal(await ereignisAnfragen(() => laeuft('c1')), 1)
assert.equal(await ereignisAnfragen(() => bestellbar('c1')), 1)
assert.equal(await ereignisAnfragen(() => aboLage('c1')), 1)
assert.equal(await ereignisAnfragen(() => laeuft('c1', { altbestand: false })), 0)
})
test('aboMemo: drei Aufrufe fuer denselben Kunden => eine Abfrage auf subscriptions (und eine auf contractEvents)', async () => {
wartung({ status: 'active' })
aw.resetListCalls()
const memo = aboMemo()
assert.equal(await memo.laeuft('c1'), true)
assert.deepEqual(await memo.bestellbar('c1'), { ok: true })
assert.deepEqual(await memo.lage('c1'), { laeuft: true, art: 'laeuft', endedAt: null })
assert.equal(aw.listCalls('subscriptions').length, 1)
assert.equal(aw.listCalls('contractEvents').length, 1)
// zweiter Kunde: eine weitere Abfrage
assert.equal(await memo.laeuft('c3'), false)
assert.deepEqual(await memo.lage('c3'), { laeuft: false, art: 'beendet', endedAt: null })
assert.equal(aw.listCalls('subscriptions').length, 2)
// ein neuer aboMemo() liest wieder
assert.equal(await aboMemo().laeuft('c1'), true)
assert.equal(aw.listCalls('subscriptions').length, 3)
})
test('aboMemo: lage liefert je Aufruf ein neues Objekt (Aendern wirkt nicht auf den Speicher)', async () => {
wartung({ status: 'active' })
const memo = aboMemo()
const erste = await memo.lage('c1')
erste.laeuft = false
erste.art = 'beendet'
assert.deepEqual(await memo.lage('c1'), { laeuft: true, art: 'laeuft', endedAt: null })
assert.equal(await memo.laeuft('c1'), true)
})
test('aboMemo: eine Ablehnung bleibt fuer diesen Lauf stehen (alle drei Fragen, keine zusaetzliche Anfrage); ein neuer Memo liest wieder', async () => {
wartung({ status: 'active' })
const memo = aboMemo()
aw.resetListCalls()
aw.failNext({ collection: 'subscriptions', method: 'GET', status: 500 })
await assert.rejects(() => memo.laeuft('c1'), /eingeplanter Fehler 500/)
await assert.rejects(() => memo.bestellbar('c1'), /eingeplanter Fehler 500/, 'bestellbar wirft, nie ok:false')
await assert.rejects(() => memo.lage('c1'), /eingeplanter Fehler 500/)
await assert.rejects(() => memo.laeuft('c1'), /eingeplanter Fehler 500/)
assert.equal(aw.listCalls('subscriptions').length, 1, 'nach der Ablehnung wird im selben Memo nicht neu gelesen')
// andere Kunden im selben Memo sind nicht betroffen, ein neuer Memo liest den Kunden neu
assert.equal(await memo.laeuft('c3'), false)
assert.equal(await aboMemo().laeuft('c1'), true)
})
test('aboMemo: auch ein Lesefehler bei den Ereignissen bleibt im Memo stehen', async () => {
wartung({ status: 'active' })
const memo = aboMemo()
aw.resetListCalls()
aw.failNext({ collection: 'contractEvents', method: 'GET', status: 500 })
await assert.rejects(() => memo.lage('c1'), /eingeplanter Fehler 500/)
await assert.rejects(() => memo.bestellbar('c1'), /eingeplanter Fehler 500/)
assert.equal(aw.listCalls('subscriptions').length, 1)
assert.equal(aw.listCalls('contractEvents').length, 1)
})
+14
View File
@@ -784,3 +784,17 @@ test('Abgleich: stopSite wirft => Dokument bleibt unverändert aktiv', async ()
assert.ok(!after.offSince)
assert.equal(removalTopics('F5').length, 0)
})
test('Altbestand ohne productKey: Analyse bleibt streng (kein_abo), aboStatus.laeuft grosszuegig', async () => {
const { laeuft } = await import('../server/services/aboStatus.js')
const { productKey: _entfernt, ...ohneSchluessel } = wartungDoc
aw.seed('subscriptions', { ...ohneSchluessel, $id: 'sub_w_alt', stripeSubscriptionId: 'sub_w_alt', customerId: 'c9' })
aw.seed('customers', { $id: 'c9', name: 'Kunde Neun', email: 'neun@example.invalid' })
aw.seed('websiteProjects', { $id: 'p9', customerId: 'c9', projectName: 'Altbestand', subdomain: 'altbestand', isPublic: true })
assert.equal(await laeuft('c9'), true, 'grosszuegige Regel: ohne productKey gilt als Wartung')
assert.equal(await laeuft('c9', { altbestand: false }), false, 'strenge Regel der Analyse')
assert.deepEqual(await an.analyseEligibility('c9', 'p9'), { ok: false, reason: 'kein_abo' })
await assert.rejects(an.activateAnalyse({ customerId: 'c9', projectId: 'p9', ...cust() }), { status: 403 })
assert.equal(puts.length, 0)
})
+29 -1
View File
@@ -13,6 +13,7 @@ const aw = await startAppwriteMock({
portalFeatures: schemaKeys('portalFeatures'),
subscriptions: schemaKeys('subscriptions'),
termineConfig: schemaKeys('termineConfig'),
contractEvents: schemaKeys('contractEvents'),
},
})
after(() => aw.close())
@@ -84,7 +85,7 @@ const STUMM = (t) => {
}
beforeEach(async () => {
for (const col of ['portalFeatures', 'workorders', 'termineConfig', 'subscriptions', 'websiteProjects']) {
for (const col of ['portalFeatures', 'workorders', 'termineConfig', 'subscriptions', 'websiteProjects', 'contractEvents']) {
for (const id of aw.docs(col).keys()) await deleteDocument(col, id)
}
seedFixtures(aw)
@@ -213,6 +214,33 @@ test('Termine: Abo beendet (canceled) => bleibt setup', async (t) => {
assert.equal(mails.sent.length, 0)
})
test('Termine: Wechsel-Widerruf (Spiegel Wartung active, juengstes wechsel-Ereignis widerrufen) => bleibt setup, keine Mail; ein neueres erledigtes Ereignis gibt frei', async (t) => {
STUMM(t)
const wechsel = (nr, status) =>
aw.seed('contractEvents', {
$id: `ev_w${nr}`,
$createdAt: new Date(Date.UTC(2026, 8, 1, 0, 0, nr)).toISOString(),
customerId: 'c1',
subscriptionId: 'sub_h',
type: 'wechsel',
productKey: 'wartung',
details: '{}',
status,
})
const id = einrichtung('termine', { einstellung: true })
wechsel(1, 'widerrufen')
assert.deepEqual(await sf.runActivation({ now: JETZT }), [])
assert.equal(zustand(id), 'setup')
assert.equal(feld(id, 'enabled'), false)
assert.equal(wartetEintraege(id).length, 1)
assert.equal(mails.sent.length, 0)
wechsel(2, 'erledigt')
assert.deepEqual((await sf.runActivation({ now: JETZT })).map((d) => d.$id), [id])
assert.equal(zustand(id), 'active')
assert.equal(k16('Dein Terminplaner ist bereit').length, 1)
})
test('Termine: keine Einstellung => bleibt setup (mit Notiz), mit gespeicherter Einstellung aktiviert der naechste Lauf', async (t) => {
STUMM(t)
const id = einrichtung('termine')
+25 -1
View File
@@ -18,6 +18,7 @@ const aw = await startAppwriteMock({
subscriptions: schemaKeys('subscriptions'),
featureConsents: schemaKeys('featureConsents'),
termineConfig: schemaKeys('termineConfig'),
contractEvents: schemaKeys('contractEvents'),
},
})
const st = await startFakeStripe()
@@ -44,7 +45,7 @@ const wartung = (patch = {}) => aw.seed('subscriptions', subscriptionDoc('sub_h'
// Jeder Test beginnt sauber: Standard-Abos (c1 Wartung), Pilot fuer alle, keine Haken-Ueberschreibungen, keine Dokumente
beforeEach(async () => {
for (const col of ['subscriptions', 'portalFeatures', 'featureConsents', 'termineConfig']) {
for (const col of ['subscriptions', 'portalFeatures', 'featureConsents', 'termineConfig', 'contractEvents']) {
for (const id of aw.docs(col).keys()) await deleteDocument(col, id)
}
seedFixtures(aw)
@@ -404,6 +405,29 @@ test('Abo-Gruende: kein Abo (c3, c2 mit Hosting), Kuendigung, offene Zahlung', a
assert.notEqual(ok.body?.grund, 'nicht_aktiv')
})
test('Wechsel-Regel (E1): Spiegel Wartung active, aber juengstes wechsel-Ereignis widerrufen => 409 kein_abo ohne Spur; ein neueres erledigtes hebt es auf', async () => {
T.bestellpruefung = async () => []
const wechsel = (nr, status) =>
aw.seed('contractEvents', {
$id: `ev_w${nr}`,
$createdAt: new Date(Date.UTC(2026, 8, 1, 0, 0, nr)).toISOString(),
customerId: 'c1',
subscriptionId: 'sub_h',
type: 'wechsel',
productKey: 'wartung',
details: '{}',
status,
})
wechsel(1, 'widerrufen')
aboAbgelehnt(await checkout('termine', bodyT()), 'kein_abo')
assert.equal(aw.docs('portalFeatures').size, 0, 'kein Dokument entstanden')
wechsel(2, 'erledigt')
const ok = await checkout('termine', bodyT())
assert.notEqual(ok.body?.code, 'kein_abo')
assert.notEqual(ok.body?.grund, 'nicht_aktiv')
})
test('Abo-Lesefehler: 500 ohne Rohtext, fail-closed ohne Spur', async () => {
T.bestellpruefung = async () => []
aw.failNext({ collection: 'subscriptions', status: 500, message: 'GEHEIMER-ROHTEXT interner Fehler' })