import assert from 'node:assert/strict'; import { test } from 'node:test'; import { applyChatAccess, chatCursor, chatEndpoint, collaborationKey, createChatApi, matchesChatSearch, mergeChatMessages, validChatImage, visibleChatRooms, } from '../src/lib/chat.ts'; const id = '11111111-1111-4111-8111-111111111111'; const internal = { key: 'allgemein', name: 'Allgemein', source: 'internal', exists: true }; const remote = { key: collaborationKey(id), id, name: 'Partner', source: 'collaboration', exists: true }; function fixture(overrides = {}) { const responses = { '/api/communication/chat/rooms': { rooms: [internal] }, '/api/communication/chat/project-rooms': { rooms: [{ key: 'project_42', name: 'Projekt', exists: true }] }, '/api/communication/chat/direct-rooms': { rooms: [] }, '/api/communication/chat/unread': { rooms: { allgemein: { count: 7 }, project_42: { count: 2 } } }, '/api/communication/chat/room-preferences': { hiddenRoomKeys: ['allgemein', collaborationKey(id)] }, '/api/collaboration': { rooms: [{ id, name: 'Partner', remote: true, unread: 11 }], invitations: [{ id: 'invite', name: 'Team' }], managedRooms: [{ id: 'not-a-member', name: 'Verwalteter Raum ohne Teilnahme' }], canManage: true, projects: [{ id: 42, name: 'Projekt' }] }, ...overrides, }; const calls = []; return { calls, api: createChatApi(async (path, options) => { calls.push({ path, ...options }); if (!(path in responses)) throw new Error(`Unexpected request: ${path}`); if (responses[path] instanceof Error) throw responses[path]; return structuredClone(responses[path]); }) }; } test('Web-Sichtbarkeit gilt auch für föderierte Räume und den Ungelesen-Zähler', async () => { const { api, calls } = fixture(); const directory = await api.directory('tenant-token'); assert.deepEqual(visibleChatRooms(directory).map(room => room.key), ['project_42']); assert.equal(directory.rooms.length, 3); // No implicit access via managedRooms. assert.equal(directory.rooms[2].source, 'collaboration'); assert.equal(directory.rooms[2].remote, true); assert.equal(directory.invitations[0].id, 'invite'); assert.equal(await api.unread('tenant-token'), 2); assert.ok(calls.every(call => call.token === 'tenant-token')); }); test('Fehlende Sichtbarkeitseinstellungen zeigen keine ungefilterte Chatliste', async () => { const { api } = fixture({ '/api/communication/chat/room-preferences': new Error('offline') }); await assert.rejects(api.directory('token'), /offline/); await assert.rejects(api.unread('token'), /offline/); }); test('Föderierte Endpunkte, Cursor und Lesebestätigungen verwenden Raum-ID und Sequenz', async () => { const calls = []; const api = createChatApi(async (path, options) => { calls.push({ path, ...options }); return { messages: [] }; }); await api.messages('t', remote, { after: 50 }); await api.messages('t', remote, { before: 20 }); await api.read('t', remote, 60); await api.messages('t', internal, { after: 12 }); await api.read('t', internal, 14); assert.equal(calls[0].path, `/api/collaboration/rooms/${id}/messages?after=50`); assert.equal(calls[1].path, `/api/collaboration/rooms/${id}/messages?before=20`); assert.deepEqual(calls[2].body, { seq: 60 }); assert.equal(calls[3].path, '/api/communication/chat/rooms/allgemein/sync?afterId=12'); assert.deepEqual(calls[4].body, { messageId: 14 }); assert.equal(chatCursor(remote, [{ id: 'pending', seq: null }, { id: 'uuid', seq: 7 }]), 7); }); test('Bestätigungen ersetzen wartende Nachrichten ohne doppelte oder verspätete Kopien', () => { const pending = { id: 'client-1', clientId: 'client-1', own: true, pending: true, seq: null }; const delivered = { id: 'server-1', clientId: 'client-1', own: true, seq: 9 }; const first = mergeChatMessages([pending], [delivered], true); assert.deepEqual(first, [delivered]); assert.deepEqual(mergeChatMessages(first, [pending], true), [delivered]); assert.deepEqual(mergeChatMessages([], [delivered, pending], true), [delivered]); assert.deepEqual(mergeChatMessages([{ id: 2 }], [{ id: 1 }, { id: 2, body: 'aktualisiert' }], false), [{ id: 1 }, { id: 2, body: 'aktualisiert' }]); }); test('Geänderte Teilnahmerechte entfernen frühere Historie und alte ausstehende Nachrichten', () => { const messages = [{ id: 'old', seq: 1 }, { id: 'new', seq: 8 }, { id: 'pending', seq: null }]; assert.deepEqual(applyChatAccess(messages, { historyFromSeq: 5, revision: 2 }, { revision: 1 }), [{ id: 'new', seq: 8 }]); assert.equal(applyChatAccess(messages, { historyFromSeq: 5, revision: 1 }, { revision: 1 }).length, 2); }); test('Senden behält die Idempotenz-ID und überträgt Bilder an die passende Schnittstelle', async () => { const calls = []; const api = createChatApi(async (path, options) => { calls.push({ path, ...options }); return {}; }); const image = { name: 'Foto.jpg', dataUrl: 'data:image/jpeg;base64,YQ==' }; await api.send('t', remote, '', image, 'same-client-id'); await api.send('t', remote, '', image, 'same-client-id'); await api.send('t', internal, 'Hallo', null, 'unused'); assert.deepEqual(calls[0], calls[1]); assert.deepEqual(calls[0].body, { text: '', image, clientId: 'same-client-id' }); assert.deepEqual(calls[2].body, { text: 'Hallo' }); }); test('Einladungen und ausgeblendete Räume verwenden dieselben Schlüssel wie das Web', async () => { const calls = []; const api = createChatApi(async (path, options) => { calls.push({ path, ...options }); return {}; }); await api.respond('t', id, true); await api.respond('t', id, false); await api.hide('t', remote.key, true); await api.hide('t', remote.key, false); assert.equal(calls[0].path, `/api/collaboration/invitations/${id}/accept`); assert.equal(calls[1].path, `/api/collaboration/invitations/${id}/decline`); assert.deepEqual(calls[2].body, { roomKey: collaborationKey(id), hidden: true }); assert.equal(calls[3].body.hidden, false); }); test('Reaktionen werden begrenzt geladen; wartende Nachrichten bleiben ausgeschlossen', async () => { const calls = []; const api = createChatApi(async (path, options) => { calls.push({ path, ...options }); return { reactions: {} }; }); const messages = Array.from({ length: 205 }, (_, index) => ({ id: index + 1 })); messages.push({ id: 'pending', pending: true }, { id: 'failed', failed: true }); await api.reactions('t', remote, messages); assert.equal(calls.length, 3); for (const call of calls) { const ids = new URL(call.path, 'https://example.invalid').searchParams.get('ids').split(','); assert.ok(ids.length <= 100); assert.ok(ids.every(value => /^\d+$/.test(value))); } await api.react('t', remote, id, '👍', false); assert.deepEqual(calls[3].body, { messageId: id, emoji: '👍', active: false }); }); test('Chatbilder akzeptieren keine externen URLs, SVGs oder übergroßen Inhalte', async () => { for (const value of ['https://tracker.invalid/image.png', 'data:image/svg+xml;base64,YQ==', `data:image/png;base64,${'a'.repeat(700_000)}`]) { assert.equal(validChatImage(value), false); const api = createChatApi(async () => ({ dataUrl: value })); await assert.rejects(api.image('t', remote, id), /Ungültiges Chatbild/); } assert.equal(validChatImage('data:image/webp;base64,YQ=='), true); }); test('Suche berücksichtigt mehrere Begriffe, Umlaute und Partnernamen', () => { assert.equal(matchesChatSearch({ name: 'Büro Süd', ownerName: 'Straße GmbH' }, 'buro strasse'), true); assert.equal(matchesChatSearch({ name: 'Büro Süd', ownerName: 'Straße GmbH' }, 'buro nord'), false); }); test('Noch nicht angelegte Direkt- und Projektchats werden gezielt provisioniert', async () => { const calls = []; const api = createChatApi(async (path, options) => { calls.push({ path, ...options }); return { exists: true }; }); await api.provision('t', { ...internal, exists: false, type: 'project', projectId: 42 }); await api.provision('t', { ...internal, exists: false, type: 'direct', userId: 'user/id' }); await api.provision('t', remote); assert.equal(calls.length, 2); assert.equal(calls[0].path, '/api/communication/chat/project-rooms/42/provision'); assert.equal(calls[1].path, '/api/communication/chat/direct-rooms/user%2Fid/provision'); assert.equal(chatEndpoint(remote), `/api/collaboration/rooms/${id}`); });