import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { createRequire } from 'node:module'; import { test } from 'node:test'; import React from 'react'; import { act, create } from 'react-test-renderer'; import ts from 'typescript'; import * as chatModel from '../src/lib/chat.ts'; globalThis.IS_REACT_ACT_ENVIRONMENT = true; const require = createRequire(import.meta.url); const remote = { key: 'collaboration:a', id: 'a', source: 'collaboration', name: 'A', exists: true }; const detail = { name: 'A', canSend: true, historyFromSeq: 1, revision: 1 }; const deferred = () => { let resolve, reject; const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); return { promise, resolve, reject }; }; function harness(overrides = {}) { const reads = [], sends = [], timers = new Map(); const api = { detail: async () => detail, messages: async () => ({ messages: [{ id: 'first', seq: 1, body: 'Start' }] }), members: async () => [{ name: 'Partner' }], reactions: async () => ({}), read: async (...args) => { reads.push(args); }, send: async (...args) => { sends.push(args); return { id: 'sent', seq: 2, own: true }; }, react: async () => ({ reactions: {} }), ...overrides, }; const output = ts.transpileModule(readFileSync(new URL('../hooks/use-chat-conversation.ts', import.meta.url), 'utf8'), { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, }).outputText; const exports = {}; new Function('require', 'exports', 'setInterval', 'clearInterval', output)(name => { if (name === '@/src/lib/chat-api') return { chatApi: api }; if (name === '@/src/lib/chat') return chatModel; return require(name); }, exports, callback => { const id = Symbol(); timers.set(id, callback); return id; }, id => timers.delete(id)); let latest, root; function Probe(props) { latest = exports.useChatConversation(props.token || 'token', props.room, props.enabled ?? true); return null; } return { api, reads, sends, get state() { return latest; }, async render(room = remote, enabled = true, token = 'token') { await act(async () => { const element = React.createElement(Probe, { key: `${token}:${room.key}`, room, enabled, token }); if (root) root.update(element); else root = create(element); }); }, async poll() { await act(async () => { for (const callback of timers.values()) callback(); }); }, async dispose() { await act(async () => root?.unmount()); }, }; } test('Verspätete Antworten des vorherigen Raums werden nach Raumwechsel verworfen', async () => { const pending = deferred(); const h = harness({ detail: async (_token, room) => room.id === 'a' ? pending.promise : { ...detail, name: 'B' }, messages: async (_token, room) => ({ messages: [{ id: room.id, seq: 1, body: room.name }] }) }); try { await h.render(); await h.render({ ...remote, key: 'collaboration:b', id: 'b', name: 'B' }); await act(async () => pending.resolve(detail)); assert.equal(h.state.detail.name, 'B'); assert.equal(h.state.messages[0].body, 'B'); assert.ok(h.reads.every(([, room]) => room.id === 'b')); } finally { await h.dispose(); } }); test('Mandantenwechsel verwirft laufende Antworten der alten Sitzung', async () => { const pending = deferred(); const h = harness({ messages: async token => token === 'old' ? pending.promise : { messages: [{ id: 'new', seq: 3 }] } }); try { await h.render(remote, true, 'old'); await h.render(remote, true, 'new'); await act(async () => pending.resolve({ messages: [{ id: 'private-old', seq: 9 }] })); assert.deepEqual(h.state.messages.map(message => message.id), ['new']); assert.ok(h.reads.every(([token]) => token === 'new')); } finally { await h.dispose(); } }); test('Entzogener Zugriff entfernt Inhalt und ignoriert danach eintreffende Sendeantworten', async () => { const pending = deferred(); let forbidden = false; const h = harness({ detail: async () => { if (forbidden) throw Object.assign(new Error('Zugriff entzogen'), { status: 403 }); return detail; }, send: () => pending.promise }); try { await h.render(); let send; await act(async () => { send = h.state.send('Hallo', null, 'stable'); }); forbidden = true; await h.poll(); assert.equal(h.state.detail, null); assert.deepEqual(h.state.messages, []); assert.deepEqual(h.state.members, []); await act(async () => pending.resolve({ id: 'late', seq: 7 })); assert.equal(await send, true); // The old draft can be cleared, but no content reappears. assert.deepEqual(h.state.messages, []); assert.equal(await h.state.send('Nicht erlaubt', null, 'blocked'), false); } finally { await h.dispose(); } }); test('Neue Leserechte und Historiengrenzen werden während des Pollings übernommen', async () => { let revision = 1; const h = harness({ detail: async () => ({ ...detail, revision, canSend: revision === 1, historyFromSeq: revision === 1 ? 1 : 5 }), messages: async () => ({ messages: revision === 1 ? [{ id: 'old', seq: 1 }, { id: 'allowed', seq: 5 }, { id: 'queued', seq: null, pending: true }] : [] }) }); try { await h.render(); revision = 2; await h.poll(); assert.deepEqual(h.state.messages.map(message => message.id), ['allowed']); assert.equal(h.state.detail.canSend, false); assert.equal(await h.state.send('Gesperrt', null, 'no-send'), false); assert.equal(h.sends.length, 0); } finally { await h.dispose(); } }); test('Doppelklick sendet nur einmal; fehlgeschlagene Nachrichten können mit derselben ID wiederholt werden', async () => { const pending = deferred(), ids = []; let attempt = 0; const h = harness({ send: (_token, _room, _text, _image, clientId) => { ids.push(clientId); return ++attempt === 1 ? pending.promise : Promise.resolve({ id: 'sent', seq: 2 }); } }); try { await h.render(); let first, second; await act(async () => { first = h.state.send('Hallo', null, 'same-id'); second = h.state.send('Hallo', null, 'same-id'); }); assert.equal(await second, false); assert.equal(ids.length, 1); await act(async () => pending.reject(new Error('Netzwerkfehler'))); assert.equal(await first, false); await act(async () => assert.equal(await h.state.send('Hallo', null, 'same-id'), true)); assert.deepEqual(ids, ['same-id', 'same-id']); } finally { await h.dispose(); } }); test('Im Hintergrund werden keine Nachrichten abgefragt oder als gelesen markiert', async () => { const h = harness(); try { await h.render(remote, false); await h.poll(); assert.equal(h.reads.length, 0); await h.render(remote, true); assert.equal(h.reads.length, 1); await h.render(remote, false); await h.poll(); assert.equal(h.reads.length, 1); } finally { await h.dispose(); } });