workers: Reiter Bot-Leads mit Filter, Grund, Telefon und Quelle
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d1b7eddd10
commit
3d830ced38
12 files changed
+806
-5
No files matched your search
@@ -0,0 +1,328 @@
|
||||
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)
|
||||
})
|
||||
@@ -0,0 +1,98 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import { loadModule } from './load.mjs'
|
||||
|
||||
const { safeHref, phoneLabel, botleadRow, ROOM_FILTERS, pageInfo } = await loadModule('src/lib/botleadsView.js')
|
||||
|
||||
// Die React-Teile sind unter Node nicht ladbar (JSX): der Quelltext wird geprüft (Muster workersComponents.test.mjs); das Verhalten prüft botleadList.test.mjs
|
||||
const read = (p) => fs.readFileSync(new URL('../../' + p, import.meta.url), 'utf8')
|
||||
|
||||
test('safeHref: nur http und https, keine Zugangsdaten in der Adresse', () => {
|
||||
assert.equal(safeHref('https://beispiel-elektro.de/impressum'), 'https://beispiel-elektro.de/impressum')
|
||||
assert.equal(safeHref(' http://beispiel-elektro.de/ '), 'http://beispiel-elektro.de/')
|
||||
for (const bose of ['javascript:alert(1)', '//evil.example', 'data:text/html,x', '', ' ', 'ftp://x.example/', 'mailto:a@b.de', '/pfad', 'https://', null, undefined, 42, {}]) assert.equal(safeHref(bose), '', String(bose))
|
||||
assert.equal(safeHref('https://user:pw@beispiel-elektro.de/'), '') // Zugangsdaten
|
||||
assert.equal(safeHref('https://beispiel-elektro.de@evil.example/'), '') // sähe nach dem Betrieb aus, führt aber woandershin
|
||||
})
|
||||
test('phoneLabel: Nummer nur mit Quelle, Heilberuf nie', () => {
|
||||
assert.equal(phoneLabel({ phone: '0631 111', phoneSource: 'https://x.de/i', isHealth: false }), '0631 111')
|
||||
assert.equal(phoneLabel({ phone: '0631 111', phoneSource: '', isHealth: false }), 'nicht verfügbar')
|
||||
assert.equal(phoneLabel({ phone: '', phoneSource: '', isHealth: true }), 'bewusst nicht gespeichert')
|
||||
assert.equal(phoneLabel({ phone: '0631 111', phoneSource: 'https://x.de/i', isHealth: true }), 'bewusst nicht gespeichert')
|
||||
assert.equal(phoneLabel({ phone: '', phoneSource: 'https://x.de/i', isHealth: false }), 'nicht verfügbar')
|
||||
assert.equal(phoneLabel({}), 'nicht verfügbar')
|
||||
assert.equal(phoneLabel(null), 'nicht verfügbar')
|
||||
})
|
||||
test('botleadRow: alle zehn Felder', () => {
|
||||
const item = { id: 'a', room: 'handwerk', roomLabel: 'Handwerk', name: 'Elektro Beispiel GmbH', postalCode: '67655', city: 'Kaiserslautern', score: 82, reason: 'keine veröffentlichte Mail', sourceUrl: 'javascript:x', phone: '', phoneSource: '', isHealth: false, foundAt: '2026-10-19T20:00:00Z', gekuerzt: false }
|
||||
const r = botleadRow(item)
|
||||
assert.deepEqual(Object.keys(r), ['id', 'title', 'place', 'roomLabel', 'scoreLabel', 'reason', 'phone', 'sourceHref', 'found', 'gekuerzt'])
|
||||
assert.deepEqual(r, { id: 'a', title: 'Elektro Beispiel GmbH', place: '67655 Kaiserslautern', roomLabel: 'Handwerk', scoreLabel: '82 Punkte', reason: 'keine veröffentlichte Mail', phone: 'nicht verfügbar', sourceHref: '', found: '19.10.2026', gekuerzt: false })
|
||||
// sichere Quelle, Nummer mit Quelle, gekürzt
|
||||
const b = botleadRow({ ...item, sourceUrl: 'https://beispiel-elektro.de/impressum', phone: '0631 111', phoneSource: 'https://beispiel-elektro.de/impressum', gekuerzt: true })
|
||||
assert.deepEqual([b.sourceHref, b.phone, b.gekuerzt], ['https://beispiel-elektro.de/impressum', '0631 111', true])
|
||||
// Heilberuf: nie die Nummer, auch wenn der Dienst eine mitschickt
|
||||
assert.equal(botleadRow({ ...item, isHealth: true, phone: '0631 111', phoneSource: 'https://x.de/i' }).phone, 'bewusst nicht gespeichert')
|
||||
})
|
||||
test('botleadRow: fehlende Werte ergeben Platzhalter, nie undefined oder NaN', () => {
|
||||
const r = botleadRow({ id: 'b' })
|
||||
assert.deepEqual(r, { id: 'b', title: 'nicht verfügbar', place: '', roomLabel: '–', scoreLabel: '–', reason: '–', phone: 'nicht verfügbar', sourceHref: '', found: '–', gekuerzt: false })
|
||||
assert.equal(botleadRow({ id: 'c', name: ' ', postalCode: '', city: 'Mainz' }).title, 'nicht verfügbar')
|
||||
assert.equal(botleadRow({ id: 'c', city: 'Mainz' }).place, 'Mainz')
|
||||
assert.equal(botleadRow({ id: 'c', postalCode: '55116' }).place, '55116')
|
||||
assert.equal(botleadRow({ id: 'c', score: 0 }).scoreLabel, '0 Punkte')
|
||||
assert.equal(botleadRow({ id: 'c', score: 'x' }).scoreLabel, '–')
|
||||
assert.equal(botleadRow({ id: 'c', foundAt: 'kein Datum' }).found, '–')
|
||||
assert.equal(botleadRow(null).id, '')
|
||||
})
|
||||
test('botleadRow: das Funddatum zählt in Berlin (Mitternacht und Zeitumstellung)', () => {
|
||||
assert.equal(botleadRow({ id: 'a', foundAt: '2026-10-19T22:30:00Z' }).found, '20.10.2026') // 00:30 Uhr am nächsten Tag
|
||||
assert.equal(botleadRow({ id: 'a', foundAt: '2026-12-31T23:30:00Z' }).found, '01.01.2027')
|
||||
assert.equal(botleadRow({ id: 'a', foundAt: '2026-10-25T00:30:00Z' }).found, '25.10.2026') // Nacht der Zeitumstellung
|
||||
})
|
||||
test('ROOM_FILTERS: Reihenfolge, Werte und Beschriftungen', () => {
|
||||
assert.deepEqual(ROOM_FILTERS, [
|
||||
{ value: '', label: 'Alle Räume' },
|
||||
{ value: 'aerzte', label: 'Ärzte & Praxen' },
|
||||
{ value: 'handwerk', label: 'Handwerk' },
|
||||
{ value: 'selbst', label: 'Selbstständige' },
|
||||
{ value: 'laden', label: 'Läden & Gastro' },
|
||||
])
|
||||
})
|
||||
test('pageInfo: Seiten, Blättern und Beschriftung', () => {
|
||||
assert.deepEqual(pageInfo({ total: 120, page: 2, pageSize: 50 }), { pages: 3, hasPrev: true, hasNext: true, label: 'Seite 2 von 3 · 120 Einträge' })
|
||||
assert.deepEqual(pageInfo({ total: 120, page: 1, pageSize: 50 }), { pages: 3, hasPrev: false, hasNext: true, label: 'Seite 1 von 3 · 120 Einträge' })
|
||||
assert.deepEqual(pageInfo({ total: 120, page: 3, pageSize: 50 }), { pages: 3, hasPrev: true, hasNext: false, label: 'Seite 3 von 3 · 120 Einträge' })
|
||||
assert.equal(pageInfo({ total: 0, page: 1, pageSize: 50 }).label, 'Keine Einträge')
|
||||
assert.deepEqual(pageInfo({ total: 0, page: 1, pageSize: 50 }), { pages: 1, hasPrev: false, hasNext: false, label: 'Keine Einträge' })
|
||||
assert.equal(pageInfo({ total: 1, page: 1, pageSize: 50 }).label, 'Seite 1 von 1 · 1 Eintrag') // Einzahl
|
||||
assert.equal(pageInfo({ total: 50, page: 1, pageSize: 50 }).pages, 1)
|
||||
assert.equal(pageInfo({ total: 51, page: 1, pageSize: 50 }).pages, 2)
|
||||
assert.equal(pageInfo({ total: 1234, page: 1, pageSize: 50 }).label, 'Seite 1 von 25 · 1.234 Einträge') // deutsche Tausenderpunkte
|
||||
// der Dienst schickt pageSize mit; fehlt es oder ist es unbrauchbar, gilt 50
|
||||
assert.equal(pageInfo({ total: 120, page: 1 }).pages, 3)
|
||||
for (const bad of [0, -5, NaN, 'x', null]) assert.equal(pageInfo({ total: 120, page: 1, pageSize: bad }).pages, 3, String(bad))
|
||||
assert.equal(pageInfo({ total: 120, page: 1, pageSize: 25 }).pages, 5)
|
||||
// unbrauchbare Zahlen ergeben nie NaN
|
||||
assert.deepEqual(pageInfo({ total: 'x', page: 'y', pageSize: 50 }), { pages: 1, hasPrev: false, hasNext: false, label: 'Keine Einträge' })
|
||||
assert.deepEqual(pageInfo({}), { pages: 1, hasPrev: false, hasNext: false, label: 'Keine Einträge' })
|
||||
})
|
||||
|
||||
test('BotLeadList.jsx: Links nur aus der Zeilen-Ableitung, extern mit rel noopener, kein HTML-Einschub, Knöpfe mit type="button"', () => {
|
||||
const src = read('src/components/workers/BotLeadList.jsx')
|
||||
assert.match(src, /botleadRow\(/)
|
||||
assert.match(src, /rel="noopener noreferrer"/); assert.match(src, /target="_blank"/)
|
||||
// jeder href kommt aus row.sourceHref (botleadRow → safeHref), nie aus dem Eintrag selbst
|
||||
assert.match(src, /href=\{row\.sourceHref\}/)
|
||||
assert.doesNotMatch(src, /\bhref=\{(?!row\.sourceHref\})/)
|
||||
assert.doesNotMatch(src, /\bhref="/)
|
||||
assert.doesNotMatch(src, /dangerouslySetInnerHTML|innerHTML/)
|
||||
// kein Anruf-Link, kein Mailversand
|
||||
assert.doesNotMatch(src, /tel:|mailto:/)
|
||||
// WOMS-Button hat keinen Standard-type: jeder Knopf nennt ihn
|
||||
assert.equal((src.match(/<(?:button|Button)\b/g) || []).length > 0, true)
|
||||
assert.equal((src.match(/<(?:button|Button)\b/g) || []).length, (src.match(/<(?:button|Button)\b[^>]*type="button"/g) || []).length)
|
||||
// kein Polling
|
||||
assert.doesNotMatch(src, /setInterval/)
|
||||
})
|
||||
@@ -0,0 +1,122 @@
|
||||
// Mini-Läufer anstelle von React für Tests der Komponenten unter Node (Hooks, Zustand, Effekte; gerendert wird in einen Baum aus { tag, props, children }).
|
||||
// Dieselbe Fassung wie in workersPultUi.test.mjs (FAKE_REACT), hier als Baustein für weitere Komponententests. Die Bauteile laufen wirklich, Klicks und
|
||||
// Eingaben ruft der Test von Hand auf. `esbuild` bündelt die Komponente mit dem Plug-in `fakeReact` (react und react/jsx-runtime kommen von hier).
|
||||
export const FAKE_REACT = `
|
||||
export const Fragment = Symbol('fragment')
|
||||
export function jsx(type, props, key) { return { type, props: props ?? {}, key } }
|
||||
export const jsxs = jsx
|
||||
|
||||
let current = null // Instanz des Bauteils, dessen Render gerade läuft
|
||||
let hookIndex = 0
|
||||
|
||||
const flat = (children, out = []) => {
|
||||
if (Array.isArray(children)) children.forEach((c) => flat(c, out))
|
||||
else if (children !== null && children !== undefined && children !== false && children !== true) out.push(children)
|
||||
return out
|
||||
}
|
||||
|
||||
// Hängt ein Element (Bauteile mit Hooks, darunter Tags) ein und rendert es; jedes Bauteil behält seinen Zustand unter seinem Pfad im Baum.
|
||||
export function __mount(element) {
|
||||
const owner = { render, effects: [], late: 0 } // late: Zustandsänderungen, die nach dem Abbau noch ankamen
|
||||
const instances = new Map()
|
||||
let tree = null
|
||||
let rendering = false
|
||||
let dirty = false
|
||||
|
||||
const dispose = (inst) => {
|
||||
inst.alive = false
|
||||
for (const h of inst.hooks) if (h && typeof h.cleanup === 'function') h.cleanup()
|
||||
}
|
||||
|
||||
const expand = (node, path, visited) => {
|
||||
if (typeof node === 'string' || typeof node === 'number') return String(node)
|
||||
const { type, props } = node
|
||||
if (typeof type === 'function') {
|
||||
visited.add(path)
|
||||
let inst = instances.get(path)
|
||||
if (!inst) { inst = { hooks: [], alive: true, owner }; instances.set(path, inst) }
|
||||
current = inst
|
||||
hookIndex = 0
|
||||
const out = type(props)
|
||||
current = null
|
||||
return out === null || out === undefined || out === false ? null : expand(out, path + '/c', visited)
|
||||
}
|
||||
const children = flat(props.children).map((c, i) => expand(c, path + '/' + (c && c.key != null ? 'k:' + c.key : i), visited))
|
||||
// ref-Objekt an einem Tag: wie React vor den Effekten gesetzt; das Element liefert der Test (globalThis.__fakeRef), sonst null
|
||||
if (typeof type === 'string' && props.ref && typeof props.ref === 'object') props.ref.current = globalThis.__fakeRef ? globalThis.__fakeRef(type, props) : null
|
||||
return { tag: type === Fragment ? 'fragment' : type, props, children }
|
||||
}
|
||||
|
||||
function render() {
|
||||
if (rendering) { dirty = true; return }
|
||||
rendering = true
|
||||
do {
|
||||
dirty = false
|
||||
const visited = new Set()
|
||||
tree = expand(element, 'r', visited)
|
||||
for (const [path, inst] of [...instances]) {
|
||||
if (visited.has(path)) continue
|
||||
dispose(inst)
|
||||
instances.delete(path)
|
||||
}
|
||||
for (const run of owner.effects.splice(0)) run()
|
||||
} while (dirty)
|
||||
rendering = false
|
||||
}
|
||||
render()
|
||||
return {
|
||||
tree: () => tree,
|
||||
rerender: render,
|
||||
lateUpdates: () => owner.late,
|
||||
unmount: () => { for (const inst of instances.values()) dispose(inst); instances.clear() },
|
||||
}
|
||||
}
|
||||
|
||||
export function useState(initial) {
|
||||
const inst = current
|
||||
const i = hookIndex++
|
||||
if (!inst.hooks[i]) inst.hooks[i] = { value: typeof initial === 'function' ? initial() : initial }
|
||||
const slot = inst.hooks[i]
|
||||
return [slot.value, (next) => {
|
||||
if (!inst.alive) { inst.owner.late += 1; return }
|
||||
const value = typeof next === 'function' ? next(slot.value) : next
|
||||
if (Object.is(value, slot.value)) return
|
||||
slot.value = value
|
||||
inst.owner.render()
|
||||
}]
|
||||
}
|
||||
export function useRef(initial) {
|
||||
const inst = current
|
||||
const i = hookIndex++
|
||||
if (!inst.hooks[i]) inst.hooks[i] = { current: initial }
|
||||
return inst.hooks[i]
|
||||
}
|
||||
export function useCallback(fn, deps) {
|
||||
const inst = current
|
||||
const i = hookIndex++
|
||||
const slot = inst.hooks[i]
|
||||
if (slot && deps.every((d, k) => Object.is(d, slot.deps[k]))) return slot.fn
|
||||
inst.hooks[i] = { fn, deps }
|
||||
return fn
|
||||
}
|
||||
export function useEffect(fn, deps) {
|
||||
const inst = current
|
||||
const i = hookIndex++
|
||||
if (!inst.hooks[i]) inst.hooks[i] = { deps: undefined, cleanup: undefined, ran: false }
|
||||
const slot = inst.hooks[i]
|
||||
if (slot.ran && deps && slot.deps && deps.every((d, k) => Object.is(d, slot.deps[k]))) return
|
||||
inst.owner.effects.push(() => {
|
||||
if (typeof slot.cleanup === 'function') slot.cleanup()
|
||||
slot.deps = deps
|
||||
slot.ran = true
|
||||
slot.cleanup = fn()
|
||||
})
|
||||
}
|
||||
`
|
||||
export const fakeReact = {
|
||||
name: 'fake-react',
|
||||
setup(build) {
|
||||
build.onResolve({ filter: /^react(\/jsx-runtime)?$/ }, () => ({ path: 'react', namespace: 'fake-react' }))
|
||||
build.onLoad({ filter: /.*/, namespace: 'fake-react' }, () => ({ contents: FAKE_REACT, loader: 'js' }))
|
||||
},
|
||||
}
|
||||
@@ -17,6 +17,14 @@ test('WorkersPage: Titel Hustler (H1 und Tab-Titel), Reiter hochhaus und freigab
|
||||
assert.match(s, /id: 'hochhaus'/); assert.match(s, /id: 'freigabe'/); assert.match(s, /WORKERS_TABS\.length > 1/)
|
||||
assert.match(s, /const api = workersApi/); assert.match(s, /pickTab\(/); assert.match(s, /isAdmin=\{isAdmin\}/)
|
||||
})
|
||||
test('WorkersPage: Reiter botleads hinter freigabe zeigt die BotLeadList mit api, bot aus der Adresse und onClearBot, der den Reiter behält; ein Reiterwechsel lässt bot weg', () => {
|
||||
const s = readFileSync(new URL('../../src/pages/WorkersPage.jsx', import.meta.url), 'utf8')
|
||||
assert.match(s, /id: 'botleads', label: 'Bot-Leads'/); assert.ok(s.indexOf("id: 'freigabe'") < s.indexOf("id: 'botleads'"))
|
||||
assert.match(s, /import BotLeadList from '\.\.\/components\/workers\/BotLeadList'/); assert.match(s, /active\.id === 'botleads' && <BotLeadList[^>]*api=\{api\}/)
|
||||
assert.match(s, /params\.get\('bot'\)/); assert.match(s, /<BotLeadList[^>]*\bbot=\{bot\}/); assert.match(s, /<BotLeadList[^>]*onClearBot=\{\(\) => setParams\(\{ tab: 'botleads' \}\)\}/)
|
||||
assert.match(s, /onChange=\{\(id\) => setParams\(\{ tab: id \}\)\}/) // setParams ersetzt alle Parameter: bot fällt beim Wechsel weg
|
||||
assert.doesNotMatch(s, /Später kommt botleads/)
|
||||
})
|
||||
test('App: RouteErrorBoundary um die Routen im Inhaltsbereich (fehlender lazy-Chunk in altem Tab lässt nicht die ganze Oberfläche verschwinden), Pfad als resetKey', () => {
|
||||
const s = readFileSync(new URL('../../src/App.jsx', import.meta.url), 'utf8')
|
||||
assert.match(s, /import RouteErrorBoundary from '\.\/components\/RouteErrorBoundary'/)
|
||||
|
||||
Reference in new issue
Block a user