import test from 'node:test' import assert from 'node:assert/strict' import fs from 'node:fs' import path from 'node:path' import { fileURLToPath } from 'node:url' import esbuild from 'esbuild' import { loadModule } from './load.mjs' // Reine Ansicht des Interessenten-Panels (Wirkung von GET /prospect/:id auf den Bildschirm): Whitelist der Felder, Score-Band, // Anzeigenamen, Nachrichtenverlauf, Vorschau-Zeile. Das Panel selbst wird weiter unten mit echtem React gerendert (Text, nie Markup). const { prospectView } = await loadModule('src/lib/workersProspect.js') const NOW = Date.parse('2026-10-19T09:00:00Z') const MIN = 60 * 1000 const HOUR = 60 * MIN const iso = (offsetMs) => new Date(NOW + offsetMs).toISOString() const read = (p) => fs.readFileSync(new URL('../../' + p, import.meta.url), 'utf8') const PROSPECT = { $id: 'p1', name: 'Verein X', source: 'kleinanzeigen-alarm', foundAt: '2026-10-19T06:00:00Z', city: 'Namborn', postalCode: '66640', status: 'bewertet', score: 72, scoreReason: 'Passt.', requestText: 'Text', sourceUrl: 'https://www.kleinanzeigen.de/s-anzeige/x/1-2-3', } const detailOf = (prospect = {}, rest = {}) => ({ prospect: { ...PROSPECT, ...prospect }, thread: null, messages: [], preview: null, reply: null, ...rest }) const view = (detail, employees = []) => prospectView(detail, { nowMs: NOW, employees }) test('Fakten: Quelle, Fundzeit, Ort, Status; keine Telefon-Zeile, auch wenn das Feld mitkäme', () => { const v = prospectView({ prospect: { name: 'Verein X', source: 'kleinanzeigen-alarm', foundAt: '2026-10-19T06:00:00Z', city: 'Namborn', postalCode: '66640', status: 'bewertet', score: 72, scoreReason: 'Passt.', requestText: 'Text', sourceUrl: 'https://www.kleinanzeigen.de/s-anzeige/x/1-2-3', phone: '0631 123' }, thread: null, messages: [], preview: null, reply: null }, { nowMs: NOW, employees: [] }) assert.deepEqual(v.facts.map((f) => f.label), ['Quelle', 'Gefunden', 'Ort', 'Status']) assert.equal(JSON.stringify(v).includes('0631'), false) assert.deepEqual([v.title, v.score.value, v.score.band, v.sourceHref], ['Verein X', 72, 'mittel', 'https://www.kleinanzeigen.de/s-anzeige/x/1-2-3']) }) test('Fakten: Werte (Anzeigename der Plattform, Alter, PLZ vor Ort, deutscher Status); Felder ohne Wert entfallen', () => { const v = view(detailOf()) assert.deepEqual(v.facts, [ { label: 'Quelle', value: 'Kleinanzeigen' }, { label: 'Gefunden', value: 'vor 3 Std.' }, { label: 'Ort', value: '66640 Namborn' }, { label: 'Status', value: 'Bewertet' }, ]) // nur die Stadt oder nur die PLZ; ohne beides gibt es keine Zeile „Ort“; ohne gültige Fundzeit keine Zeile „Gefunden“ assert.deepEqual(view(detailOf({ postalCode: undefined })).facts[2], { label: 'Ort', value: 'Namborn' }) assert.deepEqual(view(detailOf({ city: undefined })).facts[2], { label: 'Ort', value: '66640' }) assert.deepEqual(view(detailOf({ city: '', postalCode: ' ' })).facts.map((f) => f.label), ['Quelle', 'Gefunden', 'Status']) assert.deepEqual(view(detailOf({ foundAt: 'gestern' })).facts.map((f) => f.label), ['Quelle', 'Ort', 'Status']) }) test('Quelle: Anzeigenamen der drei Alarm-Plattformen, sonst der Rohwert (auch bei Namen wie "constructor")', () => { const quelle = (source) => view(detailOf({ source })).facts[0].value assert.equal(quelle('kleinanzeigen-alarm'), 'Kleinanzeigen') assert.equal(quelle('freelancermap-alarm'), 'freelancermap') assert.equal(quelle('freelance-de-alarm'), 'freelance.de') assert.equal(quelle('gutefrage.net'), 'gutefrage.net') assert.equal(quelle('constructor'), 'constructor') }) test('Status: deutsche Bezeichnung für alle zwölf Status, unbekannte Werte bleiben stehen', () => { const status = (s) => view(detailOf({ status: s })).facts.find((f) => f.label === 'Status').value assert.deepEqual( ['gefunden', 'bewertet', 'botlead', 'kontaktiert', 'gespraech', 'ja', 'vorschau_bau', 'vorschau_gesendet', 'angebot', 'deal', 'verloren', 'gesperrt'].map(status), ['Gefunden', 'Bewertet', 'Bot-Lead', 'Kontaktiert', 'Im Gespräch', 'Zugesagt', 'Vorschau im Bau', 'Vorschau gesendet', 'Angebot', 'Abschluss', 'Verloren', 'Gesperrt'], ) assert.equal(status('neu_erfunden'), 'neu_erfunden') assert.equal(status('toString'), 'toString') }) test('Score-Band: ≥ 80 hoch, 60–79 mittel, < 60 niedrig, ohne Score leer', () => { const band = (score) => view(detailOf({ score })).score assert.deepEqual([100, 80].map((s) => band(s).band), ['hoch', 'hoch']) assert.deepEqual([79, 72, 60].map((s) => band(s).band), ['mittel', 'mittel', 'mittel']) assert.deepEqual([59, 1, 0].map((s) => band(s).band), ['niedrig', 'niedrig', 'niedrig']) // ohne brauchbare Zahl: kein Band, kein Wert (ein Text "72" ist keine Zahl des Dienstes) for (const kaputt of [undefined, null, '72', NaN, Infinity, {}]) assert.deepEqual([band(kaputt).band, band(kaputt).value], ['', null], String(kaputt)) assert.deepEqual(band(72), { value: 72, band: 'mittel', reason: 'Passt.' }) assert.equal(view(detailOf({ scoreReason: undefined })).score.reason, '') }) test('Anfragetext und Quelle: Wortlaut unverändert, Adresse nur als https ohne Zugangsdaten', () => { assert.equal(view(detailOf({ requestText: 'Zeile 1\n Zeile 2 fett' })).requestText, 'Zeile 1\n Zeile 2 fett') assert.equal(view(detailOf({ requestText: undefined })).requestText, '') assert.equal(view(detailOf({ requestText: ' \n ' })).requestText, '') for (const sourceUrl of ['javascript:alert(1)', 'http://www.kleinanzeigen.de/x', 'https://www.kleinanzeigen.de@evil.example/x', 'data:text/html,hi', '', undefined, 42]) { assert.equal(view(detailOf({ sourceUrl })).sourceHref, null, String(sourceUrl)) } }) test('Titel: Name des Interessenten, ohne Namen "Interessent"', () => { assert.equal(view(detailOf({ name: ' Verein X ' })).title, 'Verein X') assert.equal(view(detailOf({ name: undefined })).title, 'Interessent') assert.equal(view(detailOf({ name: ' ' })).title, 'Interessent') }) test('Whitelist: unbekannte Felder (Telefon, E-Mail-Nachweise, interne Hinweise) tauchen in der Ansicht nicht auf', () => { const v = view(detailOf({ phone: '0631 123', phoneSource: 'https://x.example/tel', email: 'geheim@x.example', emailSource: 'https://x.example/mail', internal: 'INTERN-7', contactName: 'Frau Y' }, { thread: { $id: 't1', toEmail: 'thread@x.example' }, reply: { $id: 'r1', draft: 'ENTWURF-9' } })) const text = JSON.stringify(v) for (const leak of ['0631', 'geheim@x.example', 'x.example/tel', 'x.example/mail', 'INTERN-7', 'thread@x.example', 'ENTWURF-9']) assert.equal(text.includes(leak), false, leak) assert.deepEqual(Object.keys(v), ['title', 'facts', 'score', 'requestText', 'sourceHref', 'messages', 'previewLine']) }) test('Nachrichten: Autor "mensch:" → Name aus employees, "clara" → Clara; ohne Vorschau leere Zeile', () => { const messages = [ { $id: 'm1', direction: 'out', author: 'clara', body: 'Guten Tag', sentAt: iso(-2 * HOUR), createdAt: iso(-2 * HOUR) }, { $id: 'm2', direction: 'out', author: 'mensch:u2', body: 'Gesendet', sentAt: iso(-90 * MIN), createdAt: iso(-90 * MIN) }, { $id: 'm3', direction: 'in', author: 'kunde', body: 'Ja gern', createdAt: iso(-10 * MIN) }, { $id: 'm4', direction: 'out', author: 'mensch:u9', body: 'Unbekannt', sentAt: iso(-5 * MIN), createdAt: iso(-5 * MIN) }, { $id: 'm5', direction: 'out', author: 'max', body: 'Mail', createdAt: iso(-1000) }, ] const v = view(detailOf({}, { messages }), [{ userId: 'u2', displayName: 'Kenso' }]) assert.deepEqual(v.messages, [ { id: 'm1', who: 'Clara', when: 'vor 2 Std.', body: 'Guten Tag' }, { id: 'm2', who: 'Kenso', when: 'vor 1 Std.', body: 'Gesendet' }, { id: 'm3', who: 'Interessent', when: 'vor 10 Min.', body: 'Ja gern' }, { id: 'm4', who: 'Kollege', when: 'vor 5 Min.', body: 'Unbekannt' }, { id: 'm5', who: 'Max', when: 'gerade eben', body: 'Mail' }, ]) assert.equal(v.previewLine, '') }) test('Nachrichten: Reihenfolge bleibt, fremde Autoren erscheinen als Text, Kaputtes wird übersprungen oder leer', () => { const v = view(detailOf({}, { messages: [ { $id: 'a', author: 'info@firma.example', body: 'B1', createdAt: iso(-60 * MIN) }, null, 'text', { author: 'toString', body: 'B2' }, { $id: 'c', body: 42, createdAt: 'kaputt' }, ] })) assert.deepEqual(v.messages, [ { id: 'a', who: 'info@firma.example', when: 'vor 1 Std.', body: 'B1' }, { id: '', who: 'toString', when: '', body: 'B2' }, { id: 'c', who: 'Unbekannt', when: '', body: '' }, ]) assert.deepEqual(view(detailOf({}, { messages: 'kaputt' })).messages, []) assert.deepEqual(view(detailOf({}, { messages: undefined })).messages, []) }) test('Mitarbeiterliste fehlt oder ist kaputt: Namen fallen auf "Kollege" zurück, nichts wirft', () => { const messages = [{ $id: 'm', author: 'mensch:u2', body: 'x', createdAt: iso(-1000) }] for (const employees of [undefined, null, 'kaputt', [null], [{ userId: 'u2' }]]) assert.equal(prospectView(detailOf({}, { messages }), { nowMs: NOW, employees }).messages[0].who, 'Kollege') }) test('Vorschau-Zeile: ohne Vorschau leer, sonst Status, Runde und Alter; kein Token, keine Adresse', () => { assert.equal(view(detailOf()).previewLine, '') assert.equal(view(detailOf({}, { preview: undefined })).previewLine, '') assert.equal(view(detailOf({}, { preview: 'kaputt' })).previewLine, '') const preview = { $id: 'v1', prospectId: 'p1', builder: 'bob', status: 'freigabe', round: 2, createdAt: iso(-3 * HOUR), deleteAfter: iso(60 * HOUR), token: 'GEHEIM-TOKEN' } assert.equal(view(detailOf({}, { preview })).previewLine, 'Vorschau: wartet auf Freigabe · Runde 2 · erstellt vor 3 Std.') assert.equal(view(detailOf({}, { preview: { ...preview, status: 'gesendet', round: 1, convertedProjectId: 'proj1' } })).previewLine, 'Vorschau: gesendet · Runde 1 · erstellt vor 3 Std. · als Projekt übernommen') assert.equal(view(detailOf({}, { preview: { status: 'sonstwas' } })).previewLine, 'Vorschau: sonstwas') assert.equal(view(detailOf({}, { preview: {} })).previewLine, 'Vorschau vorhanden') assert.equal(JSON.stringify(view(detailOf({}, { preview }))).includes('GEHEIM-TOKEN'), false) }) test('Vorschau-Status: alle acht Status haben einen deutschen Text', () => { const line = (status) => view(detailOf({}, { preview: { status } })).previewLine assert.deepEqual( ['bau', 'pruefung', 'zurueck', 'fertig', 'freigabe', 'gesendet', 'verworfen', 'gescheitert'].map(line), ['Vorschau: wird gebaut', 'Vorschau: wird geprüft', 'Vorschau: geht zurück an den Bau', 'Vorschau: fertig', 'Vorschau: wartet auf Freigabe', 'Vorschau: gesendet', 'Vorschau: verworfen', 'Vorschau: gescheitert'], ) }) test('Kaputte Antwort: ohne Interessent kommt eine leere, aber vollständige Ansicht', () => { for (const detail of [{}, { prospect: null }, { prospect: 'x' }, null, undefined]) { const v = view(detail) assert.deepEqual([v.title, v.facts, v.score, v.requestText, v.sourceHref, v.messages, v.previewLine], ['Interessent', [], { value: null, band: '', reason: '' }, '', null, [], '']) } }) // ---- Das Panel wird mit echtem React gerendert (renderToStaticMarkup): Fremdtext erscheint nur als Text, Links tragen rel ---- const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..') async function loadBody() { const out = await esbuild.build({ stdin: { contents: "import { createElement } from 'react'\nimport { renderToStaticMarkup } from 'react-dom/server'\nimport { ProspectBody } from './src/components/workers/ProspectPanel.jsx'\nexport const render = (view) => renderToStaticMarkup(createElement(ProspectBody, { view }))", resolveDir: ROOT, loader: 'js', }, bundle: true, format: 'esm', platform: 'node', write: false, jsx: 'automatic', loader: { '.jsx': 'jsx' }, // die Mitarbeiterliste käme aus Appwrite: für den Aufbau der Datei nur ein Stellvertreter plugins: [{ name: 'prospect-stubs', setup(build) { build.onResolve({ filter: /hooks\/useEmployees$/ }, () => ({ path: 'useEmployees', namespace: 'prospect-stub' })) build.onLoad({ filter: /.*/, namespace: 'prospect-stub' }, () => ({ contents: 'export function useEmployees() { return { employees: [], loading: false } }', loader: 'js' })) }, }], banner: { js: "import { createRequire as __createRequire } from 'node:module'; const require = __createRequire(process.cwd() + '/');" }, }) return import(`data:text/javascript;base64,${Buffer.from(out.outputFiles[0].text).toString('base64')}`) } test('ProspectBody (gerendert): Fakten als .kv, Score und Begründung, Anfragetext und Nachrichten nur als Text, Link mit rel', async () => { const { render } = await loadBody() const v = view( detailOf({ name: 'Verein X', scoreReason: 'Passt ', requestText: 'Suche Webdesigner \nZeile 2', phone: '0631 123' }, { messages: [{ $id: 'm1', direction: 'in', author: 'kunde', body: 'Hallo du', createdAt: iso(-10 * MIN) }, { $id: 'm2', direction: 'out', author: 'clara', body: 'Antwort', createdAt: iso(-5 * MIN) }], preview: { status: 'bau', round: 1, createdAt: iso(-HOUR) } }), ) const html = render(v) assert.match(html, /
/) for (const t of ['
Quelle
Kleinanzeigen
', '
Gefunden
vor 3 Std.
', '
Ort
66640 Namborn
', '
Status
Bewertet
']) assert.ok(html.includes(t), t) assert.ok(html.includes('Score 72')); assert.ok(html.includes('mittel')); assert.ok(html.includes('Passt <script>alert(1)</script>')) // Anfragetext und Nachrichten: Zeilenumbruch bleibt im Text (pre-wrap), Markup nie assert.match(html, /

Suche Webdesigner <img src=x onerror=alert\(1\)>\nZeile 2<\/p>/) assert.ok(html.includes('Hallo <b>du</b>')); assert.ok(html.includes('Clara')); assert.ok(html.includes('Interessent')); assert.ok(html.includes('vor 10 Min.')) assert.ok(!html.includes('