diff --git a/scripts/kauf-bestand-pruefen.mjs b/scripts/kauf-bestand-pruefen.mjs index 1b075a2..5051000 100644 --- a/scripts/kauf-bestand-pruefen.mjs +++ b/scripts/kauf-bestand-pruefen.mjs @@ -9,6 +9,9 @@ * Nur zur Information, kein Hindernis: der Widerruf funktioniert nach dem Umschalten weiter. * Die Frist ist eine Näherung: Anker ist consentAt am Dokument, sonst orderedAt (der Nachweis in featureConsents wird nicht gelesen). * Ohne Datum gilt die Frist vorsichtshalber als laufend. + * Behalten-Sitzung Öffnungszeiten "Behalten": starteBehalten ändert den Zustand nicht, es trägt nur behaltenSessionId ein. Eine + * solche Zahlung ist also weder 'ordered' noch gekauft. Eigene Zeile mit den Dokument-IDs, kein Exit 1: eine + * abgelaufene Sitzungs-ID bleibt stehen, bis aktiviert oder gebucht wird. Vor dem Umschalten von Hand klären. * * Ausgabe: nur Zahlen und Dokument-IDs, nie Namen, Adressen oder Beträge. * node scripts/kauf-bestand-pruefen.mjs @@ -38,6 +41,13 @@ function fristStand(doc, jetzt) { return { laeuft: withdrawalDeadline(anker).endsAt > jetzt, ohneDatum: false } } +/** Wie unlockedByVon (siteFeatures.js), hier nachgebaut, damit das Skript nicht Stripe und Mailer lädt. */ +const freigeschaltetVon = (doc) => doc.unlockedBy || (doc.unlockedByPurchase ? 'kauf' : 'team') + +/** Behalten-Sitzung eingetragen und noch nicht gebucht (gebucht = stripeSessionId ist diese Sitzung und das Dokument ein Kauf). */ +const behaltenEingetragen = (doc) => + Boolean(doc.behaltenSessionId) && !(doc.stripeSessionId === doc.behaltenSessionId && freigeschaltetVon(doc) === 'kauf') + const liste = (ids) => (ids.length ? ` (${ids.join(', ')})` : '') /** @@ -47,6 +57,7 @@ const liste = (ids) => (ids.length ? ` (${ids.join(', ')})` : '') export async function pruefeKaeufe({ log = console.log, jetzt = new Date() } = {}) { const docs = await listAllDocuments(config.collections.portalFeatures) const offeneBestellungen = docs.filter((d) => d.state === 'ordered').map((d) => d.$id) + const behalten = docs.filter(behaltenEingetragen).map((d) => d.$id) const gekauft = docs.filter(istGekauft) const fristLaeuft = [] const ohneDatum = [] @@ -59,6 +70,8 @@ export async function pruefeKaeufe({ log = console.log, jetzt = new Date() } = { log(`Funktionsdokumente: ${docs.length}, davon gekauft: ${gekauft.length}`) log(`Offene Bestellungen: ${offeneBestellungen.length}${liste(offeneBestellungen)}`) log(`Fristen laufen: ${fristLaeuft.length}${liste(fristLaeuft)}${ohneDatum.length ? ` - davon ohne Datum: ${ohneDatum.length}` : ''}`) + log(`Behalten-Sitzung eingetragen: ${behalten.length}${liste(behalten)}`) + if (behalten.length) log('Hinweis: bei einer Behalten-Sitzung kann die Zahlung noch laufen oder die Sitzung abgelaufen sein; kein Hindernis, vor dem Umschalten aber klären.') if (offeneBestellungen.length) log('Offene Bestellungen vorhanden: erst die Zahlung abwarten (oder klären), dann umschalten.') return { offeneBestellungen, fristLaeuft, gekauftGesamt: gekauft.length } } diff --git a/test/kaufBestandPruefen.test.js b/test/kaufBestandPruefen.test.js index cad4c28..ca44ef2 100644 --- a/test/kaufBestandPruefen.test.js +++ b/test/kaufBestandPruefen.test.js @@ -1,5 +1,7 @@ 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' @@ -13,11 +15,15 @@ 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') } @@ -72,6 +78,7 @@ test('ohne Käufe: alles leer', async () => { 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 () => { @@ -79,3 +86,64 @@ test('Lesefehler: der Fehler geht nach oben, es wird nichts als leer gemeldet', 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() +})