import fs from 'fs/promises'; import os from 'os'; import path from 'path'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { ChatCache } from '../src/services/chat/cache.js'; import { MatrixClient } from '../src/services/chat/matrix/client.js'; import { ChatService } from '../src/services/chat/service.js'; import { createMockLogger } from './mockLogger.js'; import { authenticatedAccountAuth } from './testUtils.js'; const temporaryDirectories: string[] = []; afterEach(async () => { await Promise.all(temporaryDirectories.splice(0).map((directory) => fs.rm(directory, { recursive: true, force: true }), )); }); describe('ChatService text delta sync', () => { it('merges a sent text event into conversation history without a media service', async () => { const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'chat-text-sync-')); temporaryDirectories.push(directory); const cacheFile = path.join(directory, 'chat-cache.json'); const now = Date.now(); await fs.writeFile(cacheFile, JSON.stringify({ version: 1, savedAt: now, chats: [{ username: 'alice', roomName: 'alice', roomId: '!alice:example.test', lastMessageTime: now - 1_000, lastMessageSnippet: 'hello', isFromUs: false, unread: false, messages: [{ sender: 'alice', text: 'hello', timestamp: now - 1_000, isSelf: false }], }], nextBatch: 's1', lastDeltaSyncAt: now, }), 'utf-8'); const logger = createMockLogger(); const matrix = new MatrixClient({ homeserver: 'https://matrix.example.test', accessToken: 'token', userId: '@me:example.test', capturedAt: now, }, logger, { minRequestIntervalMs: 0 }); matrix.sync = vi.fn(async () => ({ next_batch: 's2', rooms: { join: { '!alice:example.test': { timeline: { events: [{ event_id: '$sent', type: 'm.room.message', sender: '@me:example.test', origin_server_ts: now, content: { msgtype: 'm.text', body: 'AI-assisted reply' }, }], }, }, }, }, })) as typeof matrix.sync; const cache = new ChatCache(logger, cacheFile); const service = new ChatService( matrix, logger, undefined, undefined, undefined, undefined, cache, undefined, undefined, authenticatedAccountAuth, ); await service.loadCache(); await service.refreshTopChats(); const [conversation] = await service.getCachedConversations(); expect(conversation.messages).toEqual([ { sender: 'alice', text: 'hello', timestamp: now - 1_000, isSelf: false }, { eventId: '$sent', sender: 'Me', text: 'AI-assisted reply', timestamp: now, isSelf: true }, ]); const persisted = JSON.parse(await fs.readFile(cacheFile, 'utf-8')) as { chats: Array<{ messages: unknown[] }>; }; expect(persisted.chats[0].messages).toHaveLength(2); }); it('reconciles an optimistic opening message with its Matrix event', async () => { const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'chat-text-reconcile-')); temporaryDirectories.push(directory); const cacheFile = path.join(directory, 'chat-cache.json'); const serverTimestamp = Date.now(); await fs.writeFile(cacheFile, JSON.stringify({ version: 1, savedAt: serverTimestamp, chats: [{ username: 'alice', roomName: 'alice', roomId: '!alice:example.test', lastMessageTime: serverTimestamp + 80, lastMessageSnippet: 'hello', isFromUs: true, unread: false, messages: [{ eventId: '$opening', sender: 'Me', text: 'hello', timestamp: serverTimestamp + 80, isSelf: true, }], }], nextBatch: 's1', lastDeltaSyncAt: serverTimestamp, }), 'utf-8'); const logger = createMockLogger(); const matrix = new MatrixClient({ homeserver: 'https://matrix.example.test', accessToken: 'token', userId: '@me:example.test', capturedAt: serverTimestamp, }, logger, { minRequestIntervalMs: 0 }); matrix.sync = vi.fn(async () => ({ next_batch: 's2', rooms: { join: { '!alice:example.test': { timeline: { events: [{ event_id: '$opening', type: 'm.room.message', sender: '@me:example.test', origin_server_ts: serverTimestamp, content: { msgtype: 'm.text', body: 'hello' }, }], }, }, }, }, })) as typeof matrix.sync; const service = new ChatService( matrix, logger, undefined, undefined, undefined, undefined, new ChatCache(logger, cacheFile), undefined, undefined, authenticatedAccountAuth, ); await service.loadCache(); await service.refreshTopChats(); const [conversation] = await service.getCachedConversations(); expect(conversation.messages).toEqual([{ eventId: '$opening', sender: 'Me', text: 'hello', timestamp: serverTimestamp, isSelf: true, }]); }); it('repairs a legacy cached opening-message duplicate', async () => { const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'chat-text-legacy-repair-')); temporaryDirectories.push(directory); const cacheFile = path.join(directory, 'chat-cache.json'); const serverTimestamp = Date.now(); const message = { sender: 'Me', text: 'hello', isSelf: true }; await fs.writeFile(cacheFile, JSON.stringify({ version: 1, savedAt: serverTimestamp, chats: [{ username: 'alice', roomName: 'alice', roomId: '!alice:example.test', lastMessageTime: serverTimestamp, lastMessageSnippet: 'hello', isFromUs: true, unread: false, messages: [ { ...message, timestamp: serverTimestamp + 80 }, { ...message, timestamp: serverTimestamp }, ], }], nextBatch: 's1', lastDeltaSyncAt: serverTimestamp, }), 'utf-8'); const logger = createMockLogger(); const matrix = new MatrixClient({ homeserver: 'https://matrix.example.test', accessToken: 'token', userId: '@me:example.test', capturedAt: serverTimestamp, }, logger, { minRequestIntervalMs: 0 }); const service = new ChatService( matrix, logger, undefined, undefined, undefined, undefined, new ChatCache(logger, cacheFile), undefined, undefined, authenticatedAccountAuth, ); await service.loadCache(); const [conversation] = await service.getCachedConversations(); expect(conversation.messages).toEqual([{ ...message, timestamp: serverTimestamp }]); const persisted = JSON.parse(await fs.readFile(cacheFile, 'utf-8')) as { chats: Array<{ messages: unknown[] }>; }; expect(persisted.chats[0].messages).toHaveLength(1); }); });