Files
ClaudeandClaude Sonnet 5.5 6eb4dd3c51 Hustler-Stufe: Keller begehbar ab Stufe 3, Bot-Leads abrufbar erst ab Stufe 6
Die Stufen-Sperre trennt jetzt zwei Dinge: KELLER_BEGEHBAR_AB = 3 (Keller-Etage als Link,
?tab=botleads gueltig, Spur-Links in den Keller) und BOTLEADS_AB = 6 (Abruf von GET /botleads).
kellerOffen/STUFE_KELLER entfallen, dafuer kellerBegehbar() und botleadsAbrufbar().
workersApi.botleads bleibt unter Stufe 6 hart gesperrt, der Text heisst nun
BOTLEADS_GESPERRT_TEXT. Die Keller-Ansicht selbst (leeres Register ohne Abruf) folgt im
naechsten Commit.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 12:21:59 +00:00

732 lines
37 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, BOTLEADS_GESPERRT_TEXT } = 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, stufe: 6 }) // Stufe 6: der Keller (botleads) ist offen; darunter ruft der Client /botleads nie ab (hustlerStufe.test.mjs)
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
})
// ---------- Stufen-Sperre (hustlerStufe.js): kein Abruf der Bot-Leads unterhalb von Stufe 6 (BOTLEADS_AB; der Keller selbst ist schon ab Stufe 3 begehbar, dann leer) ----------
test('API: auf Stufe 3 geht kein Abruf von /api/workers/botleads hinaus (auch kein JWT), auf Stufe 6 wie bisher', async () => {
for (const s of [1, 3, 4, 5]) {
const f = fakeFetch(okReplies(8)); let jwtGeholt = 0
const api = createWorkersApi({ getJwt: async () => { jwtGeholt += 1; return 'jwt' }, fetchImpl: f, stufe: s })
for (const params of [undefined, {}, { room: 'handwerk' }, { q: 'Bäcker', page: 2 }, { id: 'abc' }]) {
await assert.rejects(api.botleads(params), (err) => err.status === 404 && err.message === BOTLEADS_GESPERRT_TEXT, `Stufe ${s}`)
}
assert.equal(f.calls.length, 0, `Stufe ${s}`); assert.equal(jwtGeholt, 0, `Stufe ${s}`)
// die übrigen Abrufe der Stufe 3 laufen
await api.state(); await api.pult(); await api.pultAction('p1', 'claim'); await api.prospect('p1')
assert.deepEqual(f.calls.map((c) => c.url), ['/api/workers/state', '/api/workers/pult?status=offen', '/api/workers/pult/p1/claim', '/api/workers/prospect/p1'])
}
for (const s of [6, 9]) {
const f = fakeFetch(okReplies(1))
const api = createWorkersApi({ getJwt: async () => 'jwt', fetchImpl: f, stufe: s })
await api.botleads({ room: 'handwerk', page: 2 })
assert.deepEqual(f.calls.map((c) => [c.init.method || 'GET', c.url]), [['GET', '/api/workers/botleads?room=handwerk&page=2']])
}
assert.match(BOTLEADS_GESPERRT_TEXT, /Bot-Leads/)
})