diff --git a/src/lib/financeRequest.js b/src/lib/financeRequest.js index 6f09cf9..c8e6baf 100644 --- a/src/lib/financeRequest.js +++ b/src/lib/financeRequest.js @@ -9,13 +9,14 @@ 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 + * request(path, { method, body, as }) -> 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. + * Mit `as: 'blob'` liefert eine erfolgreiche Antwort den rohen Inhalt (Blob); Fehler kommen weiter als Meldung des Portals. */ export function createFinanceRequest({ baseUrl, createJWT, fetchImpl = (...args) => globalThis.fetch(...args), now }) { // JWT 10 Min. gecacht (Appwrite begrenzt das Erzeugen pro Nutzer) @@ -39,7 +40,7 @@ export function createFinanceRequest({ baseUrl, createJWT, fetchImpl = (...args) } } - return async function request(path, { method = 'GET', body } = {}) { + return async function request(path, { method = 'GET', body, as } = {}) { const res = await fetchWithRetry(jwtCache, (jwt) => portalFetch(`${baseUrl}${path}`, { method, @@ -47,6 +48,8 @@ export function createFinanceRequest({ baseUrl, createJWT, fetchImpl = (...args) body: body === undefined ? undefined : JSON.stringify(body), }) ) + // Dateien (z. B. das PDF einer Auftragsbestätigung) kommen roh; Fehlerantworten bleiben JSON mit Meldung + if (as === 'blob' && res.ok) return res.blob() let data = null try { data = await res.json() diff --git a/tests/unit/financeRequestBlob.test.mjs b/tests/unit/financeRequestBlob.test.mjs new file mode 100644 index 0000000..435dde8 --- /dev/null +++ b/tests/unit/financeRequestBlob.test.mjs @@ -0,0 +1,78 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { loadModule } from './load.mjs' + +const { createFinanceRequest, PORTAL_DOWN_TEXT } = await loadModule('src/lib/financeRequest.js') + +const BASE = 'https://portal.example.invalid/api/admin/finanzen' + +/** Antwort wie fetch: { ok, status, json(), blob() }; ohne `json` ist der Körper kein JSON (json() wirft). */ +const reply = (status, { json, blob } = {}) => ({ + ok: status >= 200 && status < 300, + status, + json: async () => { + if (json === undefined) throw new SyntaxError('Unexpected token % in JSON') + return json + }, + blob: async () => blob, +}) + +function client({ responses = [], createJWT } = {}) { + const calls = [] + const queue = [...responses] + const api = createFinanceRequest({ + baseUrl: BASE, + createJWT: createJWT || (async () => ({ jwt: 'jwt-1' })), + fetchImpl: async (url, init) => { + calls.push({ url, init }) + return queue.shift() || reply(200, { json: {} }) + }, + }) + return { api, calls } +} + +const fails = async (promise) => promise.then(() => assert.fail('hätte scheitern müssen'), (err) => err) +const pdf = () => new Blob(['%PDF-1.7 Probe'], { type: 'application/pdf' }) + +test('as blob: Erfolg liefert den rohen Inhalt als Blob, mit Bearer-JWT, ohne JSON zu lesen', async () => { + const { api, calls } = client({ + responses: [{ ...reply(200, { blob: pdf() }), json: async () => assert.fail('json() darf bei as blob nicht gelesen werden') }], + }) + const result = await api('/auftraege/a1/pdf', { as: 'blob' }) + assert.ok(result instanceof Blob) + assert.equal(result.type, 'application/pdf') + assert.equal(await result.text(), '%PDF-1.7 Probe') + assert.equal(calls[0].url, `${BASE}/auftraege/a1/pdf`) + assert.equal(calls[0].init.method, 'GET') + assert.equal(calls[0].init.headers.Authorization, 'Bearer jwt-1') +}) + +test('as blob: Fehler kommen weiter als Meldung des Portals mit Status (kein kaputtes PDF)', async () => { + const { api } = client({ responses: [reply(404, { json: { error: 'PDF nicht gefunden.' } })] }) + const err = await fails(api('/auftraege/a1/pdf', { as: 'blob' })) + assert.equal(err.message, 'PDF nicht gefunden.') + assert.equal(err.status, 404) + // Proxy-Fehlerseite ohne JSON: dieselbe Meldung wie bei JSON-Anfragen + const { api: api2 } = client({ responses: [reply(502)] }) + assert.equal((await fails(api2('/auftraege/a1/pdf', { as: 'blob' }))).message, PORTAL_DOWN_TEXT) +}) + +test('as blob: bei 401 einmal mit frischem JWT wiederholt, dann kommt der Blob', async () => { + let n = 0 + const datei = pdf() + const { api, calls } = client({ + createJWT: async () => ({ jwt: `jwt-${++n}` }), + responses: [reply(401, { json: { error: 'abgelaufen' } }), reply(200, { blob: datei })], + }) + assert.equal(await api('/auftraege/a1/pdf', { as: 'blob' }), datei) + assert.deepEqual(calls.map((c) => c.init.headers.Authorization), ['Bearer jwt-1', 'Bearer jwt-2']) +}) + +test('ohne as bleibt es JSON (der Blob wird nicht gelesen), ein unbekannter Wert verhält sich ebenso', async () => { + const { api } = client({ + responses: [{ ...reply(200, { json: { ok: 1 } }), blob: async () => assert.fail('blob() darf ohne as nicht gelesen werden') }], + }) + assert.deepEqual(await api('/x'), { ok: 1 }) + const { api: api2 } = client({ responses: [reply(200, { json: { ok: 2 } })] }) + assert.deepEqual(await api2('/x', { as: 'text' }), { ok: 2 }) +})