import { test } from 'node:test' import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import http from 'node:http' import { execFile } from 'node:child_process' import { schemaKeys } from './support/schema.js' test('bankTransactions: Attribute der Spec sind im Setup-Skript angelegt', () => { const keys = schemaKeys('bankTransactions') for (const key of ['amountCents', 'category', 'matchedInvoiceId', 'entryReference', 'bookingDate', 'syncedAt']) { assert.ok(keys.includes(key), `${key} fehlt`) } }) test('bankConnections: Status und Warnstufen', () => { const keys = schemaKeys('bankConnections') for (const key of ['status', 'sessionId', 'accountUid', 'validUntil', 'warnedThresholds', 'balanceBookedCents']) { assert.ok(keys.includes(key), `${key} fehlt`) } }) test('financeSettings und financeFixedCosts sind angelegt', () => { assert.ok(schemaKeys('financeSettings').includes('taxReservePercent')) assert.ok(schemaKeys('financeFixedCosts').includes('counterpartyMatch')) }) test('subscriptions wird weiter aus dem Abo-Setup gelesen', () => { assert.ok(schemaKeys('subscriptions').includes('stripeSubscriptionId')) }) test('unbekannte Collection scheitert mit klarer Meldung', () => { assert.throws(() => schemaKeys('gibtEsNicht'), /Collection gibtEsNicht fehlt im Setup-Skript/) }) test('bankTransactions: Indexe auf bookingDate (Sortierung) und status (Vormerkungen suchen)', () => { const src = readFileSync(new URL('../scripts/setup-finanzen.mjs', import.meta.url), 'utf8') const start = src.indexOf("'bankTransactions',") const block = src.slice(start, src.indexOf("// Einstellungen", start)) assert.match(block, /key: 'idx_booking', type: 'key', attributes: \['bookingDate'\]/) assert.match(block, /key: 'idx_status', type: 'key', attributes: \['status'\]/) }) // ---------- Uebernahme einer Sitzung (--session, --account, --valid-until, --iban) ---------- const SCRIPT = new URL('../scripts/setup-finanzen.mjs', import.meta.url).pathname /** * Appwrite-Stub fuer das Setup-Skript: Collections, Attribute (sofort "available") und Indexe werden angenommen, * Dokumente gespeichert (GET 404 bis zum Anlegen, POST legt an, PATCH mischt). requests haelt alle Aufrufe. */ async function startSetupStub() { const requests = [] const attributes = new Map() const docs = new Map() const server = http.createServer(async (req, res) => { let raw = '' for await (const chunk of req) raw += chunk const body = raw ? JSON.parse(raw) : null const { pathname } = new URL(req.url, 'http://127.0.0.1') requests.push({ method: req.method, path: pathname, body }) const reply = (status, payload = {}) => { res.writeHead(status, { 'Content-Type': 'application/json' }) res.end(JSON.stringify(payload)) } let m if ((m = pathname.match(/\/collections\/([^/]+)\/attributes\/\w+$/)) && req.method === 'POST') { attributes.set(m[1], [...(attributes.get(m[1]) || []), { key: body.key, status: 'available' }]) return reply(202) } if ((m = pathname.match(/\/collections\/([^/]+)\/attributes$/)) && req.method === 'GET') return reply(200, { attributes: attributes.get(m[1]) || [] }) if ((m = pathname.match(/\/collections\/([^/]+)\/documents(?:\/([^/]+))?$/))) { const key = `${m[1]}/${m[2] ?? body?.documentId}` if (req.method === 'GET') return docs.has(key) ? reply(200, docs.get(key)) : reply(404, { message: 'Document not found' }) if (req.method === 'POST') return docs.set(key, { ...body.data }) && reply(201) if (req.method === 'PATCH') return docs.set(key, { ...docs.get(key), ...body.data }) && reply(200) } return reply(200) }) await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) return { url: `http://127.0.0.1:${server.address().port}/v1`, requests, docs, close: () => new Promise((resolve) => server.close(resolve)) } } /** Das Setup-Skript mit Argumenten gegen den Stub laufen lassen -> { code, stdout, stderr }. */ function runSetup(stub, args) { return new Promise((resolve) => { execFile( process.execPath, [SCRIPT, ...args], { env: { PATH: process.env.PATH, APPWRITE_ENDPOINT: stub.url, APPWRITE_API_KEY: 'test-key' }, timeout: 30_000 }, (error, stdout, stderr) => resolve({ code: error ? error.code : 0, stdout, stderr }) ) }) } const TAKEOVER = ['--session', 'sess-geheim-4711', '--account', 'konto-4711'] const connectionDoc = (stub) => stub.docs.get('bankConnections/finom') test('Setup --valid-until: nur ein echtes Datum oder ein voller ISO-Zeitpunkt mit Zeitzone, alles andere bricht ab (M5)', async () => { const stub = await startSetupStub() try { for (const bad of ['morgen', '1', '2027', '2027-3-29', '2027-13-01', '2027-02-30', '2027-03-29 00:00:00', '2027-03-29T00:00:00', '2027-03-29T25:00:00Z', '29.03.2027', 'Mar 29 2027', '2027-03-29T00:00:00+25:00']) { const result = await runSetup(stub, [...TAKEOVER, '--valid-until', bad]) assert.equal(result.code, 1, `${bad}: Exit-Code ${result.code}`) assert.match(result.stderr, /--valid-until ist kein gueltiger ISO-Zeitpunkt/, bad) assert.ok(!result.stdout.includes('Fertig'), bad) assert.ok(!result.stderr.includes('sess-geheim-4711')) } // vor jedem Aufruf der API: nichts angelegt, nichts uebernommen assert.equal(stub.requests.length, 0) } finally { await stub.close() } }) test('Setup --valid-until: Datum und voller ISO-Zeitpunkt werden uebernommen und als UTC gespeichert (M5)', async () => { const stub = await startSetupStub() try { const cases = [ ['2027-03-29', '2027-03-29T00:00:00.000Z'], ['2027-03-29T00:00:00Z', '2027-03-29T00:00:00.000Z'], ['2027-03-29T02:00:00.000+02:00', '2027-03-29T00:00:00.000Z'], ['2027-03-29T00:00:00.000000+00:00', '2027-03-29T00:00:00.000Z'], ] for (const [input, stored] of cases) { const result = await runSetup(stub, [...TAKEOVER, '--valid-until', input]) assert.equal(result.code, 0, `${input}: ${result.stderr}`) assert.equal(connectionDoc(stub).validUntil, stored, input) assert.equal(connectionDoc(stub).status, 'aktiv') assert.ok(!result.stdout.includes('sess-geheim-4711'), 'die Sitzungs-ID wird nie ausgegeben') } } finally { await stub.close() } }) test('Setup --iban: wird gespeichert, ibanMasked sind die ersten und letzten vier Zeichen (M5)', async () => { const stub = await startSetupStub() try { const result = await runSetup(stub, [...TAKEOVER, '--valid-until', '2027-03-29', '--iban', 'de00 0000 0000 0000 0000 93']) assert.equal(result.code, 0, result.stderr) const doc = connectionDoc(stub) assert.equal(doc.iban, 'DE00000000000000000093') assert.equal(doc.ibanMasked, 'DE00…0093') assert.equal(doc.sessionId, 'sess-geheim-4711') assert.equal(doc.accountUid, 'konto-4711') // beide Felder passen in die Schemagroessen (40) assert.ok(doc.iban.length <= 40 && doc.ibanMasked.length <= 40) assert.ok(!result.stdout.includes('DE0000'), 'die IBAN wird nicht ausgegeben') // ohne --iban bleibt eine gespeicherte IBAN stehen const again = await runSetup(stub, [...TAKEOVER, '--valid-until', '2027-04-01']) assert.equal(again.code, 0, again.stderr) assert.equal(connectionDoc(stub).iban, 'DE00000000000000000093') assert.equal(connectionDoc(stub).validUntil, '2027-04-01T00:00:00.000Z') } finally { await stub.close() } }) test('Setup --iban: keine IBAN -> Abbruch vor jedem API-Aufruf; --iban allein ist keine Uebernahme (M5)', async () => { const stub = await startSetupStub() try { for (const bad of ['', 'abc', 'DE12', '1234567890123456', 'DE00 0000 0000 0000 0000 93 ZZZZZZZZZZZZZZZZZZZZZZZZZZ']) { const result = await runSetup(stub, [...TAKEOVER, '--valid-until', '2027-03-29', '--iban', bad]) assert.equal(result.code, 1, `"${bad}"`) assert.match(result.stderr, /--iban ist keine gueltige IBAN/) } const alone = await runSetup(stub, ['--iban', 'DE00000000000000000093']) assert.equal(alone.code, 1) assert.match(alone.stderr, /--session, --account und --valid-until zusammen/) assert.equal(stub.requests.length, 0) } finally { await stub.close() } }) test('Setup: ohne Uebernahme-Argumente werden nur die Collections angelegt, kein Dokument', async () => { const stub = await startSetupStub() try { const result = await runSetup(stub, []) assert.equal(result.code, 0, result.stderr) assert.equal(stub.docs.size, 0) assert.ok(stub.requests.some((r) => r.method === 'POST' && r.path.endsWith('/collections'))) } finally { await stub.close() } }) test('.env.example nennt ENABLE_BANKING_APP_ID und ENABLE_BANKING_KEY_PATH auskommentiert und ohne Wert (M16)', () => { const lines = readFileSync(new URL('../.env.example', import.meta.url), 'utf8').split('\n') for (const name of ['ENABLE_BANKING_APP_ID', 'ENABLE_BANKING_KEY_PATH']) { assert.ok(lines.includes(`# ${name}=`), `${name} fehlt oder hat einen Wert`) assert.ok(!lines.some((line) => line.startsWith(`${name}=`)), `${name} darf nicht gesetzt sein`) } })