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 <noreply@anthropic.com>
This commit is contained in:
1 parent
e9cfae28ee
commit
1e72faf97c
3 files changed
+197
-35
No files matched your search
+3
-35
@@ -1,47 +1,15 @@
|
|||||||
import { account } from './appwrite'
|
import { account } from './appwrite'
|
||||||
import { createJwtCache, fetchWithRetry } from './serverpulsAuth'
|
import { createFinanceRequest } from './financeRequest'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Client fuer die Finanzen-API des Kundenportals (/api/admin/finanzen).
|
* 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 =
|
const PROJECT_ADMIN_URL =
|
||||||
import.meta.env.VITE_PROJECT_ADMIN_URL || 'https://project.webklar.com'
|
import.meta.env.VITE_PROJECT_ADMIN_URL || 'https://project.webklar.com'
|
||||||
const BASE = `${PROJECT_ADMIN_URL}/api/admin/finanzen`
|
const BASE = `${PROJECT_ADMIN_URL}/api/admin/finanzen`
|
||||||
|
|
||||||
// JWT 10 Min. gecacht (Appwrite begrenzt das Erzeugen pro Nutzer)
|
const request = createFinanceRequest({ baseUrl: BASE, createJWT: () => account.createJWT() })
|
||||||
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 enc = encodeURIComponent
|
const enc = encodeURIComponent
|
||||||
|
|
||||||
|
|||||||
@@ -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 <Status>“
|
||||||
|
* 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 ?? {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 <Status>“; 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'])
|
||||||
|
})
|
||||||
Reference in new issue
Block a user