Files
tickte-system/tests/unit/workersApi.test.mjs
T
ClaudeandClaude Sonnet 5.5 41ae7ffd3f Endprüfung Nachrunde R7: Test der Zeitgrenze (D14) für reply, approve-preview und start-build wiederhergestellt
Der Commit 1536a3b hatte den Test „reply, approve-preview und start-build warten länger als die übrigen
Pult-Aktionen“ gelöscht. Der neue Test prüft gegen den heutigen Code (ACTION_TIMEOUT_MS 65 s,
REQUEST_TIMEOUT_MS 20 s, Optionen timeoutMs / actionTimeoutMs) mit den Uhren von node:test statt echter
Wartezeit: keine Abhängigkeit von der Last, Grenzen auf die Millisekunde (eine Millisekunde vor der Frist
läuft der Abruf, mit ihr endet er). Die drei langsamen Aktionen nehmen eine Antwort zwischen kurzer und langer
Frist an, claim/sent/discard/done/paste und das Lesen enden mit der kurzen Frist (DOWN_TEXT, status 0, Signal
abgebrochen), ein hängender Abruf endet nach der langen Frist. Derselbe Ablauf mit Standard- und mit
eingespeisten Fristen; ein zweiter Test pinnt, dass auch der Wiederholungsversuch nach einer 401 die Frist
der Aktion behält.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 23:54:10 +00:00

709 lines
36 KiB
JavaScript

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, REQUEST_TIMEOUT_MS, AUTH_COOLDOWN_MS, ACTION_TIMEOUT_MS, SLOW_PULT_ACTIONS, UNCERTAIN_STATUS } = await loadModule('src/lib/workersApi.js')
const { actionErrorText, UNCERTAIN_TEXT } = await loadModule('src/lib/workersPult.js')
const { createJwtCache } = await loadModule('src/lib/serverpulsAuth.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 Object.assign(new Error('x'), { code: 401 }) }, 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(8))
const api = createWorkersApi({ getJwt, fetchImpl: f })
for (const bad of ['../x', 'delete', '', 'start-build/../x', 'Start-Build']) {
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', 'start-build']) {
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', 'start-build'])
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' })])
// Bau starten (P4-T32): POST ohne Body
assert.deepEqual([f.calls[7].url, f.calls[7].init.method, f.calls[7].init.body], ['/api/workers/pult/p1/start-build', 'POST', undefined])
})
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',
])
})
// ---- Endprüfung P2: Zeitgrenze je Abruf und Sperre für frische JWTs nach einer 401-Folge ----
const DOWN = 'Der Hustler-Dienst ist gerade nicht erreichbar.'
const SESSION = 'Anmeldung abgelaufen. Bitte neu anmelden.'
test('Zeitgrenze: ein hängender fetch endet mit der Dienst-Meldung (status 0), das Signal wird abgebrochen', async () => {
const signals = []
const hang = (url, init) => {
signals.push(init.signal)
return new Promise(() => {}) // beachtet das Signal nicht einmal: die Zeitgrenze muss trotzdem greifen
}
const api = createWorkersApi({ getJwt, fetchImpl: hang, timeoutMs: 30 })
const t0 = Date.now()
await assert.rejects(api.state(), { message: DOWN, status: 0 })
assert.ok(Date.now() - t0 < 2000)
assert.equal(signals.length, 1)
assert.equal(signals[0].aborted, true)
// jeder Aufruf hat seine eigene Zeitgrenze (hier: Schreibaufruf und Spur)
await assert.rejects(api.pause(true), { message: DOWN, status: 0 })
await assert.rejects(api.trace('kim'), { message: DOWN, status: 0 })
assert.equal(signals.length, 3)
})
test('Zeitgrenze: auch eine Antwort, deren Rumpf nie ankommt, und ein hängendes createJWT enden mit der Dienst-Meldung', async () => {
const stalledBody = async () => ({ ok: true, status: 200, json: () => new Promise(() => {}) })
await assert.rejects(createWorkersApi({ getJwt, fetchImpl: stalledBody, timeoutMs: 30 }).state(), { message: DOWN, status: 0 })
const hungJwt = () => new Promise(() => {})
const f = fakeFetch(okReplies(1))
await assert.rejects(createWorkersApi({ getJwt: hungJwt, fetchImpl: f, timeoutMs: 30 }).state(), { message: DOWN, status: 0 })
assert.equal(f.calls.length, 0) // ohne JWT ging nichts hinaus
})
test('Zeitgrenze: rechtzeitige Antworten bleiben unberührt, ein späterer Abruf klappt wieder (Schleife wird frei)', async () => {
let n = 0
const flaky = async () => {
n += 1
if (n === 1) return new Promise(() => {})
return { ok: true, status: 200, json: async () => ({ n }) }
}
const api = createWorkersApi({ getJwt, fetchImpl: flaky, timeoutMs: 40 })
await assert.rejects(api.state(), { message: DOWN, status: 0 })
assert.deepEqual(await api.state(), { n: 2 })
const quick = createWorkersApi({ getJwt, fetchImpl: fakeFetch(okReplies(1)) }) // Standard-Zeitgrenze 20 s: wird nach der Antwort aufgeräumt
assert.deepEqual(await quick.state(), {})
})
test('Zeitgrenze: Standardwerte 20 s und 2 min, für langsame Pult-Aktionen 65 s (nach der Frist des Proxys von 60 s)', () => {
assert.equal(REQUEST_TIMEOUT_MS, 20000)
assert.equal(AUTH_COOLDOWN_MS, 120000)
// nginx bricht nach proxy_read_timeout 60 s mit 504 ab; die Frist des Clients liegt darüber, damit nicht von der Uhr abhängt, welche Meldung erscheint
assert.equal(ACTION_TIMEOUT_MS, 65000)
assert.deepEqual(SLOW_PULT_ACTIONS, ['reply', 'approve-preview', 'start-build'])
assert.ok(Object.isFrozen(SLOW_PULT_ACTIONS))
// Status, bei dem offen bleibt, ob der Dienst die Aktion ausgeführt hat: Abbruch des Clients (0) und Fehlerseiten des Proxys beim Warten (502, 504)
assert.deepEqual(UNCERTAIN_STATUS, [0, 502, 504])
assert.ok(Object.isFrozen(UNCERTAIN_STATUS))
})
// P4-T32 (D14), Nachrunde R7: Antworten, Freigeben und Bauen laufen im Dienst länger (SMTP, Warteschlange); bricht der Client nach 20 s ab, sähe der
// Mensch einen Fehler, obwohl die Mail hinausgeht. Nur diese drei Aktionen bekommen die lange Frist (65 s), alles andere bleibt bei der kurzen (20 s).
// Mit den Uhren von node:test statt echter Wartezeit: kein Warten, keine Abhängigkeit von der Last des Rechners, und die Grenzen sind auf die Millisekunde
// genau prüfbar (eine Millisekunde vor der Frist läuft der Abruf noch, mit ihr endet er). setImmediate bleibt echt und lässt die Promise-Ketten durchlaufen.
const flush = () => new Promise((resolve) => setImmediate(resolve))
const bodyFor = (action) => (action === 'reply' || action === 'paste' ? { text: 'x' } : undefined)
// Dienst, der erst nach ms antwortet (Uhr von node:test); merkt sich die Signale der Abrufe
function slowService(ms, signals = []) {
return (url, init) => {
signals.push(init.signal)
return new Promise((resolve) => setTimeout(() => resolve({ ok: true, status: 200, json: async () => ({ item: { $id: 'p1' } }) }), ms))
}
}
// Ergebnis eines Abrufs, der noch läuft, ohne auf ihn zu warten: 'offen', 'ok' oder der Fehler
function watch(promise) {
const w = { state: 'offen', error: null }
promise.then(() => { w.state = 'ok' }, (e) => { w.state = 'fehler'; w.error = e })
return w
}
// Standardfristen (20 s / 65 s) und eingespeiste Fristen (timeoutMs / actionTimeoutMs) müssen sich gleich verhalten
for (const [label, options] of [
['Standardfristen', {}],
['eingespeiste Fristen', { timeoutMs: 300, actionTimeoutMs: 4000 }],
]) {
test(`Zeitgrenze (D14): reply, approve-preview und start-build warten länger als die übrigen Pult-Aktionen (${label})`, async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] })
const short = options.timeoutMs ?? REQUEST_TIMEOUT_MS
const long = options.actionTimeoutMs ?? ACTION_TIMEOUT_MS
assert.ok(short < long)
const between = Math.floor((short + long) / 2) // die Antwort kommt nach der kurzen, aber vor der langen Frist
const run = async (action, { service = slowService(between) }) => {
const api = createWorkersApi({ getJwt, fetchImpl: service, ...options })
const w = watch(action === 'read' ? api.pult() : api.pultAction('p1', action, bodyFor(action)))
await flush() // JWT geholt, Anfrage gesendet, Frist und Antwort stehen auf der Uhr
return { w, api, tick: async (ms) => { t.mock.timers.tick(ms); await flush() } }
}
// die drei langsamen Aktionen: die Antwort nach der kurzen Frist kommt noch an
for (const action of ['reply', 'approve-preview', 'start-build']) {
const { w, tick } = await run(action, {})
await tick(short) // die kurze Frist ist erreicht: der Abruf läuft weiter
assert.equal(w.state, 'offen', `${action}: nach der kurzen Frist noch offen`)
await tick(between - short) // die Antwort trifft ein
assert.equal(w.state, 'ok', `${action}: Antwort nach der kurzen Frist, vor der langen`)
}
// alle übrigen Aktionen und das Lesen behalten die kurze Frist: eine Millisekunde davor offen, mit ihr „nicht erreichbar“ (status 0), Signal abgebrochen
for (const action of ['claim', 'sent', 'discard', 'done', 'paste', 'read']) {
const signals = []
const { w, tick } = await run(action, { service: slowService(between, signals) })
await tick(short - 1)
assert.equal(w.state, 'offen', `${action}: eine Millisekunde vor der kurzen Frist`)
assert.equal(signals[0].aborted, false, action)
await tick(1)
assert.equal(w.state, 'fehler', `${action}: mit der kurzen Frist`)
assert.equal(w.error.message, DOWN, action)
assert.equal(w.error.status, 0, action)
assert.equal(signals[0].aborted, true, `${action}: das Signal ist abgebrochen`)
}
// auch die lange Frist endet: ein hängender Abruf meldet nach ihr die Dienst-Meldung (status 0) und bricht ab, keine Millisekunde früher
for (const action of ['reply', 'approve-preview', 'start-build']) {
const signals = []
const hang = (url, init) => { signals.push(init.signal); return new Promise(() => {}) }
const { w, tick } = await run(action, { service: hang })
await tick(long - 1)
assert.equal(w.state, 'offen', `${action}: eine Millisekunde vor der langen Frist`)
assert.equal(signals[0].aborted, false, action)
await tick(1)
assert.equal(w.state, 'fehler', `${action}: mit der langen Frist`)
assert.deepEqual([w.error.message, w.error.status], [DOWN, 0], action)
assert.equal(signals[0].aborted, true, `${action}: das Signal ist abgebrochen`)
}
})
}
test('Zeitgrenze (D14): auch der Wiederholungsversuch nach einer 401 behält die lange Frist bei den langsamen Aktionen, die kurze bei den übrigen', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] })
const MIDDLE = Math.floor((REQUEST_TIMEOUT_MS + ACTION_TIMEOUT_MS) / 2)
const slowAfter401 = () => {
let n = 0
return (url, init) => {
n += 1
if (n === 1) return Promise.resolve({ ok: false, status: 401, json: async () => ({}) })
return slowService(MIDDLE)(url, init) // zweiter Versuch: Antwort nach der kurzen (20 s), vor der langen Frist (65 s)
}
}
const tick = async (ms) => { t.mock.timers.tick(ms); await flush() }
for (const action of ['reply', 'approve-preview', 'start-build']) {
const w = watch(createWorkersApi({ getJwt, fetchImpl: slowAfter401() }).pultAction('p1', action, bodyFor(action)))
await flush()
await tick(MIDDLE)
assert.equal(w.state, 'ok', action)
}
for (const action of ['claim', 'paste']) {
const w = watch(createWorkersApi({ getJwt, fetchImpl: slowAfter401() }).pultAction('p1', action, bodyFor(action)))
await flush()
await tick(REQUEST_TIMEOUT_MS)
assert.equal(w.state, 'fehler', action)
assert.deepEqual([w.error.message, w.error.status], [DOWN, 0], action)
}
})
// Endprüfung P4 (W1, D14): eine Fehlerseite des Proxys (502/504) bei reply, approve-preview und start-build heißt nicht „nicht erreichbar“:
// der Dienst arbeitet weiter, wenn nginx aufgibt, die Mail kann schon hinausgegangen sein. Der Client selbst (read) bleibt bei DOWN_TEXT
// und dem echten Status; die Aktions-Meldung (actionErrorText) entscheidet nach Aktion und Status.
test('Proxy-Fehlerseite (D14, W1): 502 und 504 bei reply, approve-preview und start-build heißen „Kann gesendet sein“, bei den übrigen Aktionen „nicht erreichbar“', async () => {
const outcome = async (action, status) => {
const api = createWorkersApi({ getJwt, fetchImpl: fakeFetch([{ status, body: undefined }]) }) // HTML-Fehlerseite des Proxys, kein JSON
const err = await api.pultAction('p1', action, action === 'reply' || action === 'paste' ? { text: 'x' } : undefined).then(() => null, (e) => e)
assert.equal(err.status, status, `${action} ${status}`)
if ([502, 503, 504].includes(status)) assert.equal(err.message, DOWN, `${action} ${status}: der Client meldet weiter „nicht erreichbar“`)
return actionErrorText(err, action)
}
for (const action of ['reply', 'approve-preview', 'start-build']) {
for (const status of [502, 504]) assert.equal(await outcome(action, status), UNCERTAIN_TEXT, `${action} ${status}`)
// Abbruch des Clients (status 0) wie bisher
const aborted = createWorkersApi({ getJwt, fetchImpl: fakeFetch([new TypeError('fetch failed')]) })
const err = await aborted.pultAction('p1', action, action === 'reply' ? { text: 'x' } : undefined).then(() => null, (e) => e)
assert.equal(actionErrorText(err, action), UNCERTAIN_TEXT, `${action} 0`)
}
// 503 (kein Upstream, die Anfrage hat den Dienst nie erreicht) bleibt „nicht erreichbar“, ebenso Drosselung, Fehler des Dienstes und alles Übrige
for (const action of ['reply', 'approve-preview', 'start-build']) {
assert.equal(await outcome(action, 503), DOWN, `${action} 503`)
assert.equal(await outcome(action, 500), 'Der Hustler-Dienst antwortet nicht richtig.', `${action} 500 ohne JSON`)
assert.equal(await outcome(action, 429), 'Der Hustler-Dienst antwortet nicht richtig.', `${action} 429 ohne JSON`)
}
// die übrigen Pult-Aktionen: ein 502 bleibt „nicht erreichbar“ (der Dienst führt sie schnell aus, es steht kein Versand dahinter)
for (const action of ['paste', 'claim', 'sent', 'discard', 'done']) {
assert.equal(await outcome(action, 502), DOWN, `${action} 502`)
assert.equal(await outcome(action, 504), DOWN, `${action} 504`)
}
// ein echter Satz des Dienstes (JSON mit error) bleibt auch bei einer langsamen Aktion und bei 502 unverändert: der Dienst selbst antwortet mit 502
// „Anmeldedienst nicht erreichbar.“, wenn die Anmeldung scheitert, und das geschieht vor der Aktion, es wurde also nichts gesendet
for (const action of ['reply', 'approve-preview', 'start-build']) {
const api = createWorkersApi({ getJwt, fetchImpl: fakeFetch([{ status: 502, body: { error: 'Anmeldedienst nicht erreichbar.' } }]) })
const err = await api.pultAction('p1', action, action === 'reply' ? { text: 'x' } : undefined).then(() => null, (e) => e)
assert.equal(actionErrorText(err, action), 'Anmeldedienst nicht erreichbar.', action)
}
})
test('401-Folge: danach kein frisches JWT mehr, bis die Sperre abgelaufen ist (höchstens ein createJWT je Sperre)', async () => {
jwts.length = 0
let t = 1_000_000
const calls = []
const always401 = async (url, init) => {
calls.push(init.headers['X-Appwrite-JWT'])
return { ok: false, status: 401, json: async () => ({}) }
}
const api = createWorkersApi({ getJwt, fetchImpl: always401, now: () => t, authCooldownMs: 60000 })
await assert.rejects(api.state(), { message: SESSION, status: 401 })
assert.deepEqual(jwts, [false, true])
assert.deepEqual(calls, ['jwt-alt', 'jwt-neu']) // Versuch und Wiederholung
// innerhalb der Sperre: je Abruf genau eine Anfrage mit dem gespeicherten JWT, nie ein frisches, die Meldung bleibt
for (let i = 0; i < 20; i += 1) {
t += 2900 // 20 Schleifendurchläufe innerhalb der Sperre von 60 s (Zustand, Ereignisse und Spur zusammen)
await assert.rejects(api.events(null), { message: SESSION, status: 401 })
}
assert.equal(jwts.filter((fresh) => fresh).length, 1)
assert.equal(calls.length, 2 + 20)
// nach Ablauf der Sperre wird wieder einmal erneuert, danach beginnt die nächste Sperre
t += 2100
await assert.rejects(api.state(), { message: SESSION, status: 401 })
assert.equal(jwts.filter((fresh) => fresh).length, 2)
t += 5000
await assert.rejects(api.trace('kim'), { message: SESSION, status: 401 })
assert.equal(jwts.filter((fresh) => fresh).length, 2)
})
test('401-Folge: Erfolg hebt die Sperre auf, ein einzelnes 401 danach wird wieder mit frischem JWT wiederholt', async () => {
jwts.length = 0
let t = 0
const replies = [
{ status: 401, body: {} }, { status: 401, body: {} }, // Folge ⇒ Sperre
{ status: 200, body: { ok: 1 } }, // Dienst nimmt das gespeicherte JWT wieder an ⇒ Sperre weg
{ status: 401, body: {} }, { status: 200, body: { ok: 2 } }, // einzelnes 401 ⇒ Wiederholung mit frischem JWT hilft
]
const api = createWorkersApi({ getJwt, fetchImpl: fakeFetch(replies), now: () => t })
await assert.rejects(api.state(), { status: 401 })
t += 5000
assert.deepEqual(await api.state(), { ok: 1 })
assert.deepEqual(await api.state(), { ok: 2 })
assert.deepEqual(jwts, [false, true, false, false, true])
})
test('401-Folge: scheitert schon die Erzeugung des frischen JWT, beginnt dieselbe Sperre', async () => {
let t = 0
const mints = []
const brokenFresh = async (o) => {
mints.push(o?.fresh === true)
if (o?.fresh) throw Object.assign(new Error('429 Too Many Requests'), { code: 429 })
return 'jwt-alt'
}
const api = createWorkersApi({ getJwt: brokenFresh, fetchImpl: fakeFetch(Array.from({ length: 6 }, () => ({ status: 401, body: {} }))), now: () => t, authCooldownMs: 60000 })
await assert.rejects(api.state(), { message: SESSION, status: 401 })
t += 10000
await assert.rejects(api.state(), { message: SESSION, status: 401 })
t += 10000
await assert.rejects(api.state(), { message: SESSION, status: 401 })
assert.deepEqual(mints, [false, true]) // ein einziger Versuch, ein frisches JWT zu erzeugen; danach bis zum Ende der Sperre gar keiner mehr (Endprüfung P3)
t += 60000
await assert.rejects(api.state(), { message: SESSION, status: 401 })
assert.deepEqual(mints, [false, true, false, true]) // nach der Sperre genau ein neuer Versuch (gespeichertes JWT, 401, frisches JWT scheitert wieder)
})
test('401-Folge: andere Fehler (403, 500, 502) heben die Sperre auf, weil der Dienst das JWT angenommen hat', async () => {
jwts.length = 0
let t = 0
const api = createWorkersApi({
getJwt,
fetchImpl: fakeFetch([{ status: 401, body: {} }, { status: 401, body: {} }, { status: 403, body: {} }, { status: 401, body: {} }, { status: 200, body: {} }]),
now: () => t,
})
await assert.rejects(api.state(), { status: 401 })
await assert.rejects(api.state(), { status: 403 })
assert.deepEqual(await api.state(), {}) // 401, Wiederholung frisch ⇒ 200
assert.deepEqual(jwts, [false, true, false, false, true])
})
// ---- P3-T21: Kennungen im Pfad, Sperre auch beim Scheitern von createJWT, Zurücksetzen beim Abmelden ----
test('Kennungen im Pfad: leer, ".", ".." oder keine Zeichenkette ⇒ "Ungültige Kennung." ohne Netzaufruf', async () => {
const f = fakeFetch(okReplies(4))
const api = createWorkersApi({ getJwt, fetchImpl: f })
const bad = [['pultAction', '..', 'claim'], ['pultAction', '.', 'sent'], ['pultAction', '', 'done'], ['pultAction', null, 'claim'], ['pultAction', 7, 'claim'],
['prospect', ''], ['prospect', '.'], ['prospect', '..'], ['prospect', undefined], ['prospect', { toString: () => 'x' }],
['trace', ''], ['trace', '..'], ['trace', '.'], ['trace', null], ['trace', ['kim']]]
for (const [method, ...args] of bad) {
const out = api[method](...args)
assert.ok(out instanceof Promise, `${method}(${String(args[0])}) liefert ein Promise, wirft nicht synchron`)
await assert.rejects(out, { message: 'Ungültige Kennung.' }, `${method}(${String(args[0])})`)
}
assert.equal(f.calls.length, 0)
// sonst wird kodiert, auch Schrägstriche und Prozentzeichen; Punkte allein sind nur als ganze Kennung verboten
await api.prospect('a/b')
await api.pultAction('a b%', 'claim')
await api.trace('k/../m')
await api.prospect('p..1')
assert.deepEqual(f.calls.map((c) => c.url), [
'/api/workers/prospect/a%2Fb',
'/api/workers/pult/a%20b%25/claim',
'/api/workers/worker/k%2F..%2Fm/trace?limit=50',
'/api/workers/prospect/p..1',
])
})
test('Kennungen im Pfad: die Aktionsprüfung bleibt vorn (unbekannte Aktion wird weiter als solche gemeldet)', async () => {
const f = fakeFetch([])
const api = createWorkersApi({ getJwt, fetchImpl: f })
await assert.rejects(api.pultAction('p1', 'delete'), /Unbekannte Aktion/)
await assert.rejects(api.pultAction('..', 'delete'), /Unbekannte Aktion/)
assert.equal(f.calls.length, 0)
})
// Verdrahtung wie in workersClient.js: echter JWT-Cache, `fresh` verwirft ihn vor dem Holen
function cachedJwt(createJWT, now) {
const cache = createJwtCache({ createJWT, now })
return (options) => {
if (options?.fresh) cache.invalidate()
return cache.get()
}
}
test('createJWT scheitert (Appwrite 429): Sperre wie nach der zweiten 401, auch bei leerem Cache; danach genau ein neuer Versuch', async () => {
let t = 0
let creates = 0
let failing = true
const getJwtCached = cachedJwt(async () => {
creates += 1
if (failing) throw Object.assign(new Error('Rate limit'), { code: 429 })
return { jwt: `jwt-${creates}` }
}, () => t)
const f = fakeFetch(okReplies(8))
const api = createWorkersApi({ getJwt: getJwtCached, fetchImpl: f, now: () => t, authCooldownMs: 60000 })
await assert.rejects(api.state(), { message: SESSION, status: 401 })
assert.equal(creates, 1)
// Schleifen (5 s / 15 s) innerhalb der Sperre: kein weiterer createJWT-Aufruf, keine Anfrage an den Dienst, immer SESSION_TEXT
for (let i = 0; i < 11; i += 1) {
t += 5000
await assert.rejects([api.state, api.events, api.settings][i % 3](), { message: SESSION, status: 401 })
}
assert.equal(t, 55000)
assert.equal(creates, 1)
assert.equal(f.calls.length, 0)
// Sperre vorbei: genau ein Versuch, er scheitert wieder ⇒ neue Sperre
t += 6000
await assert.rejects(api.state(), { message: SESSION, status: 401 })
assert.equal(creates, 2)
t += 30000
await assert.rejects(api.state(), { message: SESSION, status: 401 })
assert.equal(creates, 2)
// Appwrite erholt sich: nach der Sperre klappt der nächste Versuch, das JWT wird gecacht und der Dienst erreicht
failing = false
t += 31000
assert.deepEqual(await api.state(), {})
assert.equal(creates, 3)
assert.equal(f.calls.length, 1)
assert.equal(f.calls[0].init.headers['X-Appwrite-JWT'], 'jwt-3')
assert.deepEqual(await api.state(), {})
assert.equal(creates, 3)
})
test('createJWT scheitert beim frischen JWT nach einer 401: der leere Cache löst innerhalb der Sperre kein weiteres createJWT aus', async () => {
let t = 0
let creates = 0
const getJwtCached = cachedJwt(async () => {
creates += 1
if (creates >= 2) throw Object.assign(new Error('Rate limit'), { code: 429 }) // das erste JWT klappt, jedes weitere nicht
return { jwt: 'jwt-1' }
}, () => t)
const f = fakeFetch([{ status: 401, body: {} }, ...okReplies(4)])
const api = createWorkersApi({ getJwt: getJwtCached, fetchImpl: f, now: () => t, authCooldownMs: 60000 })
await assert.rejects(api.state(), { message: SESSION, status: 401 }) // 401, dann scheitert das frische JWT (Cache ist danach leer)
assert.equal(creates, 2)
assert.equal(f.calls.length, 1)
for (let i = 0; i < 5; i += 1) {
t += 10000
await assert.rejects(api.events(null), { message: SESSION, status: 401 })
}
assert.equal(creates, 2)
assert.equal(f.calls.length, 1)
t += 20000 // 70 s nach dem Scheitern: Sperre vorbei
await assert.rejects(api.state(), { message: SESSION, status: 401 })
assert.equal(creates, 3)
})
test('resetAuth (Abmelden): hebt die Sperre auf, der nächste Abruf holt sofort wieder ein JWT', async () => {
let t = 0
const asked = []
const failing = { on: true }
const getJwtFlaky = async (o) => {
asked.push(o?.fresh === true)
if (failing.on) throw Object.assign(new Error('429'), { code: 429 })
return 'jwt-neu'
}
const f = fakeFetch(okReplies(2))
const api = createWorkersApi({ getJwt: getJwtFlaky, fetchImpl: f, now: () => t, authCooldownMs: 60000 })
await assert.rejects(api.state(), { message: SESSION, status: 401 })
t += 1000
await assert.rejects(api.state(), { message: SESSION, status: 401 })
assert.deepEqual(asked, [false]) // gesperrt
failing.on = false
assert.equal(typeof api.resetAuth, 'function')
api.resetAuth()
assert.deepEqual(await api.state(), {})
assert.deepEqual(asked, [false, false])
assert.equal(f.calls.length, 1)
// auch die Sperre nach einer 401-Folge fällt: ein neues einzelnes 401 darf wieder mit frischem JWT wiederholt werden
const g = fakeFetch([{ status: 401, body: {} }, { status: 401, body: {} }, { status: 401, body: {} }, { status: 200, body: { ok: 1 } }])
const api2 = createWorkersApi({ getJwt, fetchImpl: g, now: () => t, authCooldownMs: 60000 })
jwts.length = 0
await assert.rejects(api2.state(), { status: 401 }) // 401 + 401 ⇒ Sperre
api2.resetAuth()
assert.deepEqual(await api2.state(), { ok: 1 })
assert.deepEqual(jwts, [false, true, false, true])
})
test('ein hängendes createJWT (Zeitgrenze) ist kein Scheitern und löst keine Sperre aus', async () => {
let asked = 0
const hung = () => { asked += 1; return new Promise(() => {}) }
const api = createWorkersApi({ getJwt: hung, fetchImpl: fakeFetch([]), timeoutMs: 20 })
await assert.rejects(api.state(), { message: DOWN, status: 0 })
await assert.rejects(api.state(), { message: DOWN, status: 0 })
assert.equal(asked, 2) // die Zeitgrenze (Netz/Ruhezustand) sperrt nichts: ein nächster Abruf versucht es wieder
})
// ---- Endprüfung P3 (W1): gesperrt wird nur, wenn Appwrite createJWT mit einem HTTP-Code ab 400 abgelehnt hat; Übertragungsfehler nicht ----
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
test('createJWT: Übertragungsfehler (TypeError, SDK-Fehler mit code 0, Fehler ohne Code) geben DOWN/0 und sperren nichts', async () => {
const transport = [
new TypeError('Failed to fetch'),
Object.assign(new Error('Load failed'), { code: 0 }), // so meldet das Appwrite-SDK einen Netzfehler (client.ts: AppwriteException mit code 0)
new Error('irgendwas'),
Object.assign(new Error('Code ist keine Zahl'), { code: '429' }),
Object.assign(new Error('Code nicht ganzzahlig'), { code: 429.5 }),
Object.assign(new Error('Code unter 400'), { code: 399 }),
null,
undefined,
]
for (const failure of transport) {
const calls = []
const getJwtFlaky = async () => { calls.push(1); throw failure }
const f = fakeFetch(okReplies(1))
const api = createWorkersApi({ getJwt: getJwtFlaky, fetchImpl: f, now: () => 0 })
await assert.rejects(api.state(), { message: DOWN, status: 0 }, String(failure))
await assert.rejects(api.state(), { message: DOWN, status: 0 }, String(failure)) // zweiter Abruf: nicht gesperrt, getJwt wird wieder gerufen
assert.equal(calls.length, 2, String(failure))
assert.equal(f.calls.length, 0)
}
})
test('createJWT: jeder HTTP-Code ab 400 sperrt (401, 403, 429, 500, 503), nur Appwrites echte Ablehnung zählt', async () => {
for (const code of [400, 401, 403, 429, 500, 503]) {
let t = 0
let creates = 0
const getJwtRefused = async () => { creates += 1; throw Object.assign(new Error('abgelehnt'), { code }) }
const api = createWorkersApi({ getJwt: getJwtRefused, fetchImpl: fakeFetch([]), now: () => t, authCooldownMs: 60000 })
await assert.rejects(api.state(), { message: SESSION, status: 401 }, `code ${code}`)
t += 10000
await assert.rejects(api.events(null), { message: SESSION, status: 401 }, `code ${code}`)
assert.equal(creates, 1, `code ${code}`) // gesperrt: kein zweiter createJWT-Aufruf
}
})
test('createJWT: offline (TypeError) gibt DOWN/0, ein Abruf 10 s später mit zurückgekehrtem Netz klappt sofort', async () => {
let t = 0
let online = false
let creates = 0
const getJwtNet = async () => {
creates += 1
if (!online) throw new TypeError('Failed to fetch')
return 'jwt-1'
}
const f = fakeFetch(okReplies(2))
const api = createWorkersApi({ getJwt: getJwtNet, fetchImpl: f, now: () => t }) // Standard-Sperre 2 min: sie darf nicht greifen
await assert.rejects(api.state(), { message: DOWN, status: 0 })
assert.equal(creates, 1)
assert.equal(f.calls.length, 0)
t += 10000
online = true
assert.deepEqual(await api.state(), {})
assert.equal(creates, 2)
assert.equal(f.calls.length, 1)
assert.equal(f.calls[0].init.headers['X-Appwrite-JWT'], 'jwt-1')
})
test('createJWT: späte Ablehnung nach der Zeitgrenze (TypeError und code 0) sperrt nicht', async () => {
for (const failure of [new TypeError('Failed to fetch'), Object.assign(new Error('Load failed'), { code: 0 })]) {
let t = 0
let creates = 0
let online = false
const getJwtLate = async () => {
creates += 1
if (online) return 'jwt-1'
await sleep(25) // trifft erst nach der Zeitgrenze (10 ms) ein: der Aufrufer hat DOWN/0 schon bekommen
throw failure
}
const f = fakeFetch(okReplies(1))
const api = createWorkersApi({ getJwt: getJwtLate, fetchImpl: f, now: () => t, timeoutMs: 10 })
await assert.rejects(api.state(), { message: DOWN, status: 0 })
await sleep(40) // die späte Ablehnung ist inzwischen angekommen
t += 5000
online = true
assert.deepEqual(await api.state(), {}) // nicht gesperrt: der nächste Takt holt ein JWT
assert.equal(creates, 2)
assert.equal(f.calls.length, 1)
}
})
test('createJWT: späte Ablehnung nach der Zeitgrenze mit code 429 sperrt weiter', async () => {
let t = 0
let creates = 0
const getJwtLate429 = async () => {
creates += 1
await sleep(25)
throw Object.assign(new Error('Rate limit'), { code: 429 })
}
const f = fakeFetch(okReplies(2))
const api = createWorkersApi({ getJwt: getJwtLate429, fetchImpl: f, now: () => t, timeoutMs: 10, authCooldownMs: 60000 })
await assert.rejects(api.state(), { message: DOWN, status: 0 }) // dem Aufrufer wurde schon DOWN/0 gemeldet
await sleep(40)
t += 5000
await assert.rejects(api.state(), { message: SESSION, status: 401 }) // die echte Ablehnung zählt gegen die Begrenzung: Sperre
assert.equal(creates, 1)
assert.equal(f.calls.length, 0)
t += 60000
await assert.rejects(api.state(), { message: DOWN, status: 0 }) // nach der Sperre genau ein neuer Versuch
assert.equal(creates, 2)
})
test('createJWT: Übertragungsfehler beim frischen JWT nach einer 401 sperrt nicht (nur die 401-Folge selbst)', async () => {
let t = 0
const mints = []
let online = true
const getJwtFresh = async (o) => {
mints.push(o?.fresh === true)
if (o?.fresh && !online) throw new TypeError('Failed to fetch')
return o?.fresh ? 'jwt-neu' : 'jwt-alt'
}
const f = fakeFetch([{ status: 401, body: {} }, { status: 401, body: {} }, { status: 200, body: { ok: 1 } }])
const api = createWorkersApi({ getJwt: getJwtFresh, fetchImpl: f, now: () => t })
online = false
await assert.rejects(api.state(), { message: DOWN, status: 0 }) // 401, dann Netz weg beim frischen JWT
t += 5000
online = true
assert.deepEqual(await api.state(), { ok: 1 }) // 401 mit dem gespeicherten JWT, die Wiederholung mit frischem JWT darf wieder erfolgen und klappt
assert.deepEqual(mints, [false, true, false, true]) // keine Sperre: weder für frische JWTs noch für createJWT überhaupt
})