Workers P2: API-Client mit deutschen Fehlertexten
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
66fe0f38ef
commit
97fdfbfcf0
3 files changed
+269
No files matched your search
@@ -0,0 +1,109 @@
|
|||||||
|
// API-Client der Hustler-Seite (Dienst unter /api/workers, Übersicht §6.2).
|
||||||
|
// Rein: kein Appwrite-Import, getJwt und fetch werden injiziert (workersClient.js verdrahtet sie), damit die
|
||||||
|
// deutschen Fehlertexte unter Node testbar sind. Fehler sind Error mit deutscher `message` und `status`.
|
||||||
|
|
||||||
|
export const DOWN_TEXT = 'Der Hustler-Dienst ist gerade nicht erreichbar.'
|
||||||
|
export const BAD_REPLY_TEXT = 'Der Hustler-Dienst antwortet nicht richtig.'
|
||||||
|
export const SESSION_TEXT = 'Anmeldung abgelaufen. Bitte neu anmelden.'
|
||||||
|
export const FORBIDDEN_TEXT = 'Dafür fehlt dir die Berechtigung.'
|
||||||
|
|
||||||
|
// Pult-Status des Dienstes (Übersicht §4.11)
|
||||||
|
export const PULT_STATUS = Object.freeze(['offen', 'in_arbeit', 'gesendet', 'verworfen', 'veraltet', 'erledigt'])
|
||||||
|
// Erlaubte Aktionen am Pult-Eintrag; alles andere wird abgewiesen, bevor ein Pfad entsteht
|
||||||
|
const PULT_ACTIONS = Object.freeze(['claim', 'sent', 'discard', 'done', 'reply', 'approve-preview', 'paste'])
|
||||||
|
|
||||||
|
const BASE = '/api/workers'
|
||||||
|
const EVENTS_LIMIT = 200
|
||||||
|
const TRACE_LIMIT = 50
|
||||||
|
|
||||||
|
const statusError = (message, status) => Object.assign(new Error(message), { status })
|
||||||
|
|
||||||
|
// Query aus Paaren; leere Werte (undefined, null, '') fallen weg, der Rest wird kodiert
|
||||||
|
function query(pairs) {
|
||||||
|
const parts = []
|
||||||
|
for (const [key, value] of pairs) {
|
||||||
|
if (value === undefined || value === null || value === '') continue
|
||||||
|
parts.push(`${key}=${encodeURIComponent(value)}`)
|
||||||
|
}
|
||||||
|
return parts.length ? `?${parts.join('&')}` : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standard nur bei fehlendem Wert (nicht bei 0); danach auf 1..max geklemmt
|
||||||
|
function clampLimit(limit, max) {
|
||||||
|
const n = limit === undefined || limit === null ? NaN : Math.trunc(Number(limit))
|
||||||
|
if (!Number.isFinite(n)) return max
|
||||||
|
return Math.min(max, Math.max(1, n))
|
||||||
|
}
|
||||||
|
|
||||||
|
// status: ein Pult-Status, eine Komma-Liste oder ein Array; leer = 'offen'; jeder Eintrag (getrimmt) muss bekannt sein
|
||||||
|
function pultStatusList(status) {
|
||||||
|
const raw = Array.isArray(status) ? status : String(status ?? '').split(',')
|
||||||
|
const list = raw.map((s) => String(s).trim())
|
||||||
|
if (list.length === 0 || (list.length === 1 && list[0] === '')) return ['offen']
|
||||||
|
for (const s of list) {
|
||||||
|
if (!PULT_STATUS.includes(s)) throw statusError(`Unbekannter Pult-Status: ${s}`, 400)
|
||||||
|
}
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createWorkersApi({ getJwt, fetchImpl = (...args) => globalThis.fetch(...args) }) {
|
||||||
|
// fetch wirft (Netz, Dienst aus) ⇒ DOWN_TEXT mit status 0
|
||||||
|
async function send(path, method, body, jwt) {
|
||||||
|
const headers = { 'X-Appwrite-JWT': jwt }
|
||||||
|
if (body !== undefined) headers['Content-Type'] = 'application/json'
|
||||||
|
try {
|
||||||
|
return await fetchImpl(`${BASE}${path}`, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) })
|
||||||
|
} catch {
|
||||||
|
throw statusError(DOWN_TEXT, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function jwtFor(options) {
|
||||||
|
try {
|
||||||
|
return await getJwt(options)
|
||||||
|
} catch {
|
||||||
|
throw statusError(SESSION_TEXT, 401)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request(path, { method = 'GET', body } = {}) {
|
||||||
|
let res = await send(path, method, body, await jwtFor())
|
||||||
|
if (res.status === 401) {
|
||||||
|
// einmal mit frischem JWT wiederholen, danach gilt die Sitzung als abgelaufen
|
||||||
|
res = await send(path, method, body, await jwtFor({ fresh: true }))
|
||||||
|
if (res.status === 401) throw statusError(SESSION_TEXT, 401)
|
||||||
|
}
|
||||||
|
let data
|
||||||
|
try {
|
||||||
|
data = await res.json()
|
||||||
|
} catch {
|
||||||
|
// keine JSON-Antwort: Fehlerseite des Proxys (502/503/504) bzw. index.html von nginx statt der API
|
||||||
|
const gatewayDown = res.status === 502 || res.status === 503 || res.status === 504
|
||||||
|
throw statusError(gatewayDown ? DOWN_TEXT : BAD_REPLY_TEXT, res.status)
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
const info = data && typeof data === 'object' ? data : {}
|
||||||
|
const message = typeof info.error === 'string' && info.error ? info.error : null
|
||||||
|
if (message) throw statusError(message, res.status)
|
||||||
|
throw statusError(res.status === 403 ? FORBIDDEN_TEXT : `Hustler-Fehler ${res.status}`, res.status)
|
||||||
|
}
|
||||||
|
return data ?? {}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
state: () => request('/state'),
|
||||||
|
events: (after, limit = EVENTS_LIMIT) => request(`/events${query([['after', after], ['limit', clampLimit(limit, EVENTS_LIMIT)]])}`),
|
||||||
|
trace: (workerId, limit = TRACE_LIMIT) => request(`/worker/${encodeURIComponent(workerId)}/trace${query([['limit', clampLimit(limit, TRACE_LIMIT)]])}`),
|
||||||
|
pause: (paused) => request('/pause', { method: 'POST', body: { paused: Boolean(paused) } }),
|
||||||
|
settings: () => request('/settings'),
|
||||||
|
saveSettings: (patch) => request('/settings', { method: 'PUT', body: patch }),
|
||||||
|
// Das Komma bleibt unkodiert: die Einträge bestehen nur aus Kleinbuchstaben und _ (PULT_STATUS)
|
||||||
|
pult: async (status = 'offen') => request(`/pult?status=${pultStatusList(status).join(',')}`),
|
||||||
|
pultAction: async (id, action, body) => {
|
||||||
|
if (!PULT_ACTIONS.includes(action)) throw statusError(`Unbekannte Aktion: ${action}`, 400)
|
||||||
|
return request(`/pult/${encodeURIComponent(id)}/${action}`, { method: 'POST', body })
|
||||||
|
},
|
||||||
|
botleads: ({ room, q, page, id } = {}) => request(`/botleads${query([['room', room], ['q', q], ['page', page], ['id', id]])}`),
|
||||||
|
prospect: (id) => request(`/prospect/${encodeURIComponent(id)}`),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { account } from './appwrite'
|
||||||
|
import { createJwtCache } from './serverpulsAuth'
|
||||||
|
import { createWorkersApi } from './workersApi'
|
||||||
|
|
||||||
|
// Verdrahtung des Hustler-API-Clients mit der Appwrite-Sitzung (wird nicht unter Node geladen, wie serverpulsApi.js).
|
||||||
|
// JWT 10 Min. gecacht (Appwrite begrenzt das Erzeugen pro Nutzer); `fresh` verwirft den Cache für die 401-Wiederholung.
|
||||||
|
const jwtCache = createJwtCache({ createJWT: () => account.createJWT() })
|
||||||
|
|
||||||
|
const getJwt = (options) => {
|
||||||
|
if (options?.fresh) jwtCache.invalidate()
|
||||||
|
return jwtCache.get()
|
||||||
|
}
|
||||||
|
|
||||||
|
export const workersApi = createWorkersApi({ getJwt })
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import test from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { loadModule } from './load.mjs'
|
||||||
|
|
||||||
|
// API-Client der Hustler-Seite: URLs, Methoden, Header, deutsche Fehlertexte, JWT-Wiederholung, Pult-Prüfungen
|
||||||
|
const { createWorkersApi } = await loadModule('src/lib/workersApi.js')
|
||||||
|
|
||||||
|
const jwts = []
|
||||||
|
const getJwt = async (o) => {
|
||||||
|
jwts.push(o?.fresh === true)
|
||||||
|
return o?.fresh ? 'jwt-neu' : 'jwt-alt'
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeFetch(replies) {
|
||||||
|
const calls = []
|
||||||
|
const fn = async (url, init) => {
|
||||||
|
calls.push({ url, init })
|
||||||
|
const r = replies.shift()
|
||||||
|
if (r instanceof Error) throw r
|
||||||
|
return {
|
||||||
|
ok: r.status >= 200 && r.status < 300,
|
||||||
|
status: r.status,
|
||||||
|
json: async () => {
|
||||||
|
if (r.body === undefined) throw new SyntaxError('kein JSON')
|
||||||
|
return r.body
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn.calls = calls
|
||||||
|
return fn
|
||||||
|
}
|
||||||
|
|
||||||
|
const okReplies = (n) => Array.from({ length: n }, () => ({ status: 200, body: {} }))
|
||||||
|
|
||||||
|
test('URLs, Methoden, Header, Body', async () => {
|
||||||
|
const f = fakeFetch(okReplies(12))
|
||||||
|
const api = createWorkersApi({ getJwt, fetchImpl: f })
|
||||||
|
await api.state()
|
||||||
|
await api.events('2026-10-17T20:00:00.000+00:00')
|
||||||
|
await api.events(null, 500)
|
||||||
|
await api.trace('kim')
|
||||||
|
await api.pause(true)
|
||||||
|
await api.settings()
|
||||||
|
await api.saveSettings({ warnPct: 70 })
|
||||||
|
await api.pult()
|
||||||
|
await api.pultAction('p1', 'claim')
|
||||||
|
await api.botleads({ room: 'netz', q: 'Bäcker', page: 2 })
|
||||||
|
await api.botleads({ id: 'p1' })
|
||||||
|
await api.prospect('x/y')
|
||||||
|
assert.deepEqual(f.calls.map((c) => [c.init.method || 'GET', c.url]), [
|
||||||
|
['GET', '/api/workers/state'],
|
||||||
|
['GET', '/api/workers/events?after=2026-10-17T20%3A00%3A00.000%2B00%3A00&limit=200'],
|
||||||
|
['GET', '/api/workers/events?limit=200'],
|
||||||
|
['GET', '/api/workers/worker/kim/trace?limit=50'],
|
||||||
|
['POST', '/api/workers/pause'],
|
||||||
|
['GET', '/api/workers/settings'],
|
||||||
|
['PUT', '/api/workers/settings'],
|
||||||
|
['GET', '/api/workers/pult?status=offen'],
|
||||||
|
['POST', '/api/workers/pult/p1/claim'],
|
||||||
|
['GET', '/api/workers/botleads?room=netz&q=B%C3%A4cker&page=2'],
|
||||||
|
['GET', '/api/workers/botleads?id=p1'],
|
||||||
|
['GET', '/api/workers/prospect/x%2Fy'],
|
||||||
|
])
|
||||||
|
assert.equal(f.calls[0].init.headers['X-Appwrite-JWT'], 'jwt-alt')
|
||||||
|
assert.equal(f.calls[4].init.body, JSON.stringify({ paused: true }))
|
||||||
|
assert.equal(f.calls[4].init.headers['Content-Type'], 'application/json')
|
||||||
|
assert.equal(f.calls[6].init.body, JSON.stringify({ warnPct: 70 }))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Fehlertexte (Review Focus 1)', async () => {
|
||||||
|
const run = (reply) => createWorkersApi({ getJwt, fetchImpl: fakeFetch([reply]) }).state().then(() => null, (e) => [e.message, e.status])
|
||||||
|
assert.deepEqual(await run({ status: 200, body: undefined }), ['Der Hustler-Dienst antwortet nicht richtig.', 200]) // nginx liefert index.html
|
||||||
|
assert.deepEqual(await run({ status: 404, body: undefined }), ['Der Hustler-Dienst antwortet nicht richtig.', 404])
|
||||||
|
assert.deepEqual(await run({ status: 502, body: undefined }), ['Der Hustler-Dienst ist gerade nicht erreichbar.', 502])
|
||||||
|
assert.deepEqual(await run(new TypeError('fetch failed')), ['Der Hustler-Dienst ist gerade nicht erreichbar.', 0])
|
||||||
|
assert.deepEqual(await run({ status: 403, body: {} }), ['Dafür fehlt dir die Berechtigung.', 403])
|
||||||
|
assert.deepEqual(await run({ status: 400, body: { error: 'Tagesgrenze ungültig.' } }), ['Tagesgrenze ungültig.', 400])
|
||||||
|
assert.deepEqual(await run({ status: 500, body: {} }), ['Hustler-Fehler 500', 500])
|
||||||
|
await assert.rejects(
|
||||||
|
createWorkersApi({ getJwt: async () => { throw new Error('x') }, fetchImpl: fakeFetch([]) }).state(),
|
||||||
|
{ message: 'Anmeldung abgelaufen. Bitte neu anmelden.', status: 401 },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('401 ⇒ einmal mit frischem JWT wiederholen, danach Meldung', async () => {
|
||||||
|
jwts.length = 0
|
||||||
|
const f = fakeFetch([{ status: 401, body: {} }, { status: 200, body: { ok: 1 } }])
|
||||||
|
assert.deepEqual(await createWorkersApi({ getJwt, fetchImpl: f }).state(), { ok: 1 })
|
||||||
|
assert.deepEqual(jwts, [false, true])
|
||||||
|
assert.equal(f.calls[1].init.headers['X-Appwrite-JWT'], 'jwt-neu')
|
||||||
|
await assert.rejects(
|
||||||
|
createWorkersApi({ getJwt, fetchImpl: fakeFetch([{ status: 401, body: {} }, { status: 401, body: {} }]) }).state(),
|
||||||
|
{ message: 'Anmeldung abgelaufen. Bitte neu anmelden.', status: 401 },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('pult: ein Status, Komma-Liste oder Array (Übersicht §7); unbekannte Status werden abgewiesen, ohne Anfrage', async () => {
|
||||||
|
const f = fakeFetch(okReplies(5))
|
||||||
|
const api = createWorkersApi({ getJwt, fetchImpl: f })
|
||||||
|
await api.pult('offen,in_arbeit')
|
||||||
|
await api.pult(['gesendet', 'verworfen', 'erledigt'])
|
||||||
|
await api.pult(' veraltet ')
|
||||||
|
await api.pult('')
|
||||||
|
await api.pult('gesendet, verworfen , erledigt')
|
||||||
|
// Kommas bleiben unkodiert, Einträge getrimmt, leer = offen
|
||||||
|
assert.deepEqual(f.calls.map((c) => c.url), [
|
||||||
|
'/api/workers/pult?status=offen,in_arbeit',
|
||||||
|
'/api/workers/pult?status=gesendet,verworfen,erledigt',
|
||||||
|
'/api/workers/pult?status=veraltet',
|
||||||
|
'/api/workers/pult?status=offen',
|
||||||
|
'/api/workers/pult?status=gesendet,verworfen,erledigt',
|
||||||
|
])
|
||||||
|
for (const bad of ['offen,../x', 'quatsch', ['offen', 'x y'], 'offen;in_arbeit', 'offen,,in_arbeit']) {
|
||||||
|
await assert.rejects(api.pult(bad), /Unbekannter Pult-Status/)
|
||||||
|
}
|
||||||
|
assert.equal(f.calls.length, 5) // die fünf abgewiesenen Aufrufe haben nichts gesendet
|
||||||
|
})
|
||||||
|
|
||||||
|
test('pultAction: nur bekannte Aktionen, Pfad-Tricks abgewiesen', async () => {
|
||||||
|
const f = fakeFetch(okReplies(7))
|
||||||
|
const api = createWorkersApi({ getJwt, fetchImpl: f })
|
||||||
|
for (const bad of ['../x', 'delete', '']) {
|
||||||
|
await assert.rejects(api.pultAction('p1', bad), /Unbekannte Aktion/)
|
||||||
|
}
|
||||||
|
assert.equal(f.calls.length, 0)
|
||||||
|
for (const a of ['claim', 'sent', 'discard', 'done', 'reply', 'approve-preview', 'paste']) {
|
||||||
|
await api.pultAction('p1', a, a === 'reply' || a === 'paste' ? { text: 'x' } : undefined)
|
||||||
|
}
|
||||||
|
assert.deepEqual(f.calls.map((c) => c.url.split('/').pop()), ['claim', 'sent', 'discard', 'done', 'reply', 'approve-preview', 'paste'])
|
||||||
|
assert.equal(f.calls[4].init.body, JSON.stringify({ text: 'x' }))
|
||||||
|
// Plattform-Folge-Antwort einfügen (P4-T34)
|
||||||
|
assert.deepEqual([f.calls[6].url, f.calls[6].init.method, f.calls[6].init.body], ['/api/workers/pult/p1/paste', 'POST', JSON.stringify({ text: 'x' })])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('limit wird geklemmt (Ereignisse 1..200, Spur 1..50)', async () => {
|
||||||
|
const f = fakeFetch(okReplies(3))
|
||||||
|
const api = createWorkersApi({ getJwt, fetchImpl: f })
|
||||||
|
await api.trace('kim', 999)
|
||||||
|
await api.events(null, 0)
|
||||||
|
await api.events(null, 150)
|
||||||
|
assert.deepEqual(f.calls.map((c) => c.url), [
|
||||||
|
'/api/workers/worker/kim/trace?limit=50',
|
||||||
|
'/api/workers/events?limit=1',
|
||||||
|
'/api/workers/events?limit=150',
|
||||||
|
])
|
||||||
|
})
|
||||||
Reference in new issue
Block a user