184 lines
6.5 KiB
TypeScript
184 lines
6.5 KiB
TypeScript
import { describe, it, expect, vi } from 'vitest';
|
|
import fs from 'fs/promises';
|
|
import os from 'os';
|
|
import path from 'path';
|
|
import { ChatService } from '../src/services/chat/service.js';
|
|
import { ChatCache } from '../src/services/chat/cache.js';
|
|
import { MatrixClient } from '../src/services/chat/matrix/client.js';
|
|
import { MediaService } from '../src/services/media.js';
|
|
import type { MediaDownloader } from '../src/services/media.js';
|
|
import { createMockLogger } from './mockLogger.js';
|
|
import { authenticatedAccountAuth } from './testUtils.js';
|
|
|
|
const creds = {
|
|
homeserver: 'https://matrix.example.test',
|
|
accessToken: 'tok',
|
|
userId: '@me:example.test',
|
|
capturedAt: Date.now(),
|
|
};
|
|
|
|
function buildDownloader(): MediaDownloader {
|
|
return {
|
|
fetch: vi.fn(async () => ({
|
|
buffer: Buffer.from('image-bytes'),
|
|
contentType: 'image/jpeg',
|
|
})),
|
|
};
|
|
}
|
|
|
|
describe('ChatService delta sync — media attachment', () => {
|
|
it('downloads media for image events arriving via /sync', async () => {
|
|
const logger = createMockLogger();
|
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'chat-delta-media-'));
|
|
try {
|
|
const matrix = new MatrixClient(creds, logger, { minRequestIntervalMs: 0 });
|
|
matrix.sync = vi.fn(async () => ({
|
|
next_batch: 's2',
|
|
rooms: {
|
|
join: {
|
|
'!room1:example.test': {
|
|
timeline: {
|
|
events: [
|
|
{
|
|
type: 'm.room.message',
|
|
sender: '@t2_abc:example.test',
|
|
origin_server_ts: 1000,
|
|
content: { msgtype: 'm.image', url: 'mxc://example.test/img1' },
|
|
},
|
|
{
|
|
type: 'm.room.message',
|
|
sender: '@t2_abc:example.test',
|
|
origin_server_ts: 1100,
|
|
content: { body: 'plain text follow-up' },
|
|
},
|
|
],
|
|
},
|
|
},
|
|
},
|
|
},
|
|
})) as typeof matrix.sync;
|
|
|
|
const downloader = buildDownloader();
|
|
const media = new MediaService(downloader, logger, tmpDir);
|
|
const cache = new ChatCache(logger, path.join(tmpDir, 'chat-cache.json'));
|
|
const svc = new ChatService(matrix, logger, undefined, media, undefined, undefined, cache, undefined, undefined, authenticatedAccountAuth);
|
|
|
|
await svc.getActiveChats();
|
|
|
|
expect(downloader.fetch).toHaveBeenCalledTimes(1);
|
|
const items = await media.getMediaItems();
|
|
expect(items).toHaveLength(1);
|
|
expect(items[0].originalUrl).toBe('mxc://example.test/img1');
|
|
expect(items[0].chatRoomId).toBe('!room1:example.test');
|
|
} finally {
|
|
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('appends new media messages to chats that already have loaded history', async () => {
|
|
const logger = createMockLogger();
|
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'chat-delta-media-merge-'));
|
|
const cacheFile = path.join(tmpDir, 'chat-cache.json');
|
|
try {
|
|
const now = Date.now();
|
|
const existing = {
|
|
version: 1,
|
|
savedAt: now - 60_000,
|
|
chats: [
|
|
{
|
|
username: 'alice',
|
|
roomName: 'alice',
|
|
lastMessageTime: now - 5_000,
|
|
lastMessageSnippet: 'hi',
|
|
isFromUs: false,
|
|
unread: false,
|
|
roomId: '!room1:example.test',
|
|
messages: [
|
|
{ sender: 't2_abc', text: 'hi', timestamp: now - 5_000, isSelf: false },
|
|
],
|
|
},
|
|
],
|
|
nextBatch: 's1',
|
|
lastFullSyncAt: now - 120_000,
|
|
lastDeltaSyncAt: now - 60_000,
|
|
cooldown: null,
|
|
};
|
|
const imageTs = now - 1_000;
|
|
await fs.writeFile(cacheFile, JSON.stringify(existing), 'utf-8');
|
|
|
|
const matrix = new MatrixClient(creds, logger, { minRequestIntervalMs: 0 });
|
|
matrix.sync = vi.fn(async () => ({
|
|
next_batch: 's2',
|
|
rooms: {
|
|
join: {
|
|
'!room1:example.test': {
|
|
timeline: {
|
|
events: [
|
|
{
|
|
type: 'm.room.message',
|
|
sender: '@t2_abc:example.test',
|
|
origin_server_ts: imageTs,
|
|
content: { msgtype: 'm.image', url: 'mxc://example.test/img1' },
|
|
},
|
|
],
|
|
},
|
|
},
|
|
},
|
|
},
|
|
})) as typeof matrix.sync;
|
|
|
|
const media = new MediaService(buildDownloader(), logger, tmpDir);
|
|
const cache = new ChatCache(logger, cacheFile);
|
|
const svc = new ChatService(matrix, logger, undefined, media, undefined, undefined, cache, undefined, undefined, authenticatedAccountAuth);
|
|
await svc.loadCache();
|
|
|
|
// refreshTopChats always runs a /sync (unlike getActiveChats, which can
|
|
// short-circuit on a fresh cache).
|
|
await svc.refreshTopChats();
|
|
const chats = await svc.getCachedConversations();
|
|
const alice = chats.find((c) => c.roomId === '!room1:example.test');
|
|
expect(alice).toBeDefined();
|
|
expect(alice!.messages).toBeDefined();
|
|
// Original message + the new image-bearing message.
|
|
expect(alice!.messages!.length).toBe(2);
|
|
const imageMsg = alice!.messages!.find((m) => m.timestamp === imageTs);
|
|
expect(imageMsg).toBeDefined();
|
|
expect(imageMsg!.media).toBeDefined();
|
|
expect(imageMsg!.media!.length).toBe(1);
|
|
} finally {
|
|
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('is a no-op when no MediaService is configured', async () => {
|
|
const logger = createMockLogger();
|
|
const matrix = new MatrixClient(creds, logger, { minRequestIntervalMs: 0 });
|
|
matrix.sync = vi.fn(async () => ({
|
|
next_batch: 's2',
|
|
rooms: {
|
|
join: {
|
|
'!room1:example.test': {
|
|
timeline: {
|
|
events: [
|
|
{
|
|
type: 'm.room.message',
|
|
sender: '@t2_abc:example.test',
|
|
origin_server_ts: 1000,
|
|
content: { url: 'mxc://example.test/img1' },
|
|
},
|
|
],
|
|
},
|
|
},
|
|
},
|
|
},
|
|
})) as typeof matrix.sync;
|
|
|
|
const cache = new ChatCache(
|
|
logger,
|
|
path.join(os.tmpdir(), `chat-cache-${Date.now()}-${Math.random()}.json`),
|
|
);
|
|
const svc = new ChatService(matrix, logger, undefined, undefined, undefined, undefined, cache, undefined, undefined, authenticatedAccountAuth);
|
|
await expect(svc.getActiveChats()).resolves.toBeDefined();
|
|
});
|
|
});
|