import { test, after } from 'node:test' import assert from 'node:assert/strict' import { execFile } from 'node:child_process' import { tmpdir } from 'node:os' import { startAppwriteMock } from './support/appwriteMock.js' import { useTestEnv } from './support/testEnv.js' import { schemaKeys } from './support/schema.js' // P10 Task 5 (scripts/kauf-bestand-pruefen.mjs): vor dem Umschalten zaehlen, wie viele gekaufte Funktionen noch auf Zahlung warten // und wie viele Widerrufsfristen noch laufen. Nur lesend; die Ausgabe nennt nur Dokument-IDs. Nur Nachbauten, nie Produktion. const aw = await startAppwriteMock({ schema: { portalFeatures: schemaKeys('portalFeatures') } }) after(() => aw.close()) useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' }) const { pruefeKaeufe } = await import('../scripts/kauf-bestand-pruefen.mjs') const { deleteDocument } = await import('../server/services/appwriteAdmin.js') const jetzt = new Date('2026-11-10T10:00:00Z') const SKRIPT = new URL('../scripts/kauf-bestand-pruefen.mjs', import.meta.url).pathname const feature = (id, over = {}) => aw.seed('portalFeatures', { $id: id, customerId: 'c1', projectId: `p_${id}`, featureKey: 'termine', state: 'active', enabled: true, siteKey: `k_${id}`, ...over, }) const leereFeatures = async () => { for (const id of aw.docs('portalFeatures').keys()) await deleteDocument('portalFeatures', id) } const sammler = () => { const zeilen = [] return { log: (z) => zeilen.push(z), text: () => zeilen.join('\n') } } feature('f_offen', { state: 'ordered', purchaseStatus: 'ordered' }) // wartet auf Zahlung feature('f_frist', { state: 'setup', purchaseStatus: 'paid', unlockedByPurchase: true, orderedAt: '2026-11-08T09:00:00.000Z' }) // Frist laeuft feature('f_alt', { state: 'active', purchaseStatus: 'paid', unlockedByPurchase: true, orderedAt: '2026-09-20T09:00:00.000Z' }) // Frist vorbei feature('f_abo', { state: 'active', unlockedBy: 'abo' }) // ueber das Abo, nicht gekauft feature('f_aus', { state: 'off' }) // nicht gekauft test('zählt offene Bestellungen und laufende Fristen, schreibt nichts, nennt nur IDs', async () => { const vorher = JSON.stringify([...aw.docs('portalFeatures')]) const out = sammler() const e = await pruefeKaeufe({ log: out.log, jetzt }) assert.deepEqual([e.offeneBestellungen, e.fristLaeuft, e.gekauftGesamt], [['f_offen'], ['f_frist'], 2]) assert.equal(JSON.stringify([...aw.docs('portalFeatures')]), vorher) assert.match(out.text(), /Offene Bestellungen: 1/) assert.match(out.text(), /Fristen laufen: 1/) assert.match(out.text(), /f_offen/) assert.match(out.text(), /f_frist/) assert.doesNotMatch(out.text(), /k_f_|p_f_|c1/, 'keine Website-Schlüssel, Projekte oder Kunden in der Ausgabe') }) test('Frist: die Zustimmung am Dokument (consentAt) geht orderedAt vor, ohne beide gilt die Frist vorsichtig als laufend', async () => { feature('f_consent', { state: 'active', unlockedByPurchase: true, orderedAt: '2026-09-01T09:00:00.000Z', consentAt: '2026-11-09T09:00:00.000Z' }) feature('f_ohne_datum', { state: 'disabled', unlockedByPurchase: true }) const out = sammler() const e = await pruefeKaeufe({ log: out.log, jetzt }) assert.deepEqual(e.fristLaeuft.sort(), ['f_consent', 'f_frist', 'f_ohne_datum']) assert.equal(e.gekauftGesamt, 4) assert.match(out.text(), /Fristen laufen: 3/) assert.match(out.text(), /ohne Datum: 1/) for (const id of ['f_consent', 'f_ohne_datum']) await deleteDocument('portalFeatures', id) }) test('Fristende: am letzten Tag bis 24 Uhr läuft sie noch, danach nicht mehr', async () => { // Vertrag Mo 2026-11-02 => letzter Tag Mo 2026-11-16 (14 Tage), Ende 23:59:59 deutscher Zeit feature('f_grenze', { state: 'active', unlockedByPurchase: true, orderedAt: '2026-11-02T09:00:00.000Z' }) const spaet = (await pruefeKaeufe({ log: () => {}, jetzt: new Date('2026-11-16T22:00:00Z') })).fristLaeuft const danach = (await pruefeKaeufe({ log: () => {}, jetzt: new Date('2026-11-16T23:00:01Z') })).fristLaeuft assert.ok(spaet.includes('f_grenze')) assert.ok(!danach.includes('f_grenze')) await deleteDocument('portalFeatures', 'f_grenze') }) test('ohne Käufe: alles leer', async () => { // aw.docs() liefert eine Kopie; geleert wird deshalb ueber die Schnittstelle for (const id of aw.docs('portalFeatures').keys()) await deleteDocument('portalFeatures', id) assert.equal(aw.docs('portalFeatures').size, 0) const out = sammler() assert.deepEqual(await pruefeKaeufe({ log: out.log, jetzt }), { offeneBestellungen: [], fristLaeuft: [], gekauftGesamt: 0 }) assert.match(out.text(), /Offene Bestellungen: 0/) assert.match(out.text(), /Fristen laufen: 0/) assert.match(out.text(), /Behalten-Sitzung eingetragen: 0/) }) test('Lesefehler: der Fehler geht nach oben, es wird nichts als leer gemeldet', async () => { feature('f_x', { state: 'ordered' }) aw.failNext({ collection: 'portalFeatures', method: 'GET', status: 500 }) await assert.rejects(() => pruefeKaeufe({ log: () => {}, jetzt })) }) test('Öffnungszeiten "Behalten": eingetragene Sitzung wird als eigene Zeile gemeldet, ohne Sitzungs-ID, ohne Einfluss auf offene Bestellungen', async () => { await leereFeatures() // f_x aus dem Lesefehler-Test steht noch da // starteBehalten ändert den Zustand nicht, es schreibt nur behaltenSessionId ans Dokument (disabled/abo_ende oder active) feature('f_behalten', { state: 'disabled', unlockedBy: 'abo', disabledReason: 'abo_ende', behaltenSessionId: 'cs_offen_1' }) feature('f_behalten_aktiv', { state: 'active', unlockedBy: 'abo', behaltenSessionId: 'cs_offen_2' }) // schon gebucht (bucheBehalten setzt behaltenSessionId zurück; steht sie doch noch, zählt die gebuchte Sitzung nicht) und ohne Sitzung feature('f_behalten_gebucht', { state: 'active', unlockedBy: 'kauf', unlockedByPurchase: true, stripeSessionId: 'cs_fertig', behaltenSessionId: 'cs_fertig', orderedAt: '2026-09-20T09:00:00.000Z', }) feature('f_ohne_sitzung', { state: 'active', unlockedBy: 'abo', behaltenSessionId: '' }) const out = sammler() const e = await pruefeKaeufe({ log: out.log, jetzt }) assert.deepEqual(e.offeneBestellungen, [], 'eine abgelaufene Sitzungs-ID bleibt stehen: kein Grund für Exit 1') assert.match(out.text(), /Behalten-Sitzung eingetragen: 2 \(f_behalten, f_behalten_aktiv\)/) assert.doesNotMatch(out.text(), /cs_/, 'keine Stripe-Sitzungen in der Ausgabe') for (const id of ['f_behalten', 'f_behalten_aktiv', 'f_behalten_gebucht', 'f_ohne_sitzung']) await deleteDocument('portalFeatures', id) }) // ---------- Einstieg: Exit-Code ist das Tor vor dem Umschalten ---------- /** Das Skript als eigener Prozess gegen den Nachbau (sauberes env, cwd ausserhalb des Repos: keine .env) -> { code, stdout, stderr }. */ function lauf() { const env = { PATH: process.env.PATH, APPWRITE_ENDPOINT: aw.url, APPWRITE_API_KEY: 'test-key' } assert.equal(new URL(env.APPWRITE_ENDPOINT).hostname, '127.0.0.1', 'nur der Nachbau') return new Promise((resolve) => { execFile( process.execPath, ['--max-old-space-size=512', SKRIPT], { env, cwd: tmpdir(), timeout: 30_000 }, (error, stdout, stderr) => resolve({ code: error ? error.code : 0, stdout, stderr }) ) }) } test('Einstieg: Exit 1 bei offener Bestellung, Exit 0 ohne (laufende Fristen sind kein Hindernis), Exit 1 bei Lesefehler', async () => { await leereFeatures() feature('f_offen', { state: 'ordered', purchaseStatus: 'ordered' }) const offen = await lauf() assert.equal(offen.code, 1, offen.stderr) assert.match(offen.stdout, /Offene Bestellungen: 1 \(f_offen\)/) assert.match(offen.stdout, /erst die Zahlung abwarten/) // keine offene Bestellung, aber eine laufende Frist (gerade gekauft) und eine Behalten-Sitzung: nur Information await deleteDocument('portalFeatures', 'f_offen') feature('f_frist_jetzt', { state: 'setup', purchaseStatus: 'paid', unlockedByPurchase: true, orderedAt: new Date().toISOString() }) feature('f_behalten', { state: 'disabled', unlockedBy: 'abo', behaltenSessionId: 'cs_offen' }) const frei = await lauf() assert.equal(frei.code, 0, frei.stderr) assert.match(frei.stdout, /Offene Bestellungen: 0/) assert.match(frei.stdout, /Fristen laufen: 1 \(f_frist_jetzt\)/) assert.match(frei.stdout, /Behalten-Sitzung eingetragen: 1 \(f_behalten\)/) // Lesefehler: Exit 1, Fehler auf stderr, nie ein leeres "alles gut" auf stdout aw.failNext({ collection: 'portalFeatures', method: 'GET', status: 500 }) const kaputt = await lauf() assert.equal(kaputt.code, 1) assert.match(kaputt.stderr, /^Fehler: /) assert.doesNotMatch(kaputt.stdout, /Offene Bestellungen/) await leereFeatures() })