Abgeschlossene Zeilen tragen jetzt ein Badge (Gesendet grün, Verworfen/Erledigt neutral) und die Zeile "Gesendet von Anna · vor 2 Std." aus doneBy/doneAt statt des Alters ab Anlage. Menschen (mensch:<id>) werden über die Mitarbeiterliste aufgelöst, ein Worker wie max über das Rollen-Register als "Max (automatisch)" und nie als "Kollege". Offene Zeilen bleiben unverändert. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
280 lines
21 KiB
JavaScript
280 lines
21 KiB
JavaScript
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, staleView, ageLabel, safeExternalUrl, mailtoHref, employeeName, rowModel, copyAndOpen } =
|
||
await loadModule('src/lib/workersPult.js')
|
||
|
||
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,<b>x</b>', '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>', '"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 ['vorschau_freigabe', '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)
|
||
}
|
||
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:<id>" ü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)
|
||
})
|