From 1e72faf97c1c9c5d8b6d6502d2a91c1b938070e9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 15:21:53 +0000 Subject: [PATCH] Finanzen: Portal-Client mit deutschen Fehlertexten fuer Proxy-Fehler und abgelaufene Anmeldung Der Anfrage-Kern liegt jetzt in financeRequest.js (rein, fetch und createJWT werden injiziert, unter Node testbar). 502/503/504 ohne JSON-Antwort und Netzfehler melden "Das Kundenportal ist gerade nicht erreichbar."; scheitert createJWT, kommt "Anmeldung abgelaufen. Bitte neu anmelden." (status 401). Meldungen des Portals bleiben. Co-Authored-By: Claude Sonnet 5.5 --- src/lib/financeApi.js | 38 +-------- src/lib/financeRequest.js | 67 +++++++++++++++ tests/unit/financeRequest.test.mjs | 127 +++++++++++++++++++++++++++++ 3 files changed, 197 insertions(+), 35 deletions(-) create mode 100644 src/lib/financeRequest.js create mode 100644 tests/unit/financeRequest.test.mjs diff --git a/src/lib/financeApi.js b/src/lib/financeApi.js index d8d6f13..d74ae1e 100644 --- a/src/lib/financeApi.js +++ b/src/lib/financeApi.js @@ -1,47 +1,15 @@ import { account } from './appwrite' -import { createJwtCache, fetchWithRetry } from './serverpulsAuth' +import { createFinanceRequest } from './financeRequest' /** * Client fuer die Finanzen-API des Kundenportals (/api/admin/finanzen). - * Authentifiziert per Appwrite-JWT (Bearer); alle Betraege in Cent. + * Authentifiziert per Appwrite-JWT (Bearer); alle Betraege in Cent. Anfrage-Kern und Fehlertexte: financeRequest.js. */ const PROJECT_ADMIN_URL = import.meta.env.VITE_PROJECT_ADMIN_URL || 'https://project.webklar.com' const BASE = `${PROJECT_ADMIN_URL}/api/admin/finanzen` -// JWT 10 Min. gecacht (Appwrite begrenzt das Erzeugen pro Nutzer) -const jwtCache = createJwtCache({ createJWT: () => account.createJWT() }) - -// Netzwerkfehler (fetch wirft) bekommen eine deutsche Meldung mit status 0 -async function portalFetch(url, init) { - try { - return await fetch(url, init) - } catch { - const err = new Error('Das Kundenportal ist gerade nicht erreichbar.') - err.status = 0 - throw err - } -} - -async function request(path, { method = 'GET', body } = {}) { - const res = await fetchWithRetry(jwtCache, (jwt) => - portalFetch(`${BASE}${path}`, { - method, - headers: { Authorization: `Bearer ${jwt}`, 'Content-Type': 'application/json' }, - body: body === undefined ? undefined : JSON.stringify(body), - }) - ) - const data = await res.json().catch(() => ({})) - if (!res.ok) { - const err = new Error(data.error || `API-Fehler ${res.status}`) - err.status = res.status - // Nach dem Festschreiben (502) liegt die Rechnung schon als "offen" vor - if (data.invoiceId) err.invoiceId = data.invoiceId - if (data.invoiceNumber) err.invoiceNumber = data.invoiceNumber - throw err - } - return data -} +const request = createFinanceRequest({ baseUrl: BASE, createJWT: () => account.createJWT() }) const enc = encodeURIComponent diff --git a/src/lib/financeRequest.js b/src/lib/financeRequest.js new file mode 100644 index 0000000..6e660fe --- /dev/null +++ b/src/lib/financeRequest.js @@ -0,0 +1,67 @@ +import { createJwtCache, fetchWithRetry } from './serverpulsAuth' + +// Anfrage-Kern der Finanzen-API (Portal). Rein (ohne Appwrite-Import, fetch und createJWT werden injiziert), damit die +// Fehlertexte unter Node testbar sind; financeApi.js verdrahtet ihn mit Appwrite. + +export const PORTAL_DOWN_TEXT = 'Das Kundenportal ist gerade nicht erreichbar.' +export const SESSION_EXPIRED_TEXT = 'Anmeldung abgelaufen. Bitte neu anmelden.' + +const statusError = (message, status) => Object.assign(new Error(message), { status }) + +/** + * request(path, { method, body }) -> geparstes JSON oder ein Error mit `status` (und bei 502 nach dem Festschreiben + * `invoiceId` / `invoiceNumber`). Meldungen: + * - fetch wirft (Netz, Portal aus): „Das Kundenportal ist gerade nicht erreichbar.“ (status 0) + * - 502/503/504 ohne JSON-Antwort (Proxy-Fehlerseite): dieselbe Meldung (status = HTTP-Status) + * - createJWT scheitert (Sitzung abgelaufen): „Anmeldung abgelaufen. Bitte neu anmelden.“ (status 401) + * - sonst die Meldung des Portals (`error`), ohne sie „API-Fehler “ + * Das JWT wird 10 Minuten gecacht; bei 401 einmal mit frischem JWT wiederholt. + */ +export function createFinanceRequest({ baseUrl, createJWT, fetchImpl = (...args) => globalThis.fetch(...args), now }) { + // JWT 10 Min. gecacht (Appwrite begrenzt das Erzeugen pro Nutzer) + const jwtCache = createJwtCache({ + createJWT: async () => { + try { + return await createJWT() + } catch { + throw statusError(SESSION_EXPIRED_TEXT, 401) + } + }, + ...(now ? { now } : {}), + }) + + // Netzwerkfehler (fetch wirft) bekommen eine deutsche Meldung mit status 0 + async function portalFetch(url, init) { + try { + return await fetchImpl(url, init) + } catch { + throw statusError(PORTAL_DOWN_TEXT, 0) + } + } + + return async function request(path, { method = 'GET', body } = {}) { + const res = await fetchWithRetry(jwtCache, (jwt) => + portalFetch(`${baseUrl}${path}`, { + method, + headers: { Authorization: `Bearer ${jwt}`, 'Content-Type': 'application/json' }, + body: body === undefined ? undefined : JSON.stringify(body), + }) + ) + let data = null + try { + data = await res.json() + } catch { + data = null // keine JSON-Antwort (Fehlerseite des Proxys, leerer Body) + } + if (!res.ok) { + const gatewayDown = data === null && (res.status === 502 || res.status === 503 || res.status === 504) + const info = data && typeof data === 'object' ? data : {} + const err = statusError(gatewayDown ? PORTAL_DOWN_TEXT : info.error || `API-Fehler ${res.status}`, res.status) + // Nach dem Festschreiben (502) liegt die Rechnung schon als "offen" vor + if (info.invoiceId) err.invoiceId = info.invoiceId + if (info.invoiceNumber) err.invoiceNumber = info.invoiceNumber + throw err + } + return data ?? {} + } +} diff --git a/tests/unit/financeRequest.test.mjs b/tests/unit/financeRequest.test.mjs new file mode 100644 index 0000000..21bb7b5 --- /dev/null +++ b/tests/unit/financeRequest.test.mjs @@ -0,0 +1,127 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { loadModule } from './load.mjs' + +const { createFinanceRequest, PORTAL_DOWN_TEXT, SESSION_EXPIRED_TEXT } = await loadModule('src/lib/financeRequest.js') + +const BASE = 'https://portal.example.invalid/api/admin/finanzen' + +/** Antwort wie fetch: { ok, status, json() }; body === undefined -> kein JSON (json() wirft). */ +const reply = (status, body) => ({ + ok: status >= 200 && status < 300, + status, + json: async () => { + if (body === undefined) throw new SyntaxError('Unexpected token < in JSON') + return body + }, +}) + +function client({ responses = [], createJWT, fetchImpl } = {}) { + const calls = [] + const queue = [...responses] + const api = createFinanceRequest({ + baseUrl: BASE, + createJWT: createJWT || (async () => ({ jwt: 'jwt-1' })), + fetchImpl: + fetchImpl || + (async (url, init) => { + calls.push({ url, init }) + return queue.shift() || reply(200, {}) + }), + }) + return { api, calls } +} + +const fails = async (promise) => promise.then(() => assert.fail('hätte scheitern müssen'), (err) => err) + +test('Erfolg: JSON zurück, Bearer-JWT, JSON-Body nur wenn gesetzt', async () => { + const { api, calls } = client({ responses: [reply(200, { ok: 1 }), reply(200, { id: 'x' })] }) + assert.deepEqual(await api('/uebersicht'), { ok: 1 }) + assert.equal(calls[0].url, `${BASE}/uebersicht`) + assert.equal(calls[0].init.method, 'GET') + assert.equal(calls[0].init.headers.Authorization, 'Bearer jwt-1') + assert.equal(calls[0].init.body, undefined) + assert.deepEqual(await api('/rechnungen', { method: 'POST', body: { a: 1 } }), { id: 'x' }) + assert.equal(calls[1].init.body, '{"a":1}') +}) + +test('Netzwerkfehler (fetch wirft): „Das Kundenportal ist gerade nicht erreichbar.“ mit status 0', async () => { + const { api } = client({ fetchImpl: async () => { throw new TypeError('Failed to fetch') } }) + const err = await fails(api('/uebersicht')) + assert.equal(PORTAL_DOWN_TEXT, 'Das Kundenportal ist gerade nicht erreichbar.') + assert.equal(err.message, PORTAL_DOWN_TEXT) + assert.equal(err.status, 0) +}) + +test('502/503/504 ohne JSON (Proxy-Fehlerseite): dieselbe Meldung, Status bleibt', async () => { + for (const status of [502, 503, 504]) { + const { api } = client({ responses: [reply(status, undefined)] }) + const err = await fails(api('/uebersicht')) + assert.equal(err.message, PORTAL_DOWN_TEXT, String(status)) + assert.equal(err.status, status) + } +}) + +test('502/503/504 mit JSON-Fehler des Portals: die Meldung des Portals und die Rechnungsdaten bleiben erhalten', async () => { + const { api } = client({ + responses: [reply(502, { error: 'Stripe ist gerade nicht erreichbar.', invoiceId: 'in_9', invoiceNumber: 'WK-0009' })], + }) + const err = await fails(api('/rechnungen/in_9/senden', { method: 'POST' })) + assert.equal(err.message, 'Stripe ist gerade nicht erreichbar.') + assert.equal(err.status, 502) + assert.equal(err.invoiceId, 'in_9') + assert.equal(err.invoiceNumber, 'WK-0009') + // JSON ohne error-Feld: Statusmeldung, nicht die Portal-Meldung + const { api: api2 } = client({ responses: [reply(503, {})] }) + assert.equal((await fails(api2('/x'))).message, 'API-Fehler 503') +}) + +test('andere Fehler ohne JSON bleiben „API-Fehler “; mit JSON die Meldung des Portals', async () => { + const { api } = client({ responses: [reply(500, undefined), reply(403, { error: 'Admin-Berechtigung erforderlich' }), reply(409, null)] }) + const e500 = await fails(api('/x')) + assert.equal(e500.message, 'API-Fehler 500') + assert.equal(e500.status, 500) + const e403 = await fails(api('/x')) + assert.equal(e403.message, 'Admin-Berechtigung erforderlich') + assert.equal(e403.status, 403) + // JSON-null: keine Ausnahme beim Lesen des Fehlers + assert.equal((await fails(api('/x'))).message, 'API-Fehler 409') +}) + +test('Erfolg ohne lesbaren Body liefert ein leeres Objekt', async () => { + const { api } = client({ responses: [reply(200, undefined)] }) + assert.deepEqual(await api('/x'), {}) +}) + +test('createJWT scheitert: „Anmeldung abgelaufen. Bitte neu anmelden.“ (status 401), kein Aufruf am Portal', async () => { + const { api, calls } = client({ createJWT: async () => { throw new Error('User (role: guests) missing scope (account)') } }) + const err = await fails(api('/uebersicht')) + assert.equal(SESSION_EXPIRED_TEXT, 'Anmeldung abgelaufen. Bitte neu anmelden.') + assert.equal(err.message, SESSION_EXPIRED_TEXT) + assert.equal(err.status, 401) + assert.equal(calls.length, 0) +}) + +test('401 des Portals: einmal mit frischem JWT wiederholen; scheitert dann createJWT, kommt die Anmeldemeldung', async () => { + let n = 0 + const { api, calls } = client({ + createJWT: async () => { + n += 1 + if (n === 1) return { jwt: 'old' } + throw new Error('Session expired') + }, + responses: [reply(401, { error: 'abgelaufen' })], + }) + const err = await fails(api('/uebersicht')) + assert.equal(calls.length, 1) + assert.equal(calls[0].init.headers.Authorization, 'Bearer old') + assert.equal(err.message, SESSION_EXPIRED_TEXT) + + let m = 0 + const retry = client({ + createJWT: async () => ({ jwt: `jwt-${++m}` }), + responses: [reply(401, { error: 'abgelaufen' }), reply(200, { ok: true })], + }) + assert.deepEqual(await retry.api('/uebersicht'), { ok: true }) + assert.deepEqual(retry.calls.map((c) => c.init.headers.Authorization), ['Bearer jwt-1', 'Bearer jwt-2']) +})