diff --git a/src/lib/workersApi.js b/src/lib/workersApi.js new file mode 100644 index 0000000..1f980ae --- /dev/null +++ b/src/lib/workersApi.js @@ -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)}`), + } +} diff --git a/src/lib/workersClient.js b/src/lib/workersClient.js new file mode 100644 index 0000000..b14bc70 --- /dev/null +++ b/src/lib/workersClient.js @@ -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 }) diff --git a/tests/unit/workersApi.test.mjs b/tests/unit/workersApi.test.mjs new file mode 100644 index 0000000..84b8760 --- /dev/null +++ b/tests/unit/workersApi.test.mjs @@ -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', + ]) +})