Workers P3: reine Pult-Logik für den Reiter Freigabe

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-18 09:27:49 +00:00
1 parent c6f7c74c52
commit c6c022678f
2 files changed
+420

No files matched your search

+192
View File
@@ -0,0 +1,192 @@
// Reine Logik des Reiters „Freigabe“ (Pult): Filter, Alter, Veraltet-Anzeige, sichere Links, Zeilenmodell, Kopieren & Öffnen.
// Kein React, kein Appwrite, kein window/document beim Import: unter Node testbar (loadModule). Nichts hier versendet etwas:
// der Mensch schickt die Antwort selbst ab (Plattform oder eigenes Mailprogramm) und markiert sie danach als gesendet.
const MINUTE_MS = 60 * 1000
const HOUR_MS = 60 * MINUTE_MS
const DAY_MS = 24 * HOUR_MS
// Pult-Arten des Dienstes (Übersicht §4.11) mit ihrem deutschen Namen
export const PULT_KIND_LABELS = Object.freeze({
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',
})
// Nur diese Arten sind Antworten, die ein Mensch absendet; alle anderen schließt er mit „Erledigt“ ab
export const SENDABLE_KINDS = Object.freeze(['plattform_antwort', 'mail_antwort'])
// Die drei Reiter der Liste; `statuses` geht unverändert als Komma-Liste an GET /pult
export const PULT_FILTERS = Object.freeze([
Object.freeze({ id: 'offen', label: 'Wartet', statuses: 'offen,in_arbeit' }),
Object.freeze({ id: 'veraltet', label: 'Veraltet', statuses: 'veraltet' }),
Object.freeze({ id: 'erledigt', label: 'Erledigt', statuses: 'gesendet,verworfen,erledigt' }),
])
const MENSCH = 'mensch:' // Präfix von claimedBy/doneBy: mensch:<userId>
const KOLLEGE = 'Kollege'
const WARTEND = ['offen', 'in_arbeit'] // der Dienst setzt nur diese beiden auf veraltet
const BEARBEITBAR = ['offen', 'in_arbeit', 'veraltet'] // in diesen Status kann ein Mensch den Eintrag abschließen
const MAIL_SUBJECT_MAX = 78
const MAIL_BODY_MAX = 1800
function timeMs(iso) {
return typeof iso === 'string' && iso !== '' ? Date.parse(iso) : NaN
}
/**
* Veraltet-Anzeige eines Eintrags: veraltet (Status veraltet oder staleAt erreicht, nur bei offen/in_arbeit), bald veraltet
* (staleAt in weniger als 24 Stunden: „läuft in N Std. ab“, aufgerundet) oder nichts.
*/
export function staleView(item, nowMs) {
if (item.status === 'veraltet') return { stale: true, soon: false, label: 'veraltet' }
if (!WARTEND.includes(item.status)) return { stale: false, soon: false, label: '' }
const staleAt = timeMs(item.staleAt)
if (Number.isNaN(staleAt)) return { stale: false, soon: false, label: '' }
const remaining = staleAt - nowMs
if (remaining <= 0) return { stale: true, soon: false, label: 'veraltet' }
if (remaining < DAY_MS) return { stale: false, soon: true, label: `läuft in ${Math.ceil(remaining / HOUR_MS)} Std. ab` }
return { stale: false, soon: false, label: '' }
}
/** Alter für die Zeile: gerade eben / vor 5 Min. / vor 3 Std. / vor 1 Tag / vor 2 Tagen; kaputte Zeit ergibt ''. */
export function ageLabel(iso, nowMs) {
const then = timeMs(iso)
if (Number.isNaN(then)) return ''
const age = nowMs - then // negativ, wenn die Uhr des Rechners nachgeht: gilt als gerade eben
if (age < MINUTE_MS) return 'gerade eben'
if (age < HOUR_MS) return `vor ${Math.floor(age / MINUTE_MS)} Min.`
if (age < DAY_MS) return `vor ${Math.floor(age / HOUR_MS)} Std.`
const days = Math.floor(age / DAY_MS)
return days === 1 ? 'vor 1 Tag' : `vor ${days} Tagen`
}
/**
* Eine Adresse, die man in einem neuen Tab öffnen darf: nur https, ohne Zugangsdaten in der Adresse (`https://www.x.de@evil.example`
* sieht nach x.de aus und führt woandershin). Ergebnis ist die vom Browser normalisierte Adresse, sonst null.
*/
export function safeExternalUrl(url) {
if (typeof url !== 'string') return null
let parsed
try {
parsed = new URL(url)
} catch {
return null
}
if (parsed.protocol !== 'https:' || parsed.username !== '' || parsed.password !== '') return null
return parsed.href
}
// Genau eine Adresse: ein '@', links und rechts etwas, kein Leerraum (CR/LF eingeschlossen) und keine Zeichen, die in der
// mailto-Adresse einen zweiten Empfänger, weitere Kopfzeilen oder ein anderes Schema einschleusen würden (? & % , ; : # / \ < > ").
// Ein führendes „mailto:“ fällt über den Doppelpunkt heraus.
const MAIL_ADDRESS = /^[^\s@?&%,;:#/\\<>"\u0000-\u001f\u007f]+@[^\s@?&%,;:#/\\<>"\u0000-\u001f\u007f]+$/
/**
* mailto-Link für „Antwort per E-Mail“. `to` muss genau eine gültige Adresse sein, sonst kommt '' (die Oberfläche zeigt dann keinen
* Mail-Link). Betreff einzeilig und höchstens 78 Zeichen; Text nur bis 1800 Zeichen, sonst entfällt er (zu lange Links öffnet
* das Mailprogramm nicht). Betreff und Text sind mit encodeURIComponent kodiert, Zeilenumbrüche im Text als %0D%0A (RFC 6068).
*/
export function mailtoHref({ to, subject, body } = {}) {
if (typeof to !== 'string' || !MAIL_ADDRESS.test(to)) return ''
const params = []
const singleLine = String(subject ?? '').replace(/[\s\u0000-\u001f\u007f]+/g, ' ').trim()
const cut = Array.from(singleLine).slice(0, MAIL_SUBJECT_MAX).join('').trim()
if (cut !== '') params.push(`subject=${encodeURIComponent(cut)}`)
const text = String(body ?? '')
if (text.trim() !== '' && text.length <= MAIL_BODY_MAX) params.push(`body=${encodeURIComponent(text.replace(/\r\n|\r|\n/g, '\r\n'))}`)
return `mailto:${to}${params.length > 0 ? `?${params.join('&')}` : ''}`
}
// Anzeigename zu einer Kennung `mensch:<userId>` aus der Mitarbeiterliste ({ userId, displayName }); null, wenn keiner da ist
function listedName(by, employees) {
if (typeof by !== 'string' || by === '') return null
const userId = by.startsWith(MENSCH) ? by.slice(MENSCH.length) : by
const found = Array.isArray(employees) ? employees.find((e) => e && e.userId === userId) : undefined
return typeof found?.displayName === 'string' && found.displayName !== '' ? found.displayName : null
}
/** Name zu einer Kennung `mensch:<userId>` über die Mitarbeiterliste; unbekannt oder ohne Namen: „Kollege“. */
export function employeeName(by, employees) {
return listedName(by, employees) ?? KOLLEGE
}
/**
* Zeilenmodell eines Pult-Eintrags (GET /pult: Eintrag plus `prospect`). me = { id, name } des Angemeldeten, employees = Mitarbeiterliste.
* Rechte nach Status (offen, in_arbeit, veraltet sind bearbeitbar) und Art (nur Antwort-Arten sind sendbar); die Zeile sperrt
* nichts wegen eines Kollegen, der übernommen hat: der Dienst entscheidet (409 mit deutschem Satz).
*/
export function rowModel(item, { me, nowMs, employees }) {
const editable = BEARBEITBAR.includes(item.status)
const sendable = SENDABLE_KINDS.includes(item.kind)
const prospect = item.prospect ?? null
// Übernommen ist nur ein Eintrag in Arbeit; bei veraltet steht der frühere Bearbeiter noch drin, ohne etwas zu halten
const held = item.status === 'in_arbeit' && typeof item.claimedBy === 'string' && item.claimedBy !== ''
const claimedByMe = held && me != null && me.id != null && item.claimedBy === `${MENSCH}${me.id}`
const claimedByOther = held && !claimedByMe
let claimedByLabel = ''
if (held) {
const ownName = claimedByMe && typeof me.name === 'string' && me.name !== '' ? me.name : null // eigener Name, falls die Liste ihn nicht kennt
claimedByLabel = listedName(item.claimedBy, employees) ?? ownName ?? KOLLEGE
}
const draft = typeof item.draft === 'string' ? item.draft : ''
let mail = ''
if (item.kind === 'mail_antwort' && prospect !== null && prospect.email) {
mail = mailtoHref({ to: prospect.email, subject: prospect.name ? `Re: ${prospect.name}` : '', body: draft })
}
return {
id: item.$id,
kind: item.kind,
kindLabel: PULT_KIND_LABELS[item.kind] ?? item.kind,
title: item.title,
ageLabel: ageLabel(item.createdAt, nowMs),
stale: staleView(item, nowMs),
canClaim: item.status === 'offen' || item.status === 'veraltet',
canSend: sendable && editable,
canDiscard: editable,
canDone: !sendable && editable,
claimedByMe,
claimedByOther,
claimedByLabel,
copyText: draft,
sourceHref: safeExternalUrl(item.sourceUrl),
mailtoHref: mail,
prospect,
}
}
/**
* „Kopieren & öffnen“: startet das Kopieren und öffnet danach sofort die Quelle, ohne dazwischen zu warten (der Browser erlaubt
* das Öffnen nur im Zug der Nutzer-Geste). Erst danach wird auf das Kopieren gewartet. `copy(text)` liefert ein Versprechen, `open(url)`
* öffnet den Tab. Ergebnis: copied = das Versprechen von copy wurde erfüllt; opened = open wurde mit einer sicheren Adresse aufgerufen
* und hat nicht geworfen (der Rückgabewert zählt nicht: window.open mit noopener liefert immer null). Ein Fehler beim Kopieren ändert
* das Öffnen nicht und umgekehrt.
*/
export async function copyAndOpen({ text, url, copy, open }) {
let copied
try {
copied = Promise.resolve(copy(text)).then(() => true, () => false)
} catch {
copied = Promise.resolve(false)
}
let opened = false
const href = safeExternalUrl(url)
if (href !== null) {
try {
open(href)
opened = true
} catch {
opened = false
}
}
return { copied: await copied, opened }
}
+228
View File
@@ -0,0 +1,228 @@
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
})
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)
})