Missing conversations: recipient Reddit accounts that are deleted or suspended left conversations unaccounted for. A new RecipientAccountStatusService polls Reddit for the users behind cached conversations and records their account status durably, with progressive backfill and delta-media fixes in ChatService so history and images are no longer dropped mid-sync. Media: attachMedia now surfaces download failures as MediaAttachmentError so the sync cursor can be retried instead of silently losing an image, recognizes image bytes when Matrix returns a generic content type, and keeps separate gallery occurrences when the same asset appears in more than one event. Manual inbox tab: the conversations list now carries the reply-loop status, so the inbox can separate conversations the operator is driving by hand from the ones automation is still handling. A Manual filter pill and a per-row chip explain why each conversation was handed over. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
249 lines
9.5 KiB
TypeScript
249 lines
9.5 KiB
TypeScript
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<typeof createMockLogger>;
|
|
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<ChatMessage & { mediaUrls?: string[] }> = [
|
|
{ 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<ChatMessage & { mediaUrls: string[] }> = [
|
|
{ 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<ChatMessage & { mediaUrls: string[] }> = [
|
|
{ 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<ChatMessage & { mediaUrls: string[] }> = [
|
|
{ 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<ChatMessage & { mediaUrls: string[] }> = [
|
|
{ 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<ChatMessage & { mediaUrls: string[] }> = [
|
|
{ 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<ChatMessage & { mediaUrls: string[] }> = [
|
|
{ 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<ChatMessage & { mediaUrls: string[] }> = [
|
|
{ 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<ChatMessage & { mediaUrls: string[] }> = [
|
|
{ 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<ChatMessage & { mediaUrls: string[] }> = [
|
|
{ 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);
|
|
});
|
|
});
|
|
});
|