import test from 'node:test'
import assert from 'node:assert/strict'
import { loadModule } from './load.mjs'
// Reine Pult-Logik des Reiters „Freigabe“: Filter, Alter, Veraltet-Anzeige, sichere Links, Zeilenmodell, Kopieren & Öffnen
const {
PULT_KIND_LABELS, SENDABLE_KINDS, PULT_FILTERS, REPLY_KINDS, PASTE_KINDS, REPLY_TEXT_MAX, UNCERTAIN_TEXT,
staleView, ageLabel, safeExternalUrl, mailtoHref, employeeName, rowModel: rowModelNachStufe, copyAndOpen, validateReplyText, charCount, actionErrorText, pasteDoneText,
} = await loadModule('src/lib/workersPult.js')
const { HUSTLER_STUFE } = await loadModule('src/lib/hustlerStufe.js')
// Stufen-Sperre (hustlerStufe.js): rowModel bietet die P4-Aktionen erst ab Stufe 4 an. Die Zusicherungen dieser Datei gelten für das volle Pult und laufen deshalb auf
// Stufe 6 (P4 und Keller offen, Verhalten wie vor der Sperre). Was unterhalb von Stufe 4 gilt, prüft hustlerStufe.test.mjs.
const rowModel = (item, ctx) => rowModelNachStufe(item, { stufe: 6, ...ctx })
const NOW = Date.parse('2026-10-19T09:00:00Z')
const MIN = 60 * 1000
const HOUR = 60 * MIN
const DAY = 24 * HOUR
const iso = (offsetMs) => new Date(NOW + offsetMs).toISOString()
test('Filter: drei Reiter mit den Status-Listen für die API', () => {
assert.deepEqual(PULT_FILTERS.map((f) => f.statuses), ['offen,in_arbeit', 'veraltet', 'gesendet,verworfen,erledigt'])
assert.deepEqual(PULT_FILTERS.map((f) => [f.id, f.label]), [['offen', 'Wartet'], ['veraltet', 'Veraltet'], ['erledigt', 'Erledigt']])
})
test('Arten: deutsche Bezeichnungen für alle acht Pult-Arten, sendbar sind nur die zwei Antwort-Arten', () => {
assert.deepEqual(PULT_KIND_LABELS, {
plattform_antwort: 'Antwort auf Plattform-Anfrage',
mail_antwort: 'Antwort per E-Mail',
vorschau_freigabe: 'Vorschau freigeben',
unsicher: 'Unsicher – bitte prüfen',
beschwerde: 'Beschwerde',
preisfrage: 'Preisfrage',
mensch_gewuenscht: 'Mensch gewünscht',
bau_fehlgeschlagen: 'Bau fehlgeschlagen',
})
assert.deepEqual(SENDABLE_KINDS, ['plattform_antwort', 'mail_antwort'])
})
test('Veraltet-Anzeige: Status veraltet, abgelaufenes staleAt, "läuft in 5 Std. ab" unter 24 h, sonst nichts', () => {
assert.deepEqual(staleView({ status: 'veraltet' }, NOW), { stale: true, soon: false, label: 'veraltet' })
assert.deepEqual(staleView({ status: 'offen', staleAt: '2026-10-19T08:59:00Z' }, NOW), { stale: true, soon: false, label: 'veraltet' })
assert.deepEqual(staleView({ status: 'offen', staleAt: '2026-10-19T14:00:00Z' }, NOW), { stale: false, soon: true, label: 'läuft in 5 Std. ab' })
assert.deepEqual(staleView({ status: 'offen', staleAt: '2026-10-22T09:00:00Z' }, NOW), { stale: false, soon: false, label: '' })
assert.deepEqual(staleView({ status: 'offen' }, NOW), { stale: false, soon: false, label: '' })
})
test('Veraltet-Anzeige: Grenzen (genau jetzt, aufrunden, genau 24 h), in_arbeit zählt mit, abgeschlossene und kaputte Zeiten nie', () => {
assert.equal(staleView({ status: 'offen', staleAt: iso(0) }, NOW).stale, true) // staleAt <= jetzt
assert.equal(staleView({ status: 'in_arbeit', staleAt: iso(-1) }, NOW).stale, true)
assert.deepEqual(staleView({ status: 'in_arbeit', staleAt: iso(30 * MIN) }, NOW), { stale: false, soon: true, label: 'läuft in 1 Std. ab' })
assert.equal(staleView({ status: 'offen', staleAt: iso(HOUR + 1) }, NOW).label, 'läuft in 2 Std. ab') // aufgerundet
assert.equal(staleView({ status: 'offen', staleAt: iso(DAY - 1) }, NOW).soon, true)
assert.equal(staleView({ status: 'offen', staleAt: iso(DAY) }, NOW).soon, false) // erst unter 24 h
for (const status of ['gesendet', 'verworfen', 'erledigt']) assert.deepEqual(staleView({ status, staleAt: iso(-DAY) }, NOW), { stale: false, soon: false, label: '' })
assert.deepEqual(staleView({ status: 'offen', staleAt: 'kein Datum' }, NOW), { stale: false, soon: false, label: '' })
assert.deepEqual(staleView({ status: 'offen', staleAt: null }, NOW), { stale: false, soon: false, label: '' })
})
test('ageLabel: gerade eben, Minuten, Stunden, Tage; kaputte Zeit leer', () => {
assert.equal(ageLabel(iso(-30 * 1000), NOW), 'gerade eben')
assert.equal(ageLabel(iso(-5 * MIN), NOW), 'vor 5 Min.')
assert.equal(ageLabel(iso(-3 * HOUR), NOW), 'vor 3 Std.')
assert.equal(ageLabel(iso(-2 * DAY), NOW), 'vor 2 Tagen')
assert.equal(ageLabel(iso(-MIN), NOW), 'vor 1 Min.')
assert.equal(ageLabel(iso(-(HOUR - 1)), NOW), 'vor 59 Min.')
assert.equal(ageLabel(iso(-HOUR), NOW), 'vor 1 Std.')
assert.equal(ageLabel(iso(-DAY), NOW), 'vor 1 Tag')
assert.equal(ageLabel(iso(5 * MIN), NOW), 'gerade eben') // Uhr des Rechners geht etwas nach
assert.equal(ageLabel('', NOW), ''); assert.equal(ageLabel(undefined, NOW), ''); assert.equal(ageLabel('gestern', NOW), '')
})
test('safeExternalUrl: nur https, alles andere (http, javascript:, leer) → null', () => {
assert.equal(safeExternalUrl('https://www.kleinanzeigen.de/s-anzeige/x/1-2-3'), 'https://www.kleinanzeigen.de/s-anzeige/x/1-2-3')
assert.equal(safeExternalUrl('javascript:alert(1)'), null); assert.equal(safeExternalUrl('http://example.org/x'), null); assert.equal(safeExternalUrl(''), null)
})
test('safeExternalUrl: weitere Schemata, relative und kaputte Werte, Zugangsdaten in der Adresse', () => {
for (const bad of ['data:text/html,x', 'file:///etc/passwd', 'ftp://x.example/', 'mailto:x@y.de', '//x.example/', '/pfad', 'https://', ' javascript:alert(1)', null, undefined, 42, {}]) assert.equal(safeExternalUrl(bad), null, String(bad))
assert.equal(safeExternalUrl('https://www.kleinanzeigen.de@evil.example/'), null) // sähe nach Kleinanzeigen aus, führt aber woandershin
assert.equal(safeExternalUrl('https://user:pw@x.example/'), null)
assert.equal(safeExternalUrl(' HTTPS://X.Example/a b\n'), 'https://x.example/a%20b') // vom Browser normalisiert: Groß-/Kleinschreibung, Leerzeichen, Zeilenumbruch
})
test('mailtoHref: kodiert, Betreff einzeilig, Text über 1800 Zeichen entfällt', () => {
assert.equal(
mailtoHref({ to: 'x@verein.example', subject: 'Re: Bäckerei & Café?', body: 'Hallo Welt\nZeile 2 & mehr?' }),
'mailto:x@verein.example?subject=Re%3A%20B%C3%A4ckerei%20%26%20Caf%C3%A9%3F&body=Hallo%20Welt%0D%0AZeile%202%20%26%20mehr%3F'
)
// Betreff einzeilig: kein Zeilenumbruch, der einen weiteren Mail-Kopf einschleusen könnte
const sub = mailtoHref({ to: 'x@y.de', subject: 'Hallo\r\nBcc: z@w.de\tTab', body: '' })
assert.equal(sub, 'mailto:x@y.de?subject=Hallo%20Bcc%3A%20z%40w.de%20Tab')
assert.doesNotMatch(sub, /%0[AD]/i)
// Betreff höchstens 78 Zeichen
assert.equal(decodeURIComponent(mailtoHref({ to: 'x@y.de', subject: 'a'.repeat(100), body: '' }).split('subject=')[1]), 'a'.repeat(78))
// Text: 1800 Zeichen gehen mit, 1801 entfallen (die Adresse und der Betreff bleiben)
assert.match(mailtoHref({ to: 'x@y.de', subject: 'S', body: 'b'.repeat(1800) }), /&body=b{1800}$/)
assert.equal(mailtoHref({ to: 'x@y.de', subject: 'S', body: 'b'.repeat(1801) }), 'mailto:x@y.de?subject=S')
// ohne Betreff und ohne Text bleibt nur die Adresse
assert.equal(mailtoHref({ to: 'x@y.de' }), 'mailto:x@y.de')
assert.equal(mailtoHref({ to: 'x@y.de', body: 'Nur Text' }), 'mailto:x@y.de?body=Nur%20Text')
})
test('mailtoHref (F15): genau eine gültige Adresse, sonst "" (kein Mail-Link)', () => {
assert.equal(mailtoHref({ to: 'x@y.de?bcc=z@w.de', subject: 'S', body: 'B' }), '') // eingeschleuster Empfänger
assert.equal(mailtoHref({ to: 'x@y.de\r\nBcc: z@w.de', subject: 'S', body: 'B' }), '') // CR/LF
for (const bad of [
'x@y.de\n', '\nx@y.de', 'x@y.de\r', 'x@y.de ', ' x@y.de', 'x @y.de', 'x@y.de\tz', 'x@y.de
z', // Leerraum jeder Art
'x@y.de&cc=z@w.de', 'x@y.de%0Abcc=z@w.de', 'x%40y.de', 'x@y.de,z@w.de', 'x@y.de;z@w.de', // Trenner und Kodierung
'mailto:x@y.de', 'MAILTO:x@y.de', 'x@y.de#a', 'x@y.de/a', 'x@y.de\\a', '', '"x"@y.de', // Präfix und Zeichen mit Sonderbedeutung
'x@@y.de', 'x@y@z.de', 'xy.de', '@y.de', 'x@', '', '@', null, undefined, 42,
]) assert.equal(mailtoHref({ to: bad, subject: 'S', body: 'B' }), '', JSON.stringify(bad))
assert.equal(mailtoHref({ subject: 'S' }), ''); assert.equal(mailtoHref(), '')
assert.equal(mailtoHref({ to: 'max.muster+shop@müller-bäcker.example' }), 'mailto:max.muster+shop@müller-bäcker.example') // Umlaut-Domain und + sind gültig
})
test('rowModel: Rechte je Status/Art/Person', () => {
const base = { $id: 'a', kind: 'plattform_antwort', status: 'offen', title: 'T', draft: 'D', sourceUrl: 'https://www.kleinanzeigen.de/s-anzeige/x/1-2-3', createdAt: '2026-10-19T06:00:00Z' }
const me = { id: 'u1', name: 'Justin' }
let m = rowModel(base, { me, nowMs: NOW, employees: [] })
assert.deepEqual([m.canClaim, m.canSend, m.canDiscard, m.canDone, m.copyText, m.sourceHref], [true, true, true, false, 'D', base.sourceUrl])
m = rowModel({ ...base, status: 'in_arbeit', claimedBy: 'mensch:u2' }, { me, nowMs: NOW, employees: [{ userId: 'u2', displayName: 'Kenso' }] })
assert.deepEqual([m.canClaim, m.claimedByOther, m.claimedByLabel, m.canSend], [false, true, 'Kenso', true])
m = rowModel({ ...base, kind: 'unsicher' }, { me, nowMs: NOW, employees: [] })
assert.deepEqual([m.canSend, m.canDone], [false, true])
m = rowModel({ ...base, status: 'gesendet' }, { me, nowMs: NOW, employees: [] })
assert.deepEqual([m.canClaim, m.canSend, m.canDiscard, m.canDone], [false, false, false, false])
m = rowModel({ ...base, kind: 'mail_antwort', sourceUrl: '', prospect: { email: 'x@verein.example', name: 'Verein' } }, { me, nowMs: NOW, employees: [] })
assert.match(m.mailtoHref, /^mailto:x@verein\.example\?subject=/)
})
test('rowModel: Zeilendaten (Id, Art, Titel, Alter, Veraltet, Interessent) kommen aus dem Eintrag', () => {
const prospect = { name: 'Verein', email: 'x@verein.example' }
const item = { $id: 'p9', kind: 'beschwerde', status: 'offen', title: 'Titel', draft: 'Entwurf', createdAt: iso(-3 * HOUR), staleAt: iso(5 * HOUR), prospect }
const m = rowModel(item, { me: { id: 'u1', name: 'Justin' }, nowMs: NOW, employees: [] })
assert.deepEqual(
[m.id, m.kind, m.kindLabel, m.title, m.ageLabel, m.stale, m.prospect, m.copyText],
['p9', 'beschwerde', 'Beschwerde', 'Titel', 'vor 3 Std.', { stale: false, soon: true, label: 'läuft in 5 Std. ab' }, prospect, 'Entwurf']
)
const bare = rowModel({ $id: 'p8', kind: 'neue_art', status: 'offen', title: 'X', createdAt: iso(-MIN) }, { me: null, nowMs: NOW, employees: [] })
assert.deepEqual([bare.kindLabel, bare.copyText, bare.prospect, bare.sourceHref, bare.mailtoHref], ['neue_art', '', null, null, ''])
})
test('rowModel: veraltet bleibt übernehmbar, senden und verwerfen; Abgeschlossenes hat keine Aktion; nur Antwort-Arten sind sendbar', () => {
const base = { $id: 'a', kind: 'plattform_antwort', status: 'veraltet', title: 'T', draft: 'D', createdAt: iso(-4 * DAY) }
const ctx = { me: { id: 'u1', name: 'Justin' }, nowMs: NOW, employees: [] }
let m = rowModel(base, ctx)
assert.deepEqual([m.canClaim, m.canSend, m.canDiscard, m.canDone, m.stale.stale], [true, true, true, false, true])
// Der Dienst lässt die Übernahme eines veralteten Eintrags zu, auch wenn früher jemand anderes ihn hatte
m = rowModel({ ...base, claimedBy: 'mensch:u2' }, ctx)
assert.deepEqual([m.canClaim, m.claimedByOther, m.claimedByLabel], [true, false, ''])
for (const status of ['gesendet', 'verworfen', 'erledigt']) {
m = rowModel({ ...base, status }, ctx)
assert.deepEqual([m.canClaim, m.canSend, m.canDiscard, m.canDone, m.stale.stale], [false, false, false, false, false], status)
}
for (const kind of ['unsicher', 'beschwerde', 'preisfrage', 'mensch_gewuenscht', 'bau_fehlgeschlagen']) {
m = rowModel({ ...base, kind, status: 'offen' }, ctx)
assert.deepEqual([m.canSend, m.canDiscard, m.canDone], [false, true, true], kind)
}
// P4-T32 (D20): „Erledigt“ würde die Vorschau-Freigabe schließen, ohne dass der Link hinausgeht: bei vorschau_freigabe gibt es nur Freigeben oder Verwerfen
m = rowModel({ ...base, kind: 'vorschau_freigabe', status: 'offen' }, ctx)
assert.deepEqual([m.canSend, m.canDiscard, m.canDone], [false, true, false])
m = rowModel({ ...base, kind: 'mail_antwort', status: 'in_arbeit' }, ctx)
assert.deepEqual([m.canSend, m.canDone], [true, false])
})
test('rowModel: eigene Übernahme, Name des Kollegen, Quell-Link nur https', () => {
const base = { $id: 'a', kind: 'plattform_antwort', status: 'in_arbeit', title: 'T', draft: 'D', createdAt: iso(-HOUR), claimedBy: 'mensch:u1' }
const employees = [{ userId: 'u1', displayName: 'Justin Ö.' }, { userId: 'u2', displayName: 'Kenso' }]
let m = rowModel(base, { me: { id: 'u1', name: 'Justin' }, nowMs: NOW, employees })
assert.deepEqual([m.claimedByMe, m.claimedByOther, m.claimedByLabel, m.canClaim, m.canSend], [true, false, 'Justin Ö.', false, true])
m = rowModel(base, { me: { id: 'u1', name: 'Justin' }, nowMs: NOW, employees: [] }) // eigener Name, auch wenn die Mitarbeiterliste ihn nicht kennt
assert.deepEqual([m.claimedByMe, m.claimedByLabel], [true, 'Justin'])
m = rowModel({ ...base, claimedBy: 'mensch:u9' }, { me: { id: 'u1', name: 'Justin' }, nowMs: NOW, employees })
assert.deepEqual([m.claimedByMe, m.claimedByOther, m.claimedByLabel], [false, true, 'Kollege'])
m = rowModel({ ...base, status: 'offen', claimedBy: undefined }, { me: { id: 'u1', name: 'Justin' }, nowMs: NOW, employees })
assert.deepEqual([m.claimedByMe, m.claimedByOther, m.claimedByLabel], [false, false, ''])
m = rowModel({ ...base, claimedBy: 'mensch:u1' }, { me: null, nowMs: NOW, employees })
assert.deepEqual([m.claimedByMe, m.claimedByOther], [false, true]) // ohne bekannte Person gehört der Eintrag jemand anderem
for (const sourceUrl of ['javascript:alert(1)', 'http://www.kleinanzeigen.de/x', 'https://www.kleinanzeigen.de@evil.example/', '']) {
assert.equal(rowModel({ ...base, sourceUrl }, { me: null, nowMs: NOW, employees }).sourceHref, null, sourceUrl)
}
})
test('rowModel: Mail-Link nur bei mail_antwort mit gültiger Adresse, Betreff "Re: Name", Text = Entwurf', () => {
const base = { $id: 'a', kind: 'mail_antwort', status: 'offen', title: 'T', draft: 'Guten Tag\nBis bald', createdAt: iso(-HOUR) }
const ctx = { me: null, nowMs: NOW, employees: [] }
assert.equal(rowModel({ ...base, prospect: { email: 'x@verein.example', name: 'Verein' } }, ctx).mailtoHref, 'mailto:x@verein.example?subject=Re%3A%20Verein&body=Guten%20Tag%0D%0ABis%20bald')
assert.equal(rowModel({ ...base, prospect: { email: 'x@verein.example' } }, ctx).mailtoHref, 'mailto:x@verein.example?body=Guten%20Tag%0D%0ABis%20bald') // ohne Namen kein Betreff
assert.equal(rowModel({ ...base, prospect: { email: 'x@y.de?bcc=z@w.de', name: 'Verein' } }, ctx).mailtoHref, '') // F15: kein Link
assert.equal(rowModel({ ...base, prospect: { name: 'Verein' } }, ctx).mailtoHref, '')
assert.equal(rowModel({ ...base, prospect: null }, ctx).mailtoHref, '')
assert.equal(rowModel(base, ctx).mailtoHref, '')
assert.equal(rowModel({ ...base, kind: 'plattform_antwort', prospect: { email: 'x@verein.example', name: 'Verein' } }, ctx).mailtoHref, '') // Plattform-Antwort geht über den Quell-Link
})
// Endprüfung P3 (W5): der Reiter „Erledigt“ zeigt, was aus einem Eintrag wurde, wer es tat und wann
test('rowModel: abgeschlossene Einträge zeigen Ergebnis, Person und Zeitpunkt; drei Status ergeben drei Bezeichnungen', () => {
const base = { $id: 'a', kind: 'plattform_antwort', title: 'T', draft: 'D', createdAt: iso(-4 * DAY), doneBy: 'mensch:u2', doneAt: iso(-2 * HOUR) }
const ctx = { me: { id: 'u1', name: 'Justin' }, nowMs: NOW, employees: [{ userId: 'u2', displayName: 'Anna' }] }
const out = {}
for (const status of ['gesendet', 'verworfen', 'erledigt']) {
const m = rowModel({ ...base, status }, ctx)
out[status] = [m.statusLabel, m.statusTone, m.doneByLabel, m.doneAgeLabel, m.doneLine]
}
assert.deepEqual(out, {
gesendet: ['Gesendet', 'green', 'von Anna', 'vor 2 Std.', 'Gesendet von Anna · vor 2 Std.'],
verworfen: ['Verworfen', 'muted', 'von Anna', 'vor 2 Std.', 'Verworfen von Anna · vor 2 Std.'],
erledigt: ['Erledigt', 'muted', 'von Anna', 'vor 2 Std.', 'Erledigt von Anna · vor 2 Std.'],
})
assert.equal(new Set(Object.values(out).map((v) => v[0])).size, 3)
// das Alter ab Anlage bleibt im Modell (Alter-Feld unverändert), die Zeile zeigt aber die Abschlusszeit
assert.equal(rowModel({ ...base, status: 'gesendet' }, ctx).ageLabel, 'vor 4 Tagen')
})
test('rowModel: doneBy "mensch:" über die Mitarbeiterliste, ein Worker wie "max" wird nie zu "Kollege"', () => {
const base = { $id: 'a', kind: 'mail_antwort', status: 'gesendet', title: 'T', createdAt: iso(-DAY), doneAt: iso(-5 * MIN) }
const ctx = { me: null, nowMs: NOW, employees: [{ userId: 'u1', displayName: 'Justin' }] }
assert.equal(rowModel({ ...base, doneBy: 'mensch:u1' }, ctx).doneByLabel, 'von Justin')
assert.equal(rowModel({ ...base, doneBy: 'mensch:u9' }, ctx).doneByLabel, 'von Kollege') // unbekannte Person
assert.equal(rowModel({ ...base, doneBy: 'mensch:u1' }, { ...ctx, employees: [] }).doneByLabel, 'von Kollege')
assert.equal(rowModel({ ...base, doneBy: 'max' }, ctx).doneByLabel, 'von Max (automatisch)') // P4: Max sendet automatisch
assert.equal(rowModel({ ...base, doneBy: 'clara' }, ctx).doneLine, 'Gesendet von Clara (automatisch) · vor 5 Min.')
assert.ok(!rowModel({ ...base, doneBy: 'max' }, ctx).doneLine.includes('Kollege'))
// unbekannte Kennungen (auch eine, die wie ein Objekt-Schlüssel aussieht) sind weder Mensch noch Worker
for (const by of ['system', 'constructor', '__proto__', 'u1']) assert.equal(rowModel({ ...base, doneBy: by }, ctx).doneByLabel, 'automatisch', by)
// ohne doneBy kein Satzteil
for (const by of [undefined, null, '', 7]) assert.deepEqual([rowModel({ ...base, doneBy: by }, ctx).doneByLabel, rowModel({ ...base, doneBy: by }, ctx).doneLine], ['', 'Gesendet · vor 5 Min.'], String(by))
})
test('rowModel: fehlendes oder kaputtes doneAt gibt keine Zeitangabe, offene Zeilen bleiben unverändert', () => {
const base = { $id: 'a', kind: 'plattform_antwort', status: 'verworfen', title: 'T', createdAt: iso(-DAY), doneBy: 'mensch:u1' }
const ctx = { me: null, nowMs: NOW, employees: [{ userId: 'u1', displayName: 'Justin' }] }
for (const doneAt of [undefined, null, '', 'gestern']) {
const m = rowModel({ ...base, doneAt }, ctx)
assert.deepEqual([m.doneAgeLabel, m.doneLine], ['', 'Verworfen von Justin'], String(doneAt))
}
// offene, übernommene und veraltete Einträge: keine Abschlussfelder, auch wenn doneBy/doneAt (aus einer Altlast) gesetzt wären
for (const status of ['offen', 'in_arbeit', 'veraltet']) {
const m = rowModel({ ...base, status, doneAt: iso(-HOUR), claimedBy: 'mensch:u1' }, ctx)
assert.deepEqual([m.statusLabel, m.statusTone, m.doneByLabel, m.doneAgeLabel, m.doneLine], ['', '', '', '', ''], status)
assert.equal(m.ageLabel, 'vor 1 Tag')
}
// unbekannter Status (neuer Dienst) und Objekt-Schlüssel als Status
for (const status of ['unbekannt', 'constructor', undefined]) assert.equal(rowModel({ ...base, status }, ctx).statusLabel, '', String(status))
})
test('employeeName: "mensch:"-Präfix weg, Nachschlagen über userId, sonst "Kollege"', () => {
const employees = [{ userId: 'u2', displayName: 'Kenso' }, { userId: 'u3', displayName: '' }, { userId: 'u4' }]
assert.equal(employeeName('mensch:u2', employees), 'Kenso')
assert.equal(employeeName('u2', employees), 'Kenso') // ohne Präfix
assert.equal(employeeName('mensch:u9', employees), 'Kollege') // unbekannt
assert.equal(employeeName('mensch:u3', employees), 'Kollege') // ohne Namen
assert.equal(employeeName('mensch:u4', employees), 'Kollege')
assert.equal(employeeName('system:u2', employees), 'Kollege') // fremde Kennung
assert.equal(employeeName('', employees), 'Kollege'); assert.equal(employeeName(undefined, employees), 'Kollege'); assert.equal(employeeName(null, undefined), 'Kollege')
assert.equal(employeeName('mensch:u2', null), 'Kollege'); assert.equal(employeeName('mensch:u2', []), 'Kollege')
})
test('copyAndOpen: Kopieren wird vor dem Öffnen angestoßen (ohne await dazwischen); Kopierfehler ändert das Öffnen nicht', async () => {
const order = []
const r = await copyAndOpen({ text: 'D', url: 'https://x.example/', copy: (t) => { order.push('copy:' + t); return Promise.resolve() }, open: (u) => { order.push('open:' + u); return true } })
assert.deepEqual(order, ['copy:D', 'open:https://x.example/']); assert.deepEqual(r, { copied: true, opened: true })
const f = await copyAndOpen({ text: 'D', url: 'https://x.example/', copy: () => Promise.reject(new Error('blockiert')), open: () => true })
assert.deepEqual(f, { copied: false, opened: true })
assert.equal((await copyAndOpen({ text: 'D', url: null, copy: async () => {}, open: () => true })).opened, false)
assert.equal((await copyAndOpen({ text: 'D', url: 'https://x.example/', copy: async () => {}, open: () => null })).opened, true) // window.open mit noopener liefert immer null: zählt nicht
})
test('copyAndOpen: open läuft noch im selben Zug (Nutzer-Geste), auch wenn copy lange braucht; Fehler und unsichere Adressen', async () => {
// open wird aufgerufen, bevor copy fertig ist, und zwar synchron nach dem Aufruf (kein await davor)
const order = []
let finish
const pending = new Promise((resolve) => { finish = resolve })
const run = copyAndOpen({ text: 'D', url: 'https://x.example/', copy: () => pending.then(() => order.push('copy fertig')), open: () => { order.push('open'); return null } })
assert.deepEqual(order, ['open'])
finish()
assert.deepEqual(await run, { copied: true, opened: true })
assert.deepEqual(order, ['open', 'copy fertig'])
// copy wirft sofort (z. B. navigator.clipboard fehlt): kein Absturz, geöffnet wird trotzdem
assert.deepEqual(await copyAndOpen({ text: 'D', url: 'https://x.example/', copy: () => { throw new TypeError('kein clipboard') }, open: () => null }), { copied: false, opened: true })
// open wirft (Popup-Sperre): kopiert ist trotzdem
assert.deepEqual(await copyAndOpen({ text: 'D', url: 'https://x.example/', copy: async () => {}, open: () => { throw new Error('gesperrt') } }), { copied: true, opened: false })
// unsichere Adresse: wird nie geöffnet, kopiert wird trotzdem
let opened = 0
const bad = await copyAndOpen({ text: 'D', url: 'javascript:alert(1)', copy: async () => {}, open: () => { opened += 1; return null } })
assert.deepEqual(bad, { copied: true, opened: false }); assert.equal(opened, 0)
})
// ---- P4-T32: als Mensch antworten, Vorschau freigeben, Bau starten, Antwort des Kunden einfügen ----
const T32_CTX = { me: { id: 'u1', name: 'Justin' }, nowMs: NOW, employees: [{ userId: 'u2', displayName: 'Kenso' }] }
const PREVIEW_URL = 'https://entwurf.project.webklar.com/AAA/'
const entry = (over = {}) => ({ $id: 'e1', kind: 'unsicher', status: 'offen', title: 'T', draft: 'D', createdAt: iso(-HOUR), prospectId: 'p1', prospect: { name: 'X', email: 'x@verein.example' }, ...over })
const flags = (item, ctx = T32_CTX) => {
const m = rowModel(item, ctx)
return [m.canReply, m.canApprovePreview, m.canStartBuild, m.canPaste]
}
test('Arten: REPLY_KINDS und PASTE_KINDS sind eingefroren und liegen im Dienst genauso', () => {
assert.deepEqual(REPLY_KINDS, ['mail_antwort', 'unsicher', 'preisfrage', 'mensch_gewuenscht'])
assert.deepEqual(PASTE_KINDS, ['plattform_antwort'])
assert.ok(Object.isFrozen(REPLY_KINDS) && Object.isFrozen(PASTE_KINDS))
assert.equal(REPLY_TEXT_MAX, 8000)
})
test('rowModel canReply: Art, Status, Übernahme und Mail-Kanal (Interessent mit Adresse)', () => {
// Art unsicher, offen, Interessent mit Adresse: ja; vorschau_freigabe und beschwerde: nein
assert.deepEqual(flags(entry()), [true, false, true, false])
assert.equal(rowModel(entry({ kind: 'beschwerde' }), T32_CTX).canReply, false)
assert.equal(rowModel(entry({ kind: 'beschwerde' }), T32_CTX).canApprovePreview, false)
for (const kind of REPLY_KINDS) assert.equal(rowModel(entry({ kind }), T32_CTX).canReply, true, kind)
for (const kind of ['plattform_antwort', 'vorschau_freigabe', 'beschwerde', 'bau_fehlgeschlagen', 'neue_art']) assert.equal(rowModel(entry({ kind }), T32_CTX).canReply, false, kind)
// „Mail ohne Faden“: kein Interessent ⇒ keine Antwort-Funktion; ohne Adresse (Plattform-Interessent) ebenso (Ruling D13)
assert.equal(rowModel(entry({ prospect: null }), T32_CTX).canReply, false)
assert.equal(rowModel(entry({ prospect: undefined }), T32_CTX).canReply, false)
assert.equal(rowModel(entry({ prospect: { name: 'X' } }), T32_CTX).canReply, false)
for (const email of ['', ' ', null, 7]) assert.equal(rowModel(entry({ prospect: { name: 'X', email } }), T32_CTX).canReply, false, String(email))
// Status: offen, in_arbeit (nur eigene Übernahme) und veraltet; alles Abgeschlossene nicht
for (const status of ['offen', 'veraltet']) assert.equal(rowModel(entry({ status }), T32_CTX).canReply, true, status)
assert.equal(rowModel(entry({ status: 'in_arbeit', claimedBy: 'mensch:u1' }), T32_CTX).canReply, true)
assert.equal(rowModel(entry({ status: 'in_arbeit', claimedBy: 'mensch:u2' }), T32_CTX).canReply, false) // Kollege hat übernommen
assert.equal(rowModel(entry({ status: 'in_arbeit', claimedBy: 'max' }), T32_CTX).canReply, false) // Max sendet gerade selbst
assert.equal(rowModel(entry({ status: 'veraltet', claimedBy: 'mensch:u2' }), T32_CTX).canReply, true) // veraltet hält nichts mehr
for (const status of ['gesendet', 'erledigt', 'verworfen']) assert.equal(rowModel(entry({ status }), T32_CTX).canReply, false, status)
})
test('rowModel canApprovePreview und previewHref: nur Art vorschau_freigabe, gleiche Status-Regeln, nur die Entwurfs-Adresse', () => {
const freigabe = (over = {}) => entry({ kind: 'vorschau_freigabe', sourceUrl: PREVIEW_URL, ...over })
let m = rowModel(freigabe(), T32_CTX)
assert.deepEqual([m.canApprovePreview, m.canReply, m.canStartBuild, m.canPaste, m.previewHref], [true, false, false, false, PREVIEW_URL])
// dieselben Status-Regeln wie bei canReply
for (const status of ['offen', 'veraltet']) assert.equal(rowModel(freigabe({ status }), T32_CTX).canApprovePreview, true, status)
assert.equal(rowModel(freigabe({ status: 'in_arbeit', claimedBy: 'mensch:u1' }), T32_CTX).canApprovePreview, true)
assert.equal(rowModel(freigabe({ status: 'in_arbeit', claimedBy: 'mensch:u2' }), T32_CTX).canApprovePreview, false)
for (const status of ['gesendet', 'erledigt', 'verworfen']) assert.equal(rowModel(freigabe({ status }), T32_CTX).canApprovePreview, false, status)
// andere Arten geben nichts frei, auch mit Entwurfs-Adresse
for (const kind of ['unsicher', 'mail_antwort', 'plattform_antwort', 'beschwerde']) {
m = rowModel(freigabe({ kind }), T32_CTX)
assert.deepEqual([m.canApprovePreview, m.previewHref], [false, null], kind)
}
// previewHref: nur https://entwurf.project.webklar.com/…, alles andere null (der Link steht neben dem Knopf „Freigeben“)
for (const bad of ['https://fremd.example/', 'javascript:alert(1)', 'http://entwurf.project.webklar.com/AAA/', 'https://entwurf.project.webklar.com.fremd.example/AAA/',
'https://entwurf.project.webklar.com@fremd.example/AAA/', 'https://user:pw@entwurf.project.webklar.com/AAA/', 'https://entwurf.project.webklar.com:8443/AAA/',
'https://www.project.webklar.com/AAA/', 'https://project.webklar.com/AAA/', '//entwurf.project.webklar.com/AAA/', '', null, undefined, 7]) {
m = rowModel(freigabe({ sourceUrl: bad }), T32_CTX)
assert.equal(m.previewHref, null, String(bad))
assert.equal(m.canApprovePreview, true, String(bad)) // das Modell bleibt bei true; die Zeile sperrt den Knopf ohne gültige Adresse (Endprüfung W5, workersPultUi.test.mjs)
}
assert.equal(rowModel(freigabe({ sourceUrl: 'HTTPS://ENTWURF.PROJECT.WEBKLAR.COM/AAA/' }), T32_CTX).previewHref, PREVIEW_URL) // vom Browser normalisiert
// abgeschlossen bleibt die Adresse im Modell (die Zeile braucht sie dort nicht)
assert.equal(rowModel(freigabe({ status: 'erledigt' }), T32_CTX).previewHref, PREVIEW_URL)
})
test('rowModel (D20): vorschau_freigabe zeigt keine „Anzeige öffnen“-Adresse (sourceUrl ist die Vorschau) und kein „Erledigt“', () => {
const m = rowModel(entry({ kind: 'vorschau_freigabe', sourceUrl: PREVIEW_URL }), T32_CTX)
assert.deepEqual([m.sourceHref, m.canDone, m.canDiscard, m.canSend, m.canClaim], [null, false, true, false, true])
// andere Arten behalten ihre Anzeige-Adresse
assert.equal(rowModel(entry({ kind: 'plattform_antwort', sourceUrl: 'https://www.kleinanzeigen.de/s-anzeige/x/1-2-3' }), T32_CTX).sourceHref, 'https://www.kleinanzeigen.de/s-anzeige/x/1-2-3')
})
test('rowModel canStartBuild (GB16): nur Art unsicher mit prospectId, bearbeitbar und nicht von einem anderen übernommen', () => {
assert.equal(rowModel(entry(), T32_CTX).canStartBuild, true)
assert.equal(rowModel(entry({ prospect: null }), T32_CTX).canStartBuild, true) // die Kurzfassung des Interessenten fehlt, die Id nicht: der Dienst prüft
for (const prospectId of [undefined, null, '', ' ', 7]) assert.equal(rowModel(entry({ prospectId }), T32_CTX).canStartBuild, false, String(prospectId))
for (const kind of ['mail_antwort', 'preisfrage', 'mensch_gewuenscht', 'beschwerde', 'vorschau_freigabe', 'plattform_antwort', 'bau_fehlgeschlagen']) {
assert.equal(rowModel(entry({ kind }), T32_CTX).canStartBuild, false, kind)
}
assert.equal(rowModel(entry({ status: 'in_arbeit', claimedBy: 'mensch:u1' }), T32_CTX).canStartBuild, true)
assert.equal(rowModel(entry({ status: 'in_arbeit', claimedBy: 'mensch:u2' }), T32_CTX).canStartBuild, false)
assert.equal(rowModel(entry({ status: 'veraltet' }), T32_CTX).canStartBuild, true)
for (const status of ['gesendet', 'erledigt', 'verworfen']) assert.equal(rowModel(entry({ status }), T32_CTX).canStartBuild, false, status)
})
// Endprüfung P4 (W2): ein toter Interessent bekommt keinen Knopf „Vorschau bauen“ (der Dienst lehnt ihn ohnehin mit 409 ab)
test('rowModel canStartBuild (W2): bei gesperrtem, verlorenem oder abgeschlossenem Interessenten (gesperrt, verloren, deal) kein Knopf, sonst bleibt er', () => {
for (const status of ['gesperrt', 'verloren', 'deal']) {
assert.equal(rowModel(entry({ prospect: { $id: 'p1', name: 'X', status } }), T32_CTX).canStartBuild, false, status)
}
// lebende Interessenten und Kurzfassungen ohne Status behalten den Knopf (der Dienst prüft das Übrige: doppelte Vorschau)
for (const status of ['gefunden', 'kontaktiert', 'gespraech', 'ja', 'vorschau_bau', undefined, null, 7]) {
assert.equal(rowModel(entry({ prospect: { $id: 'p1', name: 'X', status } }), T32_CTX).canStartBuild, true, String(status))
}
assert.equal(rowModel(entry({ prospect: null }), T32_CTX).canStartBuild, true) // keine Kurzfassung: der Dienst entscheidet
// alles andere zählt weiter: nur Art unsicher, bearbeitbar, nicht von einem anderen gehalten, mit prospectId
assert.equal(rowModel(entry({ prospect: { status: 'gesperrt' }, status: 'veraltet' }), T32_CTX).canStartBuild, false)
assert.equal(rowModel(entry({ prospect: { status: 'verloren' }, prospectId: '' }), T32_CTX).canStartBuild, false)
})
test('rowModel canPaste: Art plattform_antwort mit Interessent, in jedem Status außer verworfen, auch bei einem Kollegen', () => {
const plattform = (over = {}) => entry({ kind: 'plattform_antwort', prospect: { name: 'X' }, ...over })
for (const status of ['offen', 'in_arbeit', 'veraltet', 'gesendet', 'erledigt']) assert.equal(rowModel(plattform({ status }), T32_CTX).canPaste, true, status)
// von einem Kollegen übernommen oder schon gesendet: bleibt einfügbar (die Rückmeldung kommt meist erst nach dem Senden)
assert.equal(rowModel(plattform({ status: 'in_arbeit', claimedBy: 'mensch:u2' }), T32_CTX).canPaste, true)
assert.equal(rowModel(plattform({ status: 'in_arbeit', claimedBy: 'max' }), T32_CTX).canPaste, true)
assert.equal(rowModel(plattform({ status: 'verworfen' }), T32_CTX).canPaste, false)
assert.equal(rowModel(plattform({ prospect: null }), T32_CTX).canPaste, false)
assert.equal(rowModel(plattform({ prospect: undefined }), T32_CTX).canPaste, false)
for (const kind of ['mail_antwort', 'unsicher', 'vorschau_freigabe', 'beschwerde', 'preisfrage', 'mensch_gewuenscht', 'bau_fehlgeschlagen']) {
assert.equal(rowModel(plattform({ kind }), T32_CTX).canPaste, false, kind)
}
// Einfügen und Antworten schließen sich aus: eine Plattform-Antwort geht über die Plattform, eine Mail-Art nie über das Einfügen
assert.deepEqual(flags(plattform()), [false, false, false, true])
})
test('rowModel: eine Übernahme durch einen Worker (max) heißt „Max (automatisch)“, nie „Kollege“ (wie bei doneBy)', () => {
const held = (claimedBy) => rowModel(entry({ kind: 'mail_antwort', status: 'in_arbeit', claimedBy }), T32_CTX)
let m = held('max')
assert.deepEqual([m.claimedByLabel, m.claimedByOther, m.claimedByMe], ['Max (automatisch)', true, false])
assert.equal(held('clara').claimedByLabel, 'Clara (automatisch)')
// Menschen wie bisher; unbekannte Kennungen (auch Objekt-Schlüssel) sind keine Worker
assert.equal(held('mensch:u2').claimedByLabel, 'Kenso')
assert.equal(held('mensch:u9').claimedByLabel, 'Kollege')
for (const by of ['constructor', '__proto__', 'system']) assert.equal(held(by).claimedByLabel, 'Kollege', by)
// ein Mensch, dessen Nutzer-Kennung zufällig wie ein Worker heißt, bleibt ein Mensch
assert.equal(held('mensch:max').claimedByLabel, 'Kollege')
})
test('validateReplyText: getrimmt, leer und zu lang mit den Sätzen des Dienstes, Grenze 8000 Zeichen (Code Points)', () => {
assert.deepEqual(validateReplyText(' '), { ok: false, error: 'Bitte einen Text eingeben.' })
for (const bad of ['', '\n\t ', undefined, null, 5, {}]) assert.deepEqual(validateReplyText(bad), { ok: false, error: 'Bitte einen Text eingeben.' }, String(bad))
assert.deepEqual(validateReplyText(' Hallo '), { ok: true, text: 'Hallo' })
assert.deepEqual(validateReplyText('Zeile 1\n\nZeile 2'), { ok: true, text: 'Zeile 1\n\nZeile 2' }) // innere Umbrüche bleiben
const tooLong = { ok: false, error: 'Der Text ist zu lang (höchstens 8000 Zeichen).' }
assert.deepEqual(validateReplyText('a'.repeat(8001)), tooLong)
assert.equal(validateReplyText('a'.repeat(8000)).ok, true)
assert.equal(validateReplyText(` ${'a'.repeat(8000)} `).ok, true) // gezählt wird der getrimmte Text
// Code Points wie im Dienst: 8000 Emoji sind 8000 Zeichen (16000 UTF-16-Einheiten), 8001 sind zu viel
assert.equal(validateReplyText('😀'.repeat(8000)).ok, true)
assert.deepEqual(validateReplyText('😀'.repeat(8001)), tooLong)
assert.equal(charCount(' 😀a '), 2); assert.equal(charCount(undefined), 0); assert.equal(charCount('a'.repeat(8001)), 8001)
})
test('actionErrorText (D14): bei reply, approve-preview und start-build heißt „keine Antwort“ nicht „nicht gesendet“', () => {
assert.equal(UNCERTAIN_TEXT, 'Kann gesendet sein – Pult neu laden.')
const down = Object.assign(new Error('Der Hustler-Dienst ist gerade nicht erreichbar.'), { status: 0 })
for (const action of ['reply', 'approve-preview', 'start-build']) assert.equal(actionErrorText(down, action), UNCERTAIN_TEXT, action)
// alle anderen Aktionen und alle echten Antworten des Dienstes behalten ihren Satz
for (const action of ['paste', 'claim', 'sent', 'discard', 'done', undefined]) assert.equal(actionErrorText(down, action), down.message, String(action))
// W1: auch die Fehlerseite des Proxys beim Warten (502/504, Meldung „nicht erreichbar“) lässt offen, ob der Dienst fertig wurde; 503 (kein Upstream) und der Rest nicht
for (const status of [502, 504]) {
const gateway = Object.assign(new Error('Der Hustler-Dienst ist gerade nicht erreichbar.'), { status })
for (const action of ['reply', 'approve-preview', 'start-build']) assert.equal(actionErrorText(gateway, action), UNCERTAIN_TEXT, `${action} ${status}`)
for (const action of ['paste', 'claim', 'sent', 'discard', 'done', undefined]) assert.equal(actionErrorText(gateway, action), gateway.message, `${action} ${status}`)
// ein Satz des Dienstes unter demselben Status gehört dem Dienst (502 „Anmeldedienst nicht erreichbar.“ kommt vor der Aktion)
const sentence = Object.assign(new Error('Anmeldedienst nicht erreichbar.'), { status })
assert.equal(actionErrorText(sentence, 'reply'), 'Anmeldedienst nicht erreichbar.', String(status))
}
for (const status of [503, 429, 500, 401, 400, 409]) {
const other = Object.assign(new Error('Der Hustler-Dienst ist gerade nicht erreichbar.'), { status })
assert.equal(actionErrorText(other, 'reply'), other.message, String(status))
}
const conflict = Object.assign(new Error('Wird schon von einem Kollegen bearbeitet.'), { status: 409 })
assert.equal(actionErrorText(conflict, 'reply'), 'Wird schon von einem Kollegen bearbeitet.')
assert.equal(actionErrorText(Object.assign(new Error('Anfrage zu groß.'), { status: 413 }), 'reply'), 'Anfrage zu groß.')
assert.equal(actionErrorText(Object.assign(new Error('Anmeldung abgelaufen. Bitte neu anmelden.'), { status: 401 }), 'reply'), 'Anmeldung abgelaufen. Bitte neu anmelden.')
// kein Satz da: allgemeiner Hinweis
assert.equal(actionErrorText(new Error(''), 'reply'), 'Das hat nicht geklappt.')
assert.equal(actionErrorText(undefined, 'paste'), 'Das hat nicht geklappt.')
assert.equal(actionErrorText(null, 'reply'), 'Das hat nicht geklappt.')
})
test('pasteDoneText: neu und fortgesetzt melden das Einsortieren, doppelt meldet „schon eingefügt“', () => {
assert.equal(pasteDoneText({ status: 'neu', messageId: 'm1', threadId: 't1' }), 'Eingefügt. Nora sortiert die Antwort ein.')
assert.equal(pasteDoneText({ status: 'fortgesetzt' }), 'Eingefügt. Nora sortiert die Antwort ein.')
assert.equal(pasteDoneText({ status: 'doppelt' }), 'Diese Antwort war schon eingefügt.')
for (const odd of [undefined, null, {}, 'doppelt', { status: 7 }]) assert.equal(pasteDoneText(odd), 'Eingefügt. Nora sortiert die Antwort ein.', String(odd))
})
// ---------- Stufen-Sperre (hustlerStufe.js): rowModel auf Stufe 3 und Stufe 6 ----------
const ME = { id: 'u1', name: 'Justin' }
const PROSPEKT = { $id: 'p1', name: 'Verein', email: 'x@verein.example', status: 'neu' }
const eintrag = (extra) => ({ $id: 'e1', kind: 'plattform_antwort', status: 'offen', title: 'Titel', draft: 'Hallo', createdAt: '2026-10-19T08:00:00Z', sourceUrl: 'https://www.kleinanzeigen.de/s-anzeige/x/1-2-3', prospectId: 'p1', prospect: PROSPEKT, ...extra })
const zeile = (item, s) => rowModelNachStufe(item, { me: ME, nowMs: NOW, employees: [], stufe: s })
const P4 = ['canReply', 'canPaste', 'canApprovePreview', 'canStartBuild']
const P3 = ['canClaim', 'canSend', 'canDiscard', 'canDone']
const VORSCHAU = 'https://entwurf.project.webklar.com/verein'
test('rowModel auf Stufe 1 bis 3: canReply, canPaste, canApprovePreview und canStartBuild sind für jede Art und jeden Status false', () => {
const kinds = Object.keys(PULT_KIND_LABELS)
const states = ['offen', 'in_arbeit', 'veraltet', 'gesendet', 'verworfen', 'erledigt']
for (const s of [1, 2, 3]) {
for (const kind of kinds) {
for (const status of states) {
for (const extra of [{}, { claimedBy: 'mensch:u1' }, { claimedBy: 'mensch:u2' }, { prospect: null }]) {
const item = eintrag({ kind, status, sourceUrl: kind === 'vorschau_freigabe' ? VORSCHAU : undefined, ...extra })
const m = zeile(item, s)
for (const key of P4) assert.equal(m[key], false, `Stufe ${s} ${kind}/${status} ${JSON.stringify(extra)}: ${key}`)
}
}
}
}
})
test('rowModel auf Stufe 4, 6 und 9: die P4-Aktionen erscheinen wie vor der Sperre', () => {
for (const s of [4, 6, 9]) {
assert.equal(zeile(eintrag({ kind: 'mail_antwort' }), s).canReply, true, `Stufe ${s}`)
assert.equal(zeile(eintrag({ kind: 'unsicher' }), s).canReply, true)
assert.equal(zeile(eintrag({ kind: 'plattform_antwort', status: 'gesendet' }), s).canPaste, true)
assert.equal(zeile(eintrag({ kind: 'plattform_antwort', status: 'verworfen' }), s).canPaste, false) // die Regeln selbst bleiben
assert.equal(zeile(eintrag({ kind: 'vorschau_freigabe', sourceUrl: VORSCHAU }), s).canApprovePreview, true)
assert.equal(zeile(eintrag({ kind: 'unsicher' }), s).canStartBuild, true)
assert.equal(zeile(eintrag({ kind: 'unsicher', prospect: { ...PROSPEKT, status: 'gesperrt' } }), s).canStartBuild, false)
assert.equal(zeile(eintrag({ kind: 'mail_antwort', claimedBy: 'mensch:u2', status: 'in_arbeit' }), s).canReply, false) // von einem Kollegen übernommen
}
})
test('rowModel: die P3-Aktionen sind auf Stufe 3 und 6 gleich; die Stufe ändert nur die vier P4-Felder', () => {
const fixtures = []
for (const kind of Object.keys(PULT_KIND_LABELS)) {
for (const status of ['offen', 'in_arbeit', 'veraltet', 'gesendet', 'verworfen', 'erledigt']) fixtures.push(eintrag({ kind, status, sourceUrl: kind === 'vorschau_freigabe' ? VORSCHAU : undefined }))
}
fixtures.push(eintrag({ status: 'in_arbeit', claimedBy: 'mensch:u1' }), eintrag({ status: 'in_arbeit', claimedBy: 'mensch:u2' }), eintrag({ kind: 'mail_antwort', sourceUrl: '' }))
for (const item of fixtures) {
const gesperrt = zeile(item, 3); const offen = zeile(item, 6)
for (const key of P3) assert.equal(gesperrt[key], offen[key], `${item.kind}/${item.status}: ${key}`)
const ohneP4 = (m) => Object.fromEntries(Object.entries(m).filter(([k]) => !P4.includes(k)))
assert.deepEqual(ohneP4(gesperrt), ohneP4(offen), `${item.kind}/${item.status}`)
}
// und konkret die P3-Aktionen auf Stufe 3
const plattform = zeile(eintrag({}), 3)
assert.deepEqual([plattform.canClaim, plattform.canSend, plattform.canDiscard, plattform.canDone], [true, true, true, false])
assert.equal(plattform.sourceHref, 'https://www.kleinanzeigen.de/s-anzeige/x/1-2-3'); assert.equal(plattform.copyText, 'Hallo')
const mail = zeile(eintrag({ kind: 'mail_antwort', sourceUrl: '' }), 3)
assert.match(mail.mailtoHref, /^mailto:x@verein\.example\?/); assert.equal(mail.canSend, true)
const unsicher = zeile(eintrag({ kind: 'unsicher' }), 3)
assert.deepEqual([unsicher.canDone, unsicher.canDiscard, unsicher.canClaim], [true, true, true])
const freigabe = zeile(eintrag({ kind: 'vorschau_freigabe', sourceUrl: VORSCHAU }), 3)
assert.deepEqual([freigabe.canClaim, freigabe.canDiscard, freigabe.canDone, freigabe.canSend], [true, true, false, false])
})
test('rowModel ohne Stufe gilt HUSTLER_STUFE (die Stufe der Produktion)', () => {
const item = eintrag({ kind: 'mail_antwort' })
assert.equal(rowModelNachStufe(item, { me: ME, nowMs: NOW, employees: [] }).canReply, HUSTLER_STUFE >= 4)
assert.equal(rowModelNachStufe(item, { me: ME, nowMs: NOW, employees: [], stufe: undefined }).canReply, HUSTLER_STUFE >= 4)
assert.equal(rowModelNachStufe(item, { me: ME, nowMs: NOW, employees: [], stufe: 'x' }).canReply, HUSTLER_STUFE >= 4)
})