import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import fs from 'fs/promises'; import os from 'os'; import path from 'path'; import { MediaAttachmentError, MediaService } from '../src/services/media.js'; import type { MediaDownloader } from '../src/services/media.js'; import type { ActiveChat, ChatMessage } from '../src/services/chat.js'; import { createMockLogger } from '../tests/mockLogger.js'; function createMockDownloader(download: Buffer | null = Buffer.from('image data')): MediaDownloader { return { fetch: vi.fn(async (_url: string) => { if (!download) return null; return { buffer: download, contentType: 'image/jpeg' }; }), }; } function createMockChat(username = 'user1', roomId = 'room1'): ActiveChat { return { username, roomName: username, lastMessageTime: 0, lastMessageSnippet: '', isFromUs: false, unread: false, roomId, url: 'https://www.reddit.com/message/chat/abc', }; } describe('MediaService', () => { let service: MediaService; let logger: ReturnType; let downloader: MediaDownloader; let tempDir: string; beforeEach(async () => { logger = createMockLogger(); downloader = createMockDownloader(); tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'media-test-')); service = new MediaService(downloader, logger, tempDir); }); afterEach(async () => { await fs.rm(tempDir, { recursive: true, force: true }); }); describe('resolveFullMediaUrl', () => { it('falls back to the blurred thumbnail when the full-resolution response is not an image', async () => { const blurred = 'https://matrix.redditspace.com/_matrix/media/v3/thumbnail/reddit.com/blur_XXXXX?height=300'; const fetch = vi.fn() .mockResolvedValueOnce({ buffer: Buffer.from('not an image'), contentType: 'text/html' }) .mockResolvedValueOnce({ buffer: Buffer.from('image data'), contentType: 'image/jpeg' }); service = new MediaService({ fetch }, logger, tempDir); const [message] = await service.attachMedia(createMockChat(), [{ eventId: '$blurred', sender: 'user1', text: '', timestamp: 1, isSelf: false, mediaUrls: [blurred], }]); expect(fetch).toHaveBeenNthCalledWith( 1, 'https://matrix.redditspace.com/_matrix/media/v3/download/reddit.com/XXXXX', ); expect(fetch).toHaveBeenNthCalledWith(2, blurred); expect(message.media).toHaveLength(1); }); it('leaves non-thumbnail URLs unchanged', () => { // Same as above - test via public API expect(service).toBeDefined(); }); it('handles invalid URLs gracefully', () => { // Same as above expect(service).toBeDefined(); }); }); describe('attachMedia', () => { it('returns messages without media when no URLs', async () => { const chat = createMockChat(); const messages: Array = [ { sender: 'user1', text: 'Hello', timestamp: Date.now(), isSelf: false }, ]; const result = await service.attachMedia(chat, messages); expect(result.length).toBe(1); expect(result[0].media).toBeUndefined(); }); it('downloads media from URLs', async () => { const chat = createMockChat(); const messages: Array = [ { sender: 'user1', text: 'Check this out', timestamp: Date.now(), isSelf: false, mediaUrls: ['http://example.com/img.jpg'] }, ]; const result = await service.attachMedia(chat, messages); expect(result.length).toBe(1); expect(result[0].media).toBeDefined(); expect(result[0].media!.length).toBe(1); }); it('skips data: URLs', async () => { const chat = createMockChat(); const messages: Array = [ { sender: 'user1', text: 'Test', timestamp: Date.now(), isSelf: false, mediaUrls: ['data:image/jpeg;base64,abc'] }, ]; const result = await service.attachMedia(chat, messages); expect(result[0].media).toBeUndefined(); }); it('skips null/empty URLs', async () => { const chat = createMockChat(); const messages: Array = [ { sender: 'user1', text: 'Test', timestamp: Date.now(), isSelf: false, mediaUrls: ['', null as unknown as string] }, ]; const result = await service.attachMedia(chat, messages); expect(result[0].media).toBeUndefined(); }); it('surfaces download failures so the sync cursor can be retried', async () => { const failingDownloader = createMockDownloader(null); service = new MediaService(failingDownloader, logger); const chat = createMockChat(); const messages: Array = [ { sender: 'user1', text: 'Test', timestamp: Date.now(), isSelf: false, mediaUrls: ['http://example.com/img.jpg'] }, ]; await expect(service.attachMedia(chat, messages)).rejects.toBeInstanceOf(MediaAttachmentError); }); it('caches downloaded media by URL hash', async () => { const chat = createMockChat(); const url = 'http://example.com/img.jpg'; const messages: Array = [ { sender: 'user1', text: 'Test', timestamp: Date.now(), isSelf: false, mediaUrls: [url] }, ]; // First download await service.attachMedia(chat, messages); // Reset downloader mock vi.mocked(downloader.fetch).mockClear(); // Second download of same URL should use cache const messages2: Array = [ { sender: 'user1', text: 'Test 2', timestamp: Date.now(), isSelf: false, mediaUrls: [url] }, ]; await service.attachMedia(chat, messages2); // Second download of the same URL hits the index + on-disk cache, so the // downloader is not called again. expect(downloader.fetch).not.toHaveBeenCalled(); }); it('keeps separate gallery occurrences when the same asset appears in separate events', async () => { const chat = createMockChat(); const url = 'http://example.com/reused.jpg'; await service.attachMedia(chat, [ { eventId: '$one', sender: 'user1', text: 'First', timestamp: 1, isSelf: false, mediaUrls: [url] }, { eventId: '$two', sender: 'user1', text: 'Second', timestamp: 2, isSelf: false, mediaUrls: [url] }, ]); const items = await service.getMediaItems(); expect(items).toHaveLength(2); expect(new Set(items.map((item) => item.id)).size).toBe(2); expect(new Set(items.map((item) => item.relativePath)).size).toBe(1); expect(downloader.fetch).toHaveBeenCalledTimes(1); }); it('recognizes image bytes when Matrix returns a generic content type', async () => { const png = Buffer.from( 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64', ); service = new MediaService({ fetch: vi.fn(async () => ({ buffer: png, contentType: 'application/octet-stream' })), }, logger, tempDir); const [message] = await service.attachMedia(createMockChat(), [{ eventId: '$generic-mime', sender: 'user1', text: '', timestamp: 1, isSelf: false, mediaUrls: ['mxc://example.test/no-extension'], }]); expect(message.media?.[0].fileName).toMatch(/\.png$/); }); it('processes multiple messages', async () => { const chat = createMockChat(); const messages: Array = [ { sender: 'user1', text: 'Msg 1', timestamp: Date.now(), isSelf: false, mediaUrls: ['http://example.com/1.jpg'] }, { sender: 'user1', text: 'Msg 2', timestamp: Date.now(), isSelf: false, mediaUrls: [] }, { sender: 'user1', text: 'Msg 3', timestamp: Date.now(), isSelf: false, mediaUrls: ['http://example.com/3.jpg'] }, ]; const result = await service.attachMedia(chat, messages); expect(result.length).toBe(3); expect(result[0].media).toBeDefined(); expect(result[1].media).toBeUndefined(); expect(result[2].media).toBeDefined(); }); }); describe('getMediaItems', () => { it('returns empty array when no index', async () => { const items = await service.getMediaItems(); expect(items).toEqual([]); }); }); describe('edge cases', () => { it('handles empty message array', async () => { const chat = createMockChat(); const result = await service.attachMedia(chat, []); expect(result).toEqual([]); }); it('handles chat with no username', async () => { const chat = createMockChat('', ''); const messages: Array = [ { sender: 'anon', text: 'Test', timestamp: Date.now(), isSelf: false, mediaUrls: ['http://example.com/img.jpg'] }, ]; const result = await service.attachMedia(chat, messages); expect(result.length).toBe(1); }); it('handles messages with isSelf flag', async () => { const chat = createMockChat(); const messages: Array = [ { sender: 'user1', text: 'Their message', timestamp: Date.now(), isSelf: false, mediaUrls: ['http://example.com/img.jpg'] }, { sender: 'me', text: 'My message', timestamp: Date.now(), isSelf: true, mediaUrls: ['http://example.com/img2.jpg'] }, ]; const result = await service.attachMedia(chat, messages); expect(result.length).toBe(2); expect(result[0].isSelf).toBe(false); expect(result[1].isSelf).toBe(true); }); }); });