Files
tickte-system/tests/unit/workersHarness.test.mjs
ClaudeandClaude Sonnet 5.5 2652108e4c Hustler-Stufe: Prüfstand baut mit VITE_HUSTLER_STUFE=6, Notiz zum Hochsetzen beim Livegang von P4 und P6
build.mjs setzt die Stufe über define ein (Standard 6, andere Ziffer 1 bis 9 aus der Umgebung, Ungültiges bricht ab), damit die Szenarien K (Pult P4) und L (Keller) weiter laufen;
ein Test baut den Prüfstand mit Standard, 3 und ungültigen Werten. docs/superpowers/hustler-stufe.md erklärt Stufen, Sperren und die Schritte beim Hochsetzen.

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

230 lines
14 KiB
JavaScript

import test from 'node:test'
import assert from 'node:assert/strict'
import { execFileSync } from 'node:child_process'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { loadModule } from './load.mjs'
// Prüfstand der Hustler-Seite: die Skript-API (gleiche Form wie createWorkersApi) für die manuelle Playwright-Abnahme.
// Alles mit einer eigenen Uhr: keine echte Zeit, kein Netz.
const { createScriptedApi, SCRIPT } = await loadModule('scripts/workers-harness/scripted-api.js')
const { EVENT_KINDS } = await loadModule('src/lib/workersEvents.js')
const { SEARCH_ROOMS, ROLES, roleOf } = await loadModule('src/lib/workersRoster.js')
const ms = (iso) => Date.parse(iso)
test('Skript deckt alle 26 Ereignisarten und mindestens 15 Worker ab; erstes Ereignis nicht vor 2 s', () => {
assert.deepEqual(new Set(SCRIPT.map((s) => s.event.kind)), new Set(EVENT_KINDS))
assert.ok(new Set(SCRIPT.map((s) => s.event.workerId).filter((w) => w !== 'system')).size >= 15)
assert.ok(Math.min(...SCRIPT.map((s) => s.at)) >= 2)
})
test('scripted api: 19 Worker (kim, pia, finn nicht angeschlossen); events liefert wie der Dienst ab after - 5 s, nie aus der Zukunft; Pause folgt', async () => {
let t = 0
const api = createScriptedApi({ now: () => t })
const s = await api.state()
assert.equal(s.workers.length, 19)
assert.deepEqual(s.workers.filter((w) => !w.connected).map((w) => w.workerId).sort(), ['finn', 'kim', 'pia'])
// Grau-Regel (P1-E9): nur Such-Räume können nicht angeschlossen sein, die anderen elf sind es immer
assert.ok(s.workers.filter((w) => !w.connected).every((w) => SEARCH_ROOMS.includes(roleOf(w.workerId).room)))
assert.deepEqual(s.workers.map((w) => w.workerId), ROLES.map((r) => r.workerId))
assert.ok(new Set(s.workers.map((w) => w.status)).size >= 3) // gemischte Status
const base = ms(s.lastEventTs)
assert.ok(Number.isFinite(base))
assert.equal((await api.events(null)).events.length, 0) // nichts fällig bei 0 s
// PF-09: Antwort nach P1-Semantik (ts >= after - 5000, aufsteigend, nur Fälliges); die Entdopplung übernimmt der Feed (T05)
t = 10000
const first = (await api.events(s.lastEventTs)).events
assert.ok(first.length > 0)
assert.ok(first.every((e) => ms(e.ts) <= base + 10000)) // nichts aus der Zukunft
assert.ok(first.every((e, i) => i === 0 || ms(first[i - 1].ts) <= ms(e.ts))) // aufsteigend
const last = first.at(-1)
const again = (await api.events(last.ts)).events
assert.ok(again.some((e) => e.id === last.id)) // Überlappung: das letzte Ereignis kommt wieder
assert.ok(again.every((e) => ms(e.ts) >= ms(last.ts) - 5000 && ms(e.ts) <= base + 10000))
assert.equal(new Set(again.map((e) => e.id)).size, again.length) // innerhalb einer Antwort nie doppelt
assert.equal((await api.events(null, 2)).events.length, 2) // limit: die ältesten
assert.deepEqual((await api.events(null, 2)).events.map((e) => e.id), first.slice(0, 2).map((e) => e.id))
t = 30000 // später: Neues kommt dazu
const later = (await api.events(last.ts)).events
assert.ok(later.some((e) => !first.some((f) => f.id === e.id)))
assert.ok(later.every((e) => ms(e.ts) <= base + 30000))
await api.pause(true)
assert.equal((await api.state()).paused, true)
await api.pause(false)
assert.equal((await api.state()).paused, false)
assert.equal(s.sendingHalted, false) // Standard: nicht gestoppt
assert.equal(s.sendingHaltedReason, '')
// H4: gestoppt, Grund sichtbar, Freigabe über saveSettings
const h = createScriptedApi({ now: () => 0, halted: true })
const hs = await h.state()
assert.equal(hs.sendingHalted, true)
assert.match(hs.sendingHaltedReason, /Bounces/)
assert.equal((await h.settings()).sendingHalted, true)
const saved = await h.saveSettings({ sendingHalted: false })
assert.equal(saved.sendingHalted, false)
assert.equal(saved.sendingHaltedReason, '')
assert.deepEqual(h.saved, [{ sendingHalted: false }])
assert.equal((await h.state()).sendingHalted, false)
assert.equal((await h.settings()).sendingHalted, false)
assert.equal((await h.settings()).sendingHaltedReason, '')
})
test('scripted api: Spur mit 12 Ereignissen (unsicherer und sicherer Link), Tageswerte, Standard-Einstellungen, Zustand folgt den Ereignissen', async () => {
let t = 0
const api = createScriptedApi({ now: () => t })
const tr = await api.trace('kim')
assert.equal(tr.worker.workerId, 'kim')
assert.equal(tr.worker.name, 'Kim')
assert.equal(tr.worker.modelId, 'claude-haiku-5-5')
assert.equal(tr.events.length, 12)
assert.ok(tr.events.some((e) => e.link === 'javascript:alert(1)'))
assert.ok(tr.events.some((e) => e.link === 'https://example.org/fund'))
assert.ok(tr.events.every((e, i) => i === 0 || ms(tr.events[i - 1].ts) >= ms(e.ts))) // neueste zuerst
assert.ok(Number.isFinite(tr.today.tokens.in) && Number.isFinite(tr.today.tokens.out) && Number.isFinite(tr.today.costUsd))
assert.ok(Object.keys((await api.trace('rita')).today.counters).length > 0)
assert.deepEqual(tr.today.tokens, { in: 18400, out: 2300 })
assert.equal((await api.trace('bob')).worker.modelId, 'claude-opus-5-5')
assert.equal((await api.trace('bea')).worker.modelId, 'claude-sonnet-5-5')
const st = await api.settings() // Standardwerte aus Übersicht §4.1
assert.deepEqual(
[st.dailyBudgetUsd, st.monthlyBudgetUsd, st.warnPct, st.mailColdPerDay, st.mailReplyCapPerDay, st.sendWindowStart, st.sendWindowEnd, st.sendWeekdaysOnly],
[6, 150, 80, 3, 15, '09:00', '16:00', true])
assert.deepEqual([st.runsPerNight, st.candidatesPerRun, st.previewGate, st.previewGateN, st.retentionDays, st.offerProductKey], [1, 10, 'erste_n', 10, 90, 'hosting'])
assert.deepEqual(st.regions, ['Rheinland-Pfalz'])
assert.equal(st.privacyInteressentenLive, false)
assert.equal(st.sendingHalted, false)
assert.equal(st.sendingHaltedReason, '')
assert.ok(st.offerText.length >= 1 && st.offerText.length <= 400)
assert.deepEqual(st.connectedRooms.filter((r) => !SEARCH_ROOMS.includes(r)), []) // nur Such-Räume
assert.ok(!st.connectedRooms.includes('kleinanzeigen') && !st.connectedRooms.includes('portale') && !st.connectedRooms.includes('foren'))
const copy = await api.settings()
copy.dailyBudgetUsd = 99 // Antworten sind Kopien: der Speicher des Prüfstands bleibt heil
assert.equal((await api.settings()).dailyBudgetUsd, 6)
await api.saveSettings({ dailyBudgetUsd: 4 })
assert.equal((await api.settings()).dailyBudgetUsd, 4)
assert.deepEqual(api.saved, [{ dailyBudgetUsd: 4 }])
// Der Zustand schreibt die fälligen Ereignisse fort (wie der Dienst), lastEventTs rückt auf das jüngste fällige Ereignis
const s0 = await api.state()
t = 60000
const s1 = await api.state()
assert.ok(ms(s1.lastEventTs) > ms(s0.lastEventTs))
assert.equal(s1.workers.length, 19)
assert.deepEqual(s1.workers.filter((w) => !w.connected).map((w) => w.workerId).sort(), ['finn', 'kim', 'pia'])
assert.ok(s1.workers.filter((w) => !w.connected).every((w) => w.status === 'grau'))
assert.ok(s1.workers.find((w) => w.workerId === 'otto').task !== s0.workers.find((w) => w.workerId === 'otto').task)
for (const key of ['newAbosMonth', 'mrrNewEur', 'dealsMonth', 'openThreads', 'pultOpen', 'botLeads', 'apiTodayUsd', 'apiMonthUsd', 'dailyBudgetUsd', 'monthlyBudgetUsd']) assert.ok(Number.isFinite(s1.kpis[key]), key)
assert.deepEqual(await api.pult('offen'), { items: [] })
// Bot-Leads (P6): wie GET /botleads des Dienstes { items, total, pageSize }: 60 sichtbare Einträge in vier Räumen auf zwei Seiten, nie Telefon bei Heilberufen,
// gekürzte Stubs weder in der Liste noch in total, aber mit id erreichbar; 400 bei unbekanntem Raum
const bl = await api.botleads({})
assert.deepEqual(Object.keys(bl).sort(), ['items', 'pageSize', 'total'])
assert.deepEqual([bl.total, bl.pageSize, bl.items.length], [60, 50, 50])
assert.deepEqual(Object.keys(bl.items[0]).sort(), ['city', 'foundAt', 'gekuerzt', 'id', 'isHealth', 'name', 'phone', 'phoneSource', 'postalCode', 'reason', 'room', 'roomLabel', 'score', 'source', 'sourceUrl', 'street', 'website'])
const blTwo = await api.botleads({ page: 2 })
assert.equal(blTwo.items.length, 10)
assert.equal(new Set([...bl.items, ...blTwo.items].map((e) => e.id)).size, 60)
assert.ok([...bl.items, ...blTwo.items].every((e, i, all) => i === 0 || Date.parse(all[i - 1].foundAt) >= Date.parse(e.foundAt)))
assert.deepEqual(new Set([...bl.items, ...blTwo.items].map((e) => e.room)), new Set(['aerzte', 'handwerk', 'selbst', 'laden']))
assert.ok([...bl.items, ...blTwo.items].every((e) => e.gekuerzt === false && e.name !== '[gekürzt]' && e.foundAt.endsWith('Z')))
assert.ok([...bl.items, ...blTwo.items].filter((e) => e.isHealth).every((e) => e.phone === '' && e.phoneSource === ''))
assert.ok([...bl.items, ...blTwo.items].some((e) => !e.isHealth && e.phone !== '' && e.phoneSource !== ''))
assert.ok(api.botleadsHealthPhones.length > 0 && !JSON.stringify([bl, blTwo]).includes(api.botleadsHealthPhones[0]))
assert.equal((await api.botleads({ room: 'handwerk' })).total, 15)
assert.equal((await api.botleads({ q: 'Qwertz' })).total, 0)
assert.deepEqual((await api.botleads({ id: 'bl-gekuerzt-01' })).items.map((e) => [e.name, e.gekuerzt]), [['[gekürzt]', true]])
assert.deepEqual(await api.botleads({ id: 'gibt-es-nicht' }), { items: [], total: 0, pageSize: 50 })
await assert.rejects(api.botleads({ room: 'kaffee' }), (err) => err.status === 400 && err.message === 'Unbekannter Raum.')
})
// ---- Endprüfung P2: Fetch-Weiche des Prüfstands (echter API-Client gegen die Skript-API, mit Ausfall-Schalter) ----
const { createScriptedFetch } = await loadModule('scripts/workers-harness/scripted-fetch.js')
const { createWorkersApi } = await loadModule('src/lib/workersApi.js')
function wired(fault = null, extra = {}) {
const control = { fault, calls: [], jwts: [] }
const scripted = createScriptedApi({ now: () => 0 })
const api = createWorkersApi({
getJwt: async (o) => {
control.jwts.push(o?.fresh === true)
return 'harness-jwt'
},
fetchImpl: createScriptedFetch(scripted, control),
...extra,
})
return { control, scripted, api }
}
test('Fetch-Weiche gesund: der echte API-Client erreicht Zustand, Ereignisse, Spur, Pause und Einstellungen der Skript-API', async () => {
const { control, scripted, api } = wired()
assert.equal((await api.state()).workers.length, 19)
assert.deepEqual(await api.events(null, 5), { events: [] })
assert.equal((await api.trace('kim')).worker.name, 'Kim')
assert.deepEqual(await api.pause(true), { paused: true })
assert.equal((await api.state()).paused, true)
assert.equal((await api.settings()).paused, true)
await api.saveSettings({ sendingHalted: false })
assert.deepEqual(scripted.saved, [{ sendingHalted: false }])
await assert.rejects(api.trace('gibt-es-nicht'), { message: 'Diesen Hustler gibt es nicht.', status: 404 })
assert.deepEqual(control.calls.map((c) => c.path).slice(0, 3), ['/state', '/events', '/worker/kim/trace'])
assert.ok(control.calls.every((c) => c.jwt === 'harness-jwt'))
})
test('Fetch-Weiche down: 503 ohne JSON ergibt die Dienst-Meldung, session: 401 ergibt die Anmelde-Meldung, danach nur ein createJWT je Sperre', async () => {
const down = wired('down')
await assert.rejects(down.api.state(), { message: 'Der Hustler-Dienst ist gerade nicht erreichbar.', status: 503 })
const session = wired('session')
for (let i = 0; i < 6; i += 1) await assert.rejects(session.api.events(null), { message: 'Anmeldung abgelaufen. Bitte neu anmelden.', status: 401 })
assert.equal(session.control.jwts.filter(Boolean).length, 1)
assert.equal(session.control.calls.length, 7) // 1 Versuch + 1 Wiederholung, dann je Aufruf eine Anfrage
// Schalter zurück: der Dienst nimmt das JWT wieder an, ohne dass etwas neu erzeugt wird
session.control.fault = null
assert.equal((await session.api.state()).workers.length, 19)
assert.equal(session.control.jwts.filter(Boolean).length, 1)
})
test('Fetch-Weiche hang: die Antwort kommt nie, der API-Client gibt nach der Zeitgrenze auf', async () => {
const { api } = wired('hang', { timeoutMs: 40 })
await assert.rejects(api.state(), { message: 'Der Hustler-Dienst ist gerade nicht erreichbar.', status: 0 })
})
// ---------- Stufen-Sperre (hustlerStufe.js): der Prüfstand baut mit Stufe 6 ----------
test('Prüfstand: build.mjs baut standardmäßig mit Stufe 6, nimmt eine andere Stufe 1..9 aus VITE_HUSTLER_STUFE und lehnt Ungültiges ab', () => {
const tmp = fs.realpathSync(os.tmpdir())
const build = fileURLToPath(new URL('../../scripts/workers-harness/build.mjs', import.meta.url))
const lauf = (wert) => {
const dir = fs.mkdtempSync(path.join(tmp, 'hk-stufe-'))
assert.ok(dir.startsWith(tmp + path.sep))
try {
const env = { ...process.env, TMPDIR: tmp }
delete env.VITE_HUSTLER_STUFE
if (wert !== undefined) env.VITE_HUSTLER_STUFE = wert
let ausgabe = ''; let code = 0
try {
ausgabe = execFileSync(process.execPath, ['--max-old-space-size=512', build, dir], { env, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 120000 })
} catch (err) {
code = err.status ?? 1
}
return { code, ausgabe, js: fs.existsSync(path.join(dir, 'harness.js')) ? fs.readFileSync(path.join(dir, 'harness.js'), 'utf8') : '' }
} finally {
fs.rmSync(dir, { recursive: true, force: true })
}
}
const standard = lauf(undefined)
assert.equal(standard.code, 0); assert.match(standard.ausgabe, /\(Stufe 6\)/); assert.match(standard.js, /function buildWert\(\) \{\s*try \{\s*return "6";/, 'Stufe 6 ist in den Prüfstand eingesetzt')
const produktion = lauf('3')
assert.equal(produktion.code, 0); assert.match(produktion.ausgabe, /\(Stufe 3\)/); assert.match(produktion.js, /function buildWert\(\) \{\s*try \{\s*return "3";/)
for (const ungueltig of ['0', '10', 'abc', '']) {
const r = lauf(ungueltig)
assert.equal(r.code, 2, `VITE_HUSTLER_STUFE=${JSON.stringify(ungueltig)}`); assert.equal(r.js, '')
}
})