import fs from 'fs/promises'; import os from 'os'; import path from 'path'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { ConversationOutcomesRepository, ConversationStateRepository } from '../src/database/repositories.js'; import { ChatCache } from '../src/services/chat/cache.js'; import { MatrixClient } from '../src/services/chat/matrix/client.js'; import type { MatrixSyncResponse } from '../src/services/chat/matrix/types.js'; import { ChatService } from '../src/services/chat/service.js'; import { createMockLogger } from './mockLogger.js'; import { authenticatedAccountAuth, createTestDb, createTestRepositories } from './testUtils.js'; const credentials = { homeserver: 'https://matrix.example.test', accessToken: 'token', userId: '@me:example.test', capturedAt: Date.now(), }; const temporaryDirectories: string[] = []; const openDatabases: Array> = []; afterEach(async () => { for (const database of openDatabases.splice(0)) database.close(); await Promise.all(temporaryDirectories.splice(0).map((directory) => fs.rm(directory, { recursive: true, force: true }))); }); describe('ChatService inbound media notifications', () => { it('reports an inbound image exactly once, however often the chat re-syncs', async () => { const fixture = await createFixture({ inboundMediaCount: 2 }); const received: unknown[] = []; fixture.service.setMediaReceivedListener((info) => received.push(info)); await fixture.service.runStartupSync(); expect(received).toEqual([{ conversationId: fixture.roomId, username: 'alice', count: 2 }]); expect(fixture.repos.messagesRepo.getByChatRoomId(fixture.roomId)).toMatchObject({ gotImage: true }); await fixture.service.runStartupSync(); expect(received).toHaveLength(1); }); it('stays quiet for a text-only conversation', async () => { const fixture = await createFixture({ inboundMediaCount: 0 }); const received: unknown[] = []; fixture.service.setMediaReceivedListener((info) => received.push(info)); await fixture.service.runStartupSync(); expect(received).toEqual([]); }); it('records the image even when the listener throws', async () => { const fixture = await createFixture({ inboundMediaCount: 1 }); fixture.service.setMediaReceivedListener(() => { throw new Error('notifier exploded'); }); await fixture.service.runStartupSync(); expect(fixture.repos.messagesRepo.getByChatRoomId(fixture.roomId)).toMatchObject({ gotImage: true }); }); }); async function createFixture(options: { inboundMediaCount: number }) { const logger = createMockLogger(); const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'chat-media-notify-')); temporaryDirectories.push(directory); const cacheFile = path.join(directory, 'chat-cache.json'); const db = createTestDb(); openDatabases.push(db); const repos = createTestRepositories(db); const outcomes = new ConversationOutcomesRepository(db); const conversationStates = new ConversationStateRepository(db); const now = Date.now(); const roomId = '!alice:example.test'; repos.postsRepo.insert({ id: 'source-post', subreddit: 'example', author: 'alice', title: 'Source', imageUrl: null, processedAt: now - 10_000, }); repos.messagesRepo.insert({ postId: 'source-post', recipient: 'alice', subject: 'alice', message: 'Hello', sentAt: now - 9_000, strategyId: 'direct', chatRoomId: roomId, }); const media = Array.from({ length: options.inboundMediaCount }, (_unused, index) => ({ originalUrl: `mxc://example.test/image-${index}`, localPath: `/data/media/image-${index}.jpg`, fileName: `image-${index}.jpg`, })); await fs.writeFile(cacheFile, JSON.stringify({ version: 1, savedAt: now - 60_000, chats: [{ username: 'alice', roomName: 'alice', lastMessageTime: now - 2_000, lastMessageSnippet: '[Image]', isFromUs: false, unread: false, roomId, messages: [ { sender: 'Me', text: 'Hello', timestamp: now - 5_000, isSelf: true }, { sender: 'alice', text: media.length > 0 ? '[Image]' : 'Thanks!', timestamp: now - 2_000, isSelf: false, ...(media.length > 0 ? { media } : {}), }, ], }], nextBatch: 's1', lastFullSyncAt: now - 120_000, lastDeltaSyncAt: now - 60_000, cooldown: null, }), 'utf8'); const matrix = new MatrixClient(credentials, logger, { minRequestIntervalMs: 0 }); matrix.sync = vi.fn(async (): Promise => ({ next_batch: 's2' })) as typeof matrix.sync; const cache = new ChatCache(logger, cacheFile); const service = new ChatService( matrix, logger, repos.successfulMessagesRepo, undefined, undefined, undefined, cache, repos.messagesRepo, undefined, authenticatedAccountAuth, outcomes, conversationStates, ); await service.loadCache(); return { repos, roomId, service }; }