Files
tickte-system/tests/unit/botleadList.test.mjs
T

329 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
})