import test from 'node:test' import assert from 'node:assert/strict' import path from 'node:path' import { fileURLToPath } from 'node:url' import esbuild from 'esbuild' import { fakeReact } from './helpers/fakeReact.mjs' // BotLeadList läuft wirklich: im Mini-Läufer anstelle von React und DOM (Muster workersPultUi.test.mjs, Baustein helpers/fakeReact.mjs). // Gerendert wird in einen Baum aus { tag, props, children }; Eingaben und Klicks ruft der Test von Hand auf, die Zeit (window.setTimeout) // läuft von Hand, der Dienst ist eine Attrappe mit offenen Versprechen. const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..') const importBundle = (out) => import(`data:text/javascript;base64,${Buffer.from(out.outputFiles[0].text).toString('base64')}`) const settle = () => new Promise((resolve) => setImmediate(resolve)) // Die Komponente wird einmal gebündelt; jeder Test hängt sie neu ein (eigener Zustand) let bundle const loadUi = () => (bundle ??= esbuild.build({ stdin: { contents: "export { __mount, jsx } from 'react'\nexport { default as BotLeadList } from './src/components/workers/BotLeadList.jsx'", resolveDir: ROOT, loader: 'js' }, bundle: true, format: 'esm', platform: 'neutral', write: false, jsx: 'automatic', loader: { '.jsx': 'jsx' }, plugins: [fakeReact], }).then(importBundle)) // Baum durchsuchen const nodes = (node, match, out = []) => { if (node === null || typeof node === 'string') return out if (match(node)) out.push(node) for (const child of node.children) nodes(child, match, out) return out } const textOf = (node) => (typeof node === 'string' ? node : node === null ? '' : node.children.map(textOf).join('')) const hasClass = (n, name) => String(n.props.className ?? '').split(' ').includes(name) const buttons = (tree, label) => nodes(tree, (n) => n.tag === 'button' && textOf(n).trim() === label) // Dienst-Attrappe: jeder Aufruf von botleads ist ein offenes Versprechen, das der Test selbst auflöst function fakeApi() { const calls = [] return { calls, botleads: (params) => new Promise((resolve, reject) => { calls.push({ params, resolve, reject }) }) } } const ITEM = { id: 'p1', room: 'handwerk', roomLabel: 'Handwerk', name: 'Elektro Beispiel GmbH', postalCode: '67655', city: 'Kaiserslautern', score: 82, reason: 'keine veröffentlichte Mail', sourceUrl: 'https://beispiel-elektro.de/impressum', phone: '0631 111', phoneSource: 'https://beispiel-elektro.de/impressum', isHealth: false, foundAt: '2026-10-19T20:00:00Z', gekuerzt: false, } const PRAXIS = { ...ITEM, id: 'p2', room: 'aerzte', roomLabel: 'Ärzte & Praxen', name: 'Praxis Dr. Muster', isHealth: true, phone: '', phoneSource: '', sourceUrl: 'javascript:alert(1)', foundAt: '2026-10-18T20:00:00Z' } const KUERZ = { ...ITEM, id: 'p3', name: '[gekürzt]', gekuerzt: true, phone: '', phoneSource: '', sourceUrl: '' } // Ein Lauf: Komponente einhängen, window.setTimeout von Hand, Props zwischendurch änderbar (rerender) async function start(api, initial = {}) { const { __mount, jsx, BotLeadList } = await loadUi() const timers = new Map() let nextTimer = 1 const saved = globalThis.window globalThis.window = { setTimeout: (fn, ms) => { const id = nextTimer++; timers.set(id, { fn, ms }); return id }, clearTimeout: (id) => { timers.delete(id) }, } let props = { api, ...initial } const mounted = __mount(jsx(() => jsx(BotLeadList, props), {})) const view = { timers, tree: () => mounted.tree(), text: () => textOf(view.tree()), setProps(next) { props = { ...props, ...next }; mounted.rerender() }, rows: () => nodes(view.tree(), (n) => n.tag === 'tr').filter((tr) => nodes(tr, (n) => n.tag === 'td').length > 0), cells: (tr) => nodes(tr, (n) => n.tag === 'td').map((td) => textOf(td).trim()), select: () => nodes(view.tree(), (n) => n.tag === 'select')[0], search: () => nodes(view.tree(), (n) => n.tag === 'input')[0], errors: () => nodes(view.tree(), (n) => hasClass(n, 'msg') && hasClass(n, 'err')).map((n) => ({ text: textOf(n), role: n.props.role })), async pick(room) { view.select().props.onChange({ target: { value: room } }); await settle() }, async type(value) { view.search().props.onChange({ target: { value } }); await settle() }, async click(label) { const found = buttons(view.tree(), label) assert.equal(found.length, 1, `Knopf „${label}“ (gefunden: ${found.length})`) if (!found[0].props.disabled) found[0].props.onClick({ preventDefault() {} }) await settle() }, disabled: (label) => buttons(view.tree(), label).map((n) => n.props.disabled === true), fireTimers() { for (const [id, t] of [...timers]) { timers.delete(id); t.fn() } }, // der Dienst antwortet auf den n-ten Aufruf async answer(n, data) { api.calls[n].resolve(data); await settle() }, async fail(n, err) { api.calls[n].reject(err); await settle() }, lateUpdates: () => mounted.lateUpdates(), end() { mounted.unmount(); globalThis.window = saved }, } await settle() return view } test('lädt beim Start (Raum alle, keine Suche, Seite 1), zeigt zuerst „Wird geladen“, dann die Zeilen', async () => { const api = fakeApi() const ui = await start(api) try { assert.deepEqual(api.calls.map((c) => c.params), [{ room: '', q: '', page: 1 }]) assert.ok(ui.text().includes('Reine Liste: keine Anrufe, kein Mailversand.')) assert.ok(ui.text().includes('Wird geladen')) assert.equal(ui.rows().length, 0) await ui.answer(0, { items: [ITEM, PRAXIS], total: 2, pageSize: 50 }) assert.ok(!ui.text().includes('Wird geladen')) assert.deepEqual(nodes(ui.tree(), (n) => n.tag === 'th').map((n) => textOf(n)), ['Betrieb', 'Raum', 'Punkte', 'Grund', 'Telefon', 'Quelle', 'Gefunden']) const rows = ui.rows() assert.equal(rows.length, 2) assert.deepEqual(ui.cells(rows[0]), ['Elektro Beispiel GmbH67655 Kaiserslautern', 'Handwerk', '82 Punkte', 'keine veröffentlichte Mail', '0631 111', 'Quelle öffnen', '19.10.2026']) // Heilberuf: nie eine Nummer, unsichere Quelle ist kein Link assert.deepEqual(ui.cells(rows[1]).slice(4), ['bewusst nicht gespeichert', '–', '18.10.2026']) assert.equal(rows[0].props['aria-current'], undefined) } finally { ui.end() } }) test('Quelle: Link nur über die geprüfte Adresse, neuer Tab ohne Rückbezug; kein Anruf-Link', async () => { const api = fakeApi() const ui = await start(api) try { await ui.answer(0, { items: [ITEM, PRAXIS, { ...ITEM, id: 'p4', sourceUrl: 'https://user:pw@evil.example/' }], total: 3 }) const links = nodes(ui.tree(), (n) => n.tag === 'a') assert.equal(links.length, 1) assert.deepEqual([links[0].props.href, links[0].props.target, links[0].props.rel], ['https://beispiel-elektro.de/impressum', '_blank', 'noopener noreferrer']) assert.equal(nodes(ui.tree(), (n) => n.props && typeof n.props.href === 'string' && n.props.href.startsWith('tel:')).length, 0) } finally { ui.end() } }) test('gekürzte Einträge sind grau und tragen „gekürzt“', async () => { const api = fakeApi() const ui = await start(api) try { await ui.answer(0, { items: [ITEM, KUERZ], total: 2 }) const [normal, cut] = ui.rows() assert.equal(hasClass(normal, 'wk-bl-cut'), false) assert.equal(hasClass(cut, 'wk-bl-cut'), true) assert.ok(textOf(cut).includes('gekürzt')) assert.ok(!textOf(normal).includes('gekürzt')) } finally { ui.end() } }) test('Raumfilter: aus ROOM_FILTERS, neue Anfrage mit Seite 1; nur die neueste Anfrage zählt', async () => { const api = fakeApi() const ui = await start(api) try { assert.deepEqual(nodes(ui.select(), (n) => n.tag === 'option').map((o) => [o.props.value, textOf(o)]), [['', 'Alle Räume'], ['aerzte', 'Ärzte & Praxen'], ['handwerk', 'Handwerk'], ['selbst', 'Selbstständige'], ['laden', 'Läden & Gastro']]) await ui.answer(0, { items: [ITEM], total: 120, pageSize: 50 }) await ui.click('Weiter') assert.deepEqual(api.calls[1].params, { room: '', q: '', page: 2 }) await ui.answer(1, { items: [PRAXIS], total: 120, pageSize: 50 }) // Raum wechseln: zurück auf Seite 1, genau eine neue Anfrage await ui.pick('handwerk') assert.equal(api.calls.length, 3) assert.deepEqual(api.calls[2].params, { room: 'handwerk', q: '', page: 1 }) assert.equal(ui.select().props.value, 'handwerk') // noch einmal gewechselt, bevor die erste Antwort kommt; die späte Antwort der älteren Anfrage darf nichts überschreiben await ui.pick('laden') assert.deepEqual(api.calls[3].params, { room: 'laden', q: '', page: 1 }) await ui.answer(2, { items: [ITEM], total: 1 }) assert.equal(ui.rows().length, 0) // „laden“ lädt noch, der alte Raum zeigt nichts assert.ok(ui.text().includes('Wird geladen')) await ui.answer(3, { items: [PRAXIS], total: 1 }) assert.deepEqual(ui.rows().map((r) => ui.cells(r)[0]), ['Praxis Dr. Muster67655 Kaiserslautern']) // derselbe Raum noch einmal gewählt: keine neue Anfrage await ui.pick('laden') assert.equal(api.calls.length, 4) } finally { ui.end() } }) test('Suche: 300 ms Verzögerung, nur die letzte Eingabe wird abgefragt, Seite springt auf 1', async () => { const api = fakeApi() const ui = await start(api) try { await ui.answer(0, { items: [ITEM], total: 120, pageSize: 50 }) await ui.click('Weiter') await ui.answer(1, { items: [ITEM], total: 120, pageSize: 50 }) assert.equal(api.calls.length, 2) for (const value of ['b', 'bä', ' bäckerei ']) await ui.type(value) assert.equal(ui.search().props.value, ' bäckerei ') // das Feld zeigt sofort, was getippt wurde assert.equal(api.calls.length, 2) // noch keine Anfrage assert.deepEqual([...ui.timers.values()].map((t) => t.ms), [300]) // nur der letzte Zeitgeber lebt ui.fireTimers(); await settle() assert.equal(api.calls.length, 3) assert.deepEqual(api.calls[2].params, { room: '', q: 'bäckerei', page: 1 }) // getrimmt, Seite 1 // derselbe Suchbegriff noch einmal: keine neue Anfrage await ui.type('bäckerei'); ui.fireTimers(); await settle() assert.equal(api.calls.length, 3) assert.equal(ui.search().props.maxLength, 100) } finally { ui.end() } }) test('Fehler: der Satz aus der API steht als Meldung, keine Liste; der nächste Erfolg räumt ihn weg', async () => { const api = fakeApi() const ui = await start(api) try { await ui.fail(0, Object.assign(new Error('Der Hustler-Dienst ist gerade nicht erreichbar.'), { status: 0 })) assert.deepEqual(ui.errors(), [{ text: 'Der Hustler-Dienst ist gerade nicht erreichbar.', role: 'alert' }]) assert.equal(ui.rows().length, 0) assert.ok(!ui.text().includes('Noch keine Bot-Leads.')) await ui.pick('laden') await ui.answer(1, { items: [ITEM], total: 1 }) assert.deepEqual(ui.errors(), []) assert.equal(ui.rows().length, 1) // ein Fehler ohne Satz zeigt trotzdem einen deutschen Text await ui.pick('selbst') await ui.fail(2, {}) assert.deepEqual(ui.errors().map((e) => e.text), ['Unbekannter Fehler']) // ein später Fehler einer älteren Anfrage zählt nicht await ui.pick('aerzte'); await ui.pick('handwerk') await ui.fail(3, new Error('zu spät')) assert.deepEqual(ui.errors(), []) } finally { ui.end() } }) test('Leerzustände: ohne Filter „Noch keine Bot-Leads.“, mit Filter oder Suche „Keine Treffer.“', async () => { const api = fakeApi() const ui = await start(api) try { await ui.answer(0, { items: [], total: 0 }) assert.ok(ui.text().includes('Noch keine Bot-Leads.')) assert.equal(nodes(ui.tree(), (n) => n.tag === 'table').length, 0) assert.ok(!ui.text().includes('Keine Einträge')) // keine Seitenzeile await ui.pick('laden') await ui.answer(1, { items: [], total: 0 }) assert.ok(ui.text().includes('Keine Treffer.')) assert.ok(!ui.text().includes('Noch keine Bot-Leads.')) await ui.pick('') await ui.type('xyz'); ui.fireTimers(); await settle() await ui.answer(3, { items: [], total: 0 }) assert.ok(ui.text().includes('Keine Treffer.')) } finally { ui.end() } }) test('Blättern: Seitenzeile, Zurück und Weiter, pageSize aus der Antwort', async () => { const api = fakeApi() const ui = await start(api) try { await ui.answer(0, { items: [ITEM], total: 120, pageSize: 50 }) assert.ok(ui.text().includes('Seite 1 von 3 · 120 Einträge')) assert.deepEqual([ui.disabled('Zurück'), ui.disabled('Weiter')], [[true], [false]]) await ui.click('Zurück') // gesperrt: nichts passiert assert.equal(api.calls.length, 1) await ui.click('Weiter') await ui.answer(1, { items: [ITEM], total: 120, pageSize: 50 }) assert.deepEqual(api.calls[1].params, { room: '', q: '', page: 2 }) assert.ok(ui.text().includes('Seite 2 von 3 · 120 Einträge')) await ui.click('Weiter') await ui.answer(2, { items: [ITEM], total: 120, pageSize: 50 }) assert.deepEqual([ui.disabled('Zurück'), ui.disabled('Weiter')], [[false], [true]]) await ui.click('Zurück') assert.deepEqual(api.calls[3].params, { room: '', q: '', page: 2 }) // während einer Anfrage ist die Seitenzeile weg: kein zweiter Klick auf eine veraltete Seitenzahl assert.equal(buttons(ui.tree(), 'Weiter').length, 0) } finally { ui.end() } }) test('Blättern: eine Seite genügt ⇒ nur die Zahl, kein Blättern; Einzahl bei einem Eintrag', async () => { const api = fakeApi() const ui = await start(api) try { await ui.answer(0, { items: [ITEM], total: 1, pageSize: 50 }) assert.ok(ui.text().includes('Seite 1 von 1 · 1 Eintrag')) assert.equal(buttons(ui.tree(), 'Weiter').length + buttons(ui.tree(), 'Zurück').length, 0) } finally { ui.end() } }) test('Blättern: ist die Seite nach einem Kürzen leer, springt die Liste auf die letzte Seite', async () => { const api = fakeApi() const ui = await start(api) try { await ui.answer(0, { items: [ITEM], total: 120, pageSize: 50 }) await ui.click('Weiter'); await ui.answer(1, { items: [ITEM], total: 120, pageSize: 50 }) await ui.click('Weiter') assert.deepEqual(api.calls[2].params, { room: '', q: '', page: 3 }) await ui.answer(2, { items: [], total: 60, pageSize: 50 }) // inzwischen nur noch zwei Seiten assert.deepEqual(api.calls[3].params, { room: '', q: '', page: 2 }) assert.ok(ui.text().includes('Wird geladen')) await ui.answer(3, { items: [ITEM], total: 60, pageSize: 50 }) assert.ok(ui.text().includes('Seite 2 von 2 · 60 Einträge')) assert.equal(api.calls.length, 4) } finally { ui.end() } }) test('bot: ruft die API mit id ab, hebt die Zeile hervor und bietet „Alle anzeigen“ statt der Filter an', async () => { const api = fakeApi() let cleared = 0 const ui = await start(api, { bot: 'p1', onClearBot: () => { cleared += 1 } }) try { assert.deepEqual(api.calls.map((c) => c.params), [{ id: 'p1' }]) assert.equal(nodes(ui.tree(), (n) => n.tag === 'select' || n.tag === 'input').length, 0) // Filter und Suche wirken auf einen Einzeleintrag nicht await ui.answer(0, { items: [ITEM], total: 1, pageSize: 50 }) const rows = ui.rows() assert.equal(rows.length, 1) assert.equal(rows[0].props['aria-current'], 'true') assert.equal(buttons(ui.tree(), 'Weiter').length, 0) // keine Seitenzeile bei einem Einzeleintrag await ui.click('Alle anzeigen') assert.equal(cleared, 1) // die Seite nimmt bot aus der Adresse: die Liste lädt wieder alle (Filter und Suche zurück) ui.setProps({ bot: '' }) await settle() assert.deepEqual(api.calls[1].params, { room: '', q: '', page: 1 }) assert.equal(ui.select().props.value, '') } finally { ui.end() } }) test('bot: ein anderer Eintrag über die Adresse lädt neu; nur die neueste Anfrage zählt; nicht gefunden ⇒ Hinweis mit „Alle anzeigen“', async () => { const api = fakeApi() const ui = await start(api, { bot: 'p1', onClearBot: () => {} }) try { ui.setProps({ bot: 'p2' }); await settle() assert.deepEqual(api.calls.map((c) => c.params), [{ id: 'p1' }, { id: 'p2' }]) await ui.answer(0, { items: [ITEM], total: 1 }) // späte Antwort für p1 assert.equal(ui.rows().length, 0) await ui.answer(1, { items: [], total: 0 }) assert.ok(ui.text().includes('Dieser Eintrag ist nicht (mehr) in der Liste.')) assert.ok(!ui.text().includes('Noch keine Bot-Leads.')) assert.equal(buttons(ui.tree(), 'Alle anzeigen').length, 1) // ein Fehler (ungültige Kennung) lässt den Weg zurück offen ui.setProps({ bot: 'x y' }); await settle() await ui.fail(2, Object.assign(new Error('Ungültige Kennung.'), { status: 400 })) assert.deepEqual(ui.errors().map((e) => e.text), ['Ungültige Kennung.']) assert.equal(buttons(ui.tree(), 'Alle anzeigen').length, 1) } finally { ui.end() } }) test('nach dem Abbau setzt eine späte Antwort nichts mehr, ein offener Zeitgeber der Suche wird gelöscht', async () => { const api = fakeApi() const ui = await start(api) await ui.type('abc') assert.equal(ui.timers.size, 1) ui.end() assert.equal(ui.timers.size, 0) api.calls[0].resolve({ items: [ITEM], total: 1 }) await settle() assert.equal(ui.lateUpdates(), 0) })