232 lines
7.1 KiB
TypeScript
232 lines
7.1 KiB
TypeScript
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);
|
|
});
|
|
});
|