" → 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('