From c6c022678f845099ca0b664e77ec5cef403bd6c5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 09:27:49 +0000 Subject: [PATCH] =?UTF-8?q?Workers=20P3:=20reine=20Pult-Logik=20f=C3=BCr?= =?UTF-8?q?=20den=20Reiter=20Freigabe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- src/lib/workersPult.js | 192 +++++++++++++++++++++++++++ tests/unit/workersPult.test.mjs | 228 ++++++++++++++++++++++++++++++++ 2 files changed, 420 insertions(+) create mode 100644 src/lib/workersPult.js create mode 100644 tests/unit/workersPult.test.mjs diff --git a/src/lib/workersPult.js b/src/lib/workersPult.js new file mode 100644 index 0000000..8f81182 --- /dev/null +++ b/src/lib/workersPult.js @@ -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: +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:` 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:` ü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 } +} diff --git a/tests/unit/workersPult.test.mjs b/tests/unit/workersPult.test.mjs new file mode 100644 index 0000000..681b11f --- /dev/null +++ b/tests/unit/workersPult.test.mjs @@ -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,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 ['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) +})