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>
709 lines
36 KiB
JavaScript
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
|
|
})
|