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, '') } })