feat(mobile): KI-AGENT: Föderierte Chats und gemeinsame Sichtbarkeit ergänzen

This commit is contained in:
2026-10-19 11:40:26 +02:00
parent bfba29b15e
commit da84e98b8a
14 changed files with 1107 additions and 289 deletions

View File

@@ -0,0 +1,121 @@
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(); }
});

View File

@@ -0,0 +1,56 @@
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);
function harness() {
let resolvePicker;
const picker = new Promise(resolve => { resolvePicker = resolve; });
const drafts = [];
const chat = { detail: { name: 'Test', canSend: true }, messages: [], members: [], reactions: {}, loading: false };
const output = ts.transpileModule(readFileSync(new URL('../components/chat-conversation.tsx', import.meta.url), 'utf8'), {
compilerOptions: { module: ts.ModuleKind.CommonJS, jsx: ts.JsxEmit.ReactJSX, target: ts.ScriptTarget.ES2022 },
}).outputText;
const exports = {};
new Function('require', 'exports', output)(name => {
if (name === 'react-native') return { ...Object.fromEntries(['ActivityIndicator', 'FlatList', 'Modal', 'Pressable', 'ScrollView', 'Text', 'TextInput', 'View'].map(key => [key, key])), StyleSheet: { create: value => value } };
if (name === '@expo/vector-icons') return { Ionicons: 'Icon' };
if (name === 'expo-image') return { Image: 'Image' };
if (name === 'expo-image-picker') return { launchImageLibraryAsync: () => picker };
if (name === '@/hooks/use-chat-conversation') return { useChatConversation: () => chat };
if (name === '@/src/lib/chat-api') return { chatApi: {} };
if (name === '@/src/lib/chat-image') return { prepareMobileChatImage: async () => ({ name: 'Bild.jpg', dataUrl: 'data:image/jpeg;base64,YQ==' }) };
if (name === '@/src/lib/chat') return chatModel;
return require(name);
}, exports);
const props = { token: 't', room: { key: 'test', name: 'Test' }, enabled: true,
draft: { text: 'Bild dazu', image: null, clientId: 'draft' }, onDraft: draft => drafts.push(draft) };
let root;
return {
drafts,
async render(enabled) { await act(async () => { const element = React.createElement(exports.ChatConversation, { ...props, enabled }); if (root) root.update(element); else root = create(element); }); },
async pick() { await act(async () => root.root.findByProps({ accessibilityLabel: 'Bild anhängen' }).props.onPress()); },
async finish() { await act(async () => resolvePicker({ canceled: false, assets: [{ uri: 'file:///test.jpg' }] })); },
async dispose() { await act(async () => root.unmount()); },
};
}
test('System-Fotoauswahl darf die App vorübergehend in den Hintergrund versetzen', async () => {
const h = harness();
try {
await h.render(true); await h.pick(); await h.render(false); await h.finish();
assert.equal(h.drafts.length, 1);
assert.equal(h.drafts[0].text, 'Bild dazu');
assert.equal(h.drafts[0].image.name, 'Bild.jpg');
} finally { await h.dispose(); }
});
test('Eine verspätete Fotoauswahl verändert nach Raumwechsel keinen anderen Entwurf', async () => {
const h = harness();
await h.render(true); await h.pick(); await h.dispose(); await h.finish();
assert.equal(h.drafts.length, 0);
});

133
mobile/tests/chat.test.mjs Normal file
View File

@@ -0,0 +1,133 @@
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}`);
});