From d568a3798d514790483bdfc239df75183a775f55 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 12:12:09 +0000 Subject: [PATCH] Abo-Baustein (Kernversion) und Platzhalter fuer die Bestellpruefung aboStatus.js: laeuft(customerId) (Wartungs-Abo, productKey || 'wartung', Status in ACTIVE_STATUSES), bestellbar(customerId) mit den Gruenden kein_abo, gekuendigt, nicht_aktiv in dieser Pruefreihenfolge und aboMemo() (je Aufruf und Kunde nur eine Lesung). Fail-closed: ein Lesefehler wirft, nie false oder ok:false. termine/fragebogen.js: Platzhalter bestellpruefung() mit der endgueltigen Signatur, liefert []; Block C ersetzt ihn. Co-Authored-By: Claude Sonnet 5.5 --- server/services/aboStatus.js | 57 +++++++++ server/services/termine/fragebogen.js | 11 ++ test/aboStatus.test.js | 164 ++++++++++++++++++++++++++ test/termineFragebogenStub.test.js | 26 ++++ 4 files changed, 258 insertions(+) create mode 100644 server/services/aboStatus.js create mode 100644 server/services/termine/fragebogen.js create mode 100644 test/aboStatus.test.js create mode 100644 test/termineFragebogenStub.test.js diff --git a/server/services/aboStatus.js b/server/services/aboStatus.js new file mode 100644 index 0000000..189d71b --- /dev/null +++ b/server/services/aboStatus.js @@ -0,0 +1,57 @@ +// KERNVERSION (Block B) – Block E baut aus +import { ACTIVE_STATUSES, getCurrentSubscription } from './stripeBilling.js' + +// Abo-Baustein fuer Funktionen, die ein laufendes Wartungs-Abo voraussetzen (Terminplaner; die Analyse zieht in +// Block E nach). Fail-closed: ein Lesefehler wirft, er wird nie zu false bzw. ok:false. Wer die Fehler abfaengt, +// entscheidet selbst, was dann gilt (Bestellung ablehnen, Kunde im Listenlauf ueberspringen, nie abschalten). + +// Wartungs-Abo: Altbestand ohne productKey gilt als Wartung (wie routes/abo.js productKeyOf), ein anderer +// Schluessel (hosting, mailStorage, unbekannt) nicht. Status wie im Portal: active, trialing, past_due. +function laeuftAus(sub) { + return Boolean(sub) && (sub.productKey || 'wartung') === 'wartung' && ACTIVE_STATUSES.has(sub.status) +} + +// Die Gruende gelten in dieser Reihenfolge fuer alle Bloecke: kein laufendes Wartungs-Abo => kein_abo; Kuendigung +// vorgemerkt (cancelAtPeriodEnd oder cancelAt, auch bei trialing und past_due) => gekuendigt; Status nicht active +// (trialing, past_due) => nicht_aktiv. +function bestellbarAus(sub) { + if (!laeuftAus(sub)) return { ok: false, grund: 'kein_abo' } + if (sub.cancelAtPeriodEnd || sub.cancelAt) return { ok: false, grund: 'gekuendigt' } + if (sub.status !== 'active') return { ok: false, grund: 'nicht_aktiv' } + return { ok: true } +} + +/** Hat der Kunde ein laufendes Wartungs-Abo? Wirft bei Lesefehler. */ +export async function laeuft(customerId) { + return laeuftAus(await getCurrentSubscription(customerId)) +} + +/** Darf der Kunde jetzt bestellen? { ok: true } oder { ok: false, grund: 'kein_abo' | 'gekuendigt' | 'nicht_aktiv' }. Wirft bei Lesefehler. */ +export async function bestellbar(customerId) { + return bestellbarAus(await getCurrentSubscription(customerId)) +} + +/** + * Zwischenspeicher fuer einen Lauf (eine Anfrage, ein Listenlauf): { laeuft, bestellbar } mit denselben Antworten + * wie oben, aber das Abo jedes Kunden wird je aboMemo()-Aufruf nur einmal gelesen (auch bei gleichzeitigen Fragen, + * und fuer laeuft und bestellbar gemeinsam). Ein Lesefehler wird nicht gemerkt: der naechste Aufruf liest neu. + */ +export function aboMemo() { + const gelesen = new Map() + const lade = (customerId) => { + if (!gelesen.has(customerId)) { + gelesen.set( + customerId, + getCurrentSubscription(customerId).catch((err) => { + gelesen.delete(customerId) + throw err + }) + ) + } + return gelesen.get(customerId) + } + return { + laeuft: async (customerId) => laeuftAus(await lade(customerId)), + bestellbar: async (customerId) => bestellbarAus(await lade(customerId)), + } +} diff --git a/server/services/termine/fragebogen.js b/server/services/termine/fragebogen.js new file mode 100644 index 0000000..7143f66 --- /dev/null +++ b/server/services/termine/fragebogen.js @@ -0,0 +1,11 @@ +// STUB (Block B) – ersetzt Task C2 (bestellpruefung); Task C1 legt in dieser Datei den Rest des Fragebogen-Dienstes an + +/** + * Bestellpruefung des Terminplaners: Liste der offenen Punkte `[{ schritt, text }]`, leer = bestellbar. + * Die Signatur ist endgueltig (Block C ersetzt nur den Rumpf): `mitAbo !== false` schaltet den Abo-Punkt (h) + * dazu, den nur die Vorschau (GET /api/termine/einstellungen) mit `abo` anfordert; der Checkout ruft mit + * `mitAbo: false` und ohne `abo`. Der Platzhalter meldet nichts offen. + */ +export function bestellpruefung({ einstellung, fragebogen, feature, abo, mitAbo = true } = {}) { + return [] +} diff --git a/test/aboStatus.test.js b/test/aboStatus.test.js new file mode 100644 index 0000000..70d157b --- /dev/null +++ b/test/aboStatus.test.js @@ -0,0 +1,164 @@ +import { test, after, beforeEach } from 'node:test' +import assert from 'node:assert/strict' +import { startAppwriteMock } from './support/appwriteMock.js' +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. +// 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 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 { deleteDocument } = await import('../server/services/appwriteAdmin.js') +const { laeuft, bestellbar, aboMemo } = await import('../server/services/aboStatus.js') + +const DAY_MS = 86400000 +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 +beforeEach(async () => { + for (const id of aw.docs('subscriptions').keys()) await deleteDocument('subscriptions', id) + seedFixtures(aw) +}) + +test('Wartungs-Abo active: laeuft true, bestellbar { ok: true } ohne Grund', async () => { + wartung({ status: 'active' }) + assert.equal(await laeuft('c1'), true) + assert.deepEqual(await bestellbar('c1'), { ok: true }) +}) + +test('Hosting-Abo (Standard von c1 und c2): laeuft false, bestellbar kein_abo', async () => { + for (const customerId of ['c1', 'c2']) { + assert.equal(await laeuft(customerId), false, customerId) + assert.deepEqual(await bestellbar(customerId), { ok: false, grund: 'kein_abo' }, customerId) + } +}) + +test('Dokument ohne productKey zaehlt als Wartungs-Abo (wie routes/abo.js productKeyOf), ein unbekannter Schluessel nicht', async () => { + const { productKey: _entfernt, ...ohneSchluessel } = wartungDoc + aw.seed('subscriptions', subscriptionDoc('sub_h', ohneSchluessel)) + assert.equal(await laeuft('c1'), true, 'Feld fehlt') + assert.deepEqual(await bestellbar('c1'), { ok: true }) + + wartung({ productKey: null }) + assert.equal(await laeuft('c1'), true, 'productKey null') + assert.deepEqual(await bestellbar('c1'), { ok: true }) + + wartung({ productKey: 'mailStorage' }) + assert.equal(await laeuft('c1'), false, 'anderes Produkt') + assert.deepEqual(await bestellbar('c1'), { ok: false, grund: 'kein_abo' }) +}) + +test('canceled: laeuft false, bestellbar kein_abo - auch mit vorgemerkter Kuendigung (kein Abo schlaegt gekuendigt)', async () => { + wartung({ status: 'canceled' }) + assert.equal(await laeuft('c1'), false) + assert.deepEqual(await bestellbar('c1'), { ok: false, grund: 'kein_abo' }) + + wartung({ status: 'canceled', cancelAtPeriodEnd: true, cancelAt: inTagen(-1) }) + assert.equal(await laeuft('c1'), false) + assert.deepEqual(await bestellbar('c1'), { ok: false, grund: 'kein_abo' }) +}) + +test('past_due und trialing: laeuft true, bestellbar nicht_aktiv', async () => { + for (const status of ['past_due', 'trialing']) { + wartung({ status }) + assert.equal(await laeuft('c1'), true, status) + assert.deepEqual(await bestellbar('c1'), { ok: false, grund: 'nicht_aktiv' }, status) + } +}) + +test('Kuendigung vorgemerkt (cancelAtPeriodEnd oder cancelAt): gekuendigt, vor nicht_aktiv und bei jedem laufenden Status', async () => { + for (const status of ['active', 'trialing', 'past_due']) { + wartung({ status, cancelAtPeriodEnd: true, cancelAt: null }) + assert.equal(await laeuft('c1'), true, `${status} laeuft`) + assert.deepEqual(await bestellbar('c1'), { ok: false, grund: 'gekuendigt' }, `${status} cancelAtPeriodEnd`) + + wartung({ status, cancelAtPeriodEnd: false, cancelAt: inTagen(12) }) + assert.equal(await laeuft('c1'), true, `${status} laeuft`) + assert.deepEqual(await bestellbar('c1'), { ok: false, grund: 'gekuendigt' }, `${status} cancelAt`) + } +}) + +test('kein Abo (c3), leere Kunden-ID: laeuft false, bestellbar kein_abo', async () => { + assert.equal(await laeuft('c3'), false) + assert.deepEqual(await bestellbar('c3'), { ok: false, grund: 'kein_abo' }) + assert.equal(await laeuft(''), false) + assert.deepEqual(await bestellbar(undefined), { ok: false, grund: 'kein_abo' }) +}) + +test('Lesefehler (500 auf subscriptions): laeuft und bestellbar werfen, nie false und nie ok:false', async () => { + wartung({ status: 'active' }) + aw.failNext({ collection: 'subscriptions', status: 500 }) + await assert.rejects(() => laeuft('c1'), /Nachbau: eingeplanter Fehler 500/) + + aw.failNext({ collection: 'subscriptions', status: 500 }) + await assert.rejects(() => bestellbar('c1'), /Nachbau: eingeplanter Fehler 500/) + + // der Fehler war einmalig: danach liest dieselbe Frage wieder normal + assert.equal(await laeuft('c1'), true) + assert.deepEqual(await bestellbar('c1'), { ok: true }) +}) + +test('aboMemo: zweiter Aufruf desselben Kunden kommt aus dem Speicher, ein frischer laeuft liest neu', async () => { + wartung({ status: 'active' }) + const memo = aboMemo() + assert.equal(await memo.laeuft('c1'), true) + + aw.failNext({ collection: 'subscriptions', status: 500 }) + assert.equal(await memo.laeuft('c1'), true, 'aus dem Speicher, das eingeplante Failure trifft ihn nicht') + await assert.rejects(() => laeuft('c1'), /eingeplanter Fehler 500/, 'ein frischer laeuft liest neu und trifft den Fehler') +}) + +test('aboMemo: bestellbar wird je Kunde gemerkt; laeuft und bestellbar teilen sich die Abo-Lesung desselben Kunden', async () => { + wartung({ status: 'past_due' }) + const memo = aboMemo() + assert.deepEqual(await memo.bestellbar('c1'), { ok: false, grund: 'nicht_aktiv' }) + + aw.failNext({ collection: 'subscriptions', status: 500 }) + assert.deepEqual(await memo.bestellbar('c1'), { ok: false, grund: 'nicht_aktiv' }, 'zweiter Aufruf aus dem Speicher') + assert.equal(await memo.laeuft('c1'), true, 'laeuft nutzt dieselbe gelesene Abo-Zeile') + await assert.rejects(() => bestellbar('c1'), /eingeplanter Fehler 500/, 'der eingeplante Fehler war noch frei') +}) + +test('aboMemo: ein anderer Kunde wird neu gelesen, ein anderer aboMemo() ebenfalls', async () => { + wartung({ status: 'active' }) + const memo = aboMemo() + assert.equal(await memo.laeuft('c1'), true) + + // 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') + + // anderer aboMemo() fuer denselben Kunden: neu lesen, der alte Speicher gilt nicht + aw.failNext({ collection: 'subscriptions', status: 500 }) + await assert.rejects(() => aboMemo().laeuft('c1'), /eingeplanter Fehler 500/) + 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() + const memo = aboMemo() + const antworten = await Promise.all([memo.laeuft('c1'), memo.bestellbar('c1'), memo.laeuft('c1')]) + assert.deepEqual(antworten, [true, { ok: true }, true]) + assert.equal(aw.listCalls('subscriptions').length, 1) +}) diff --git a/test/termineFragebogenStub.test.js b/test/termineFragebogenStub.test.js new file mode 100644 index 0000000..28fba2a --- /dev/null +++ b/test/termineFragebogenStub.test.js @@ -0,0 +1,26 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' + +// Platzhalter der Bestellpruefung (server/services/termine/fragebogen.js, Block B): bestellbar ist alles, bis +// Block C die echte Pruefung liefert. Block C loescht diese Datei zusammen mit dem Stub (Task C2). +const { bestellpruefung } = await import('../server/services/termine/fragebogen.js') + +test('Stub: bestellpruefung liefert [] fuer jede Eingabe, mit und ohne Abo-Punkt', () => { + assert.deepEqual(bestellpruefung({ einstellung: null, fragebogen: null, feature: { key: 'termine' } }), []) + assert.deepEqual(bestellpruefung({ einstellung: {}, fragebogen: {}, feature: {}, abo: { ok: false, grund: 'kein_abo' } }), []) + assert.deepEqual(bestellpruefung({ einstellung: {}, fragebogen: {}, feature: {}, mitAbo: false }), []) + assert.deepEqual(bestellpruefung({}), []) + assert.ok(Array.isArray(bestellpruefung({})), 'ein Array, kein Promise') +}) + +test('Stub: jeder Aufruf liefert ein neues Array (Aufrufer duerfen es veraendern)', () => { + const eins = bestellpruefung({}) + eins.push({ schritt: 'x', text: 'y' }) + assert.deepEqual(bestellpruefung({}), []) +}) + +test('Stub traegt die Marke im Kopfkommentar (Block C ersetzt die Datei, Abschlusspruefung Block B zaehlt sie)', async () => { + const quelle = await readFile(new URL('../server/services/termine/fragebogen.js', import.meta.url), 'utf8') + assert.match(quelle, /^\/\/ STUB \(Block B\) – ersetzt Task C/) +})