import { test, after, beforeEach, mock } 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' // Nachbauten starten und die Umgebung setzen, BEVOR config.js geladen wird (Muster wie financeAssign.test.js) const aw = await startAppwriteMock({ schema: { financeSettings: schemaKeys('financeSettings'), financeFixedCosts: schemaKeys('financeFixedCosts'), bankTransactions: schemaKeys('bankTransactions'), }, }) const st = await startFakeStripe() useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) const { config } = await import('../server/config.js') const { seedFixtures } = await import('./support/fixtures.js') const { startTestApp, get, post, rawGet } = await import('./support/testApp.js') const { default: financeRoutes } = await import('../server/routes/admin/finanzen.js') const settings = await import('../server/services/finance/settings.js') const { createProjectInvoice, invalidateFinanceCache, resetInvoiceCreateGuardForTests } = await import('../server/services/finance/stripeFinance.js') const { deleteDocument } = await import('../server/services/appwriteAdmin.js') const app = await startTestApp({ '/api/admin/finanzen': financeRoutes }) after(async () => { mock.restoreAll() await Promise.all([app.close(), aw.close(), st.close()]) }) seedFixtures(aw) const admin = { headers: { Authorization: 'Bearer jwt-admin' } } const staff = { headers: { Authorization: 'Bearer jwt-staff' } } const kunde = { headers: { Authorization: 'Bearer jwt-kunde' } } const base = '/api/admin/finanzen' const SETTINGS = () => config.collections.financeSettings const COSTS = () => config.collections.financeFixedCosts const TX = () => config.collections.bankTransactions const storedSettings = () => aw.docs(SETTINGS()).get('main') const storedCost = (id) => aw.docs(COSTS()).get(id) const callsTo = (method, path) => st.calls.filter((c) => c.method === method && (path instanceof RegExp ? path.test(c.path) : c.path === path)) const DEFAULTS = { minBufferCents: 0, taxReservePercent: 30, previousYearRevenueCents: null, estPrepaymentCents: 0, bankDetailsText: '', steuernummer: '' } const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/ async function reset() { mock.restoreAll() invalidateFinanceCache() resetInvoiceCreateGuardForTests() st.calls.length = 0 st.invoices.clear() st.customers.clear() st.customers.set('cus_c1', { id: 'cus_c1', object: 'customer', name: 'Kunde Eins', email: 'k@example.invalid', metadata: { webklarCustomerId: 'c1' } }) for (const collection of [SETTINGS(), COSTS(), TX()]) { for (const id of aw.docs(collection).keys()) await deleteDocument(collection, id) } } beforeEach(reset) const request = async (method, path, { body, headers } = {}) => { const res = await fetch(app.url + path, { method, headers: { ...(body !== undefined ? { 'content-type': 'application/json' } : {}), ...(headers || {}) }, body: body === undefined ? undefined : JSON.stringify(body), }) const text = await res.text() return { status: res.status, body: text ? JSON.parse(text) : null } } const patchSettings = (body, auth = admin) => request('PATCH', `${base}/einstellungen`, { body, ...auth }) const getSettings = () => get(`${base}/einstellungen`, admin) const postCost = (body) => post(`${base}/fixkosten`, body, admin) const patchCost = (id, body) => request('PATCH', `${base}/fixkosten/${id}`, { body, ...admin }) const deleteCost = (id) => request('DELETE', `${base}/fixkosten/${id}`, admin) const PORKBUN = { name: 'Porkbun', amountCents: 449, interval: 'monat', nextDate: '2026-10-29', counterpartyMatch: 'PORKBUN' } const seedCost = (id, over = {}) => aw.seed(COSTS(), { $id: id, name: 'Porkbun', amountCents: 449, interval: 'monat', nextDate: '2026-10-29', counterpartyMatch: 'porkbun', active: true, ...over }) // ---------- Zugriff ---------- test('alle Routen: ohne JWT 401, Mitarbeiter ohne Admin-Label und Kunden 403', async () => { seedCost('fk1') const calls = [ ['GET', `${base}/einstellungen`, undefined], ['PATCH', `${base}/einstellungen`, { taxReservePercent: 10 }], ['GET', `${base}/fixkosten`, undefined], ['POST', `${base}/fixkosten`, PORKBUN], ['PATCH', `${base}/fixkosten/fk1`, { active: false }], ['DELETE', `${base}/fixkosten/fk1`, undefined], ] for (const [method, path, body] of calls) { assert.equal((await request(method, path, { body })).status, 401, `${method} ${path} ohne JWT`) assert.equal((await request(method, path, { body, ...staff })).status, 403, `${method} ${path} jwt-staff`) assert.equal((await request(method, path, { body, ...kunde })).status, 403, `${method} ${path} jwt-kunde`) } // nichts wurde veraendert assert.equal(storedSettings(), undefined) assert.equal(storedCost('fk1').active, true) assert.equal(aw.docs(COSTS()).size, 1) }) // ---------- Einstellungen lesen ---------- test('GET /einstellungen ohne Dokument: Standardwerte, es wird nichts angelegt', async () => { assert.deepEqual(await getSettings(), { einstellungen: DEFAULTS }) assert.equal(aw.docs(SETTINGS()).size, 0) assert.deepEqual(await settings.getSettings(), DEFAULTS) assert.equal(aw.docs(SETTINGS()).size, 0) }) test('GET /einstellungen: gespeicherte Werte ueberschreiben die Standardwerte, fehlende und null-Felder fallen auf sie zurueck', async () => { // wie das Setup-Skript es anlegt: ohne previousYearRevenueCents und bankDetailsText aw.seed(SETTINGS(), { $id: 'main', taxReservePercent: 25, minBufferCents: 50000, estPrepaymentCents: 0 }) assert.deepEqual((await getSettings()).einstellungen, { ...DEFAULTS, taxReservePercent: 25, minBufferCents: 50000 }) // Appwrite liefert fuer nie gesetzte Felder null aw.seed(SETTINGS(), { $id: 'main', taxReservePercent: null, minBufferCents: null, estPrepaymentCents: 12300, previousYearRevenueCents: null, bankDetailsText: null }) assert.deepEqual((await getSettings()).einstellungen, { ...DEFAULTS, estPrepaymentCents: 12300 }) aw.seed(SETTINGS(), { $id: 'main', previousYearRevenueCents: 0, bankDetailsText: 'IBAN DE00' }) assert.deepEqual((await getSettings()).einstellungen, { ...DEFAULTS, previousYearRevenueCents: 0, bankDetailsText: 'IBAN DE00' }) }) test('GET /einstellungen: nur die sechs Felder, nie Appwrite-Metafelder', async () => { aw.seed(SETTINGS(), { $id: 'main', taxReservePercent: 25, secret: 'intern' }) const { einstellungen } = await getSettings() assert.deepEqual(Object.keys(einstellungen).sort(), Object.keys(DEFAULTS).sort()) }) // ---------- Einstellungen aendern ---------- test('PATCH { taxReservePercent: 25 } ohne Dokument: 25, das Dokument main wird angelegt (mit den Standardwerten)', async () => { const res = await patchSettings({ taxReservePercent: 25 }) assert.equal(res.status, 200, JSON.stringify(res.body)) assert.deepEqual(res.body, { einstellungen: { ...DEFAULTS, taxReservePercent: 25 } }) const doc = storedSettings() assert.ok(doc, 'financeSettings/main fehlt') assert.equal(doc.taxReservePercent, 25) assert.equal(doc.minBufferCents, 0) assert.equal(doc.estPrepaymentCents, 0) assert.deepEqual((await getSettings()).einstellungen, { ...DEFAULTS, taxReservePercent: 25 }) }) test('PATCH aendert nur die genannten Felder, auch mehrere auf einmal', async () => { aw.seed(SETTINGS(), { $id: 'main', taxReservePercent: 30, minBufferCents: 1000, estPrepaymentCents: 500, bankDetailsText: 'IBAN DE00' }) const res = await patchSettings({ minBufferCents: 250000, bankDetailsText: 'IBAN DE11\nBIC TESTDEFF', estPrepaymentCents: 0 }) assert.equal(res.status, 200, JSON.stringify(res.body)) assert.deepEqual(res.body.einstellungen, { ...DEFAULTS, taxReservePercent: 30, minBufferCents: 250000, estPrepaymentCents: 0, bankDetailsText: 'IBAN DE11\nBIC TESTDEFF', }) assert.equal(storedSettings().taxReservePercent, 30) assert.equal(aw.docs(SETTINGS()).size, 1) }) test('PATCH: Grenzwerte sind erlaubt (0 und 60 Prozent, Puffer 100.000.000, 500 Zeichen Bankdaten, Vorjahr 0)', async () => { for (const patch of [ { taxReservePercent: 0 }, { taxReservePercent: 60 }, { minBufferCents: 0 }, { minBufferCents: 100_000_000 }, { previousYearRevenueCents: 0 }, { estPrepaymentCents: 0 }, { bankDetailsText: 'x'.repeat(500) }, { bankDetailsText: '' }, ]) { const res = await patchSettings(patch) assert.equal(res.status, 200, `${JSON.stringify(patch).slice(0, 60)}: ${JSON.stringify(res.body).slice(0, 120)}`) const [key, value] = Object.entries(patch)[0] assert.equal(res.body.einstellungen[key], value) } }) test('PATCH: der Bankverbindungstext wird ohne Rand-Leerraum gespeichert', async () => { const res = await patchSettings({ bankDetailsText: ' IBAN DE00 0000\n ' }) assert.equal(res.status, 200) assert.equal(res.body.einstellungen.bankDetailsText, 'IBAN DE00 0000') assert.equal(storedSettings().bankDetailsText, 'IBAN DE00 0000') }) test('PATCH steuernummer: Vorgabe leer, getrimmt gespeichert; Buchstaben, Ziffern, Leerzeichen, / - und . bis 40 Zeichen, sonst 400', async () => { assert.equal((await getSettings()).einstellungen.steuernummer, '') const getrimmt = await patchSettings({ steuernummer: ' 12/345/67890 ' }) assert.equal(getrimmt.status, 200, JSON.stringify(getrimmt.body)) assert.equal(getrimmt.body.einstellungen.steuernummer, '12/345/67890') assert.equal(storedSettings().steuernummer, '12/345/67890') assert.equal((await getSettings()).einstellungen.steuernummer, '12/345/67890') for (const wert of ['DE123456789', 'de 123.456.789', '12 345 67890', '12-345-678', 'A'.repeat(40), ` ${'1'.repeat(40)} `, ' ', '']) { const res = await patchSettings({ steuernummer: wert }) assert.equal(res.status, 200, JSON.stringify(wert)) assert.equal(res.body.einstellungen.steuernummer, wert.trim()) assert.equal(storedSettings().steuernummer, wert.trim()) } assert.equal((await patchSettings({ steuernummer: 'DE123456789' })).status, 200) for (const wert of ['