Files
RedditBot/tests/web/media-route.test.ts
T
Jamie BruntonandClaude Opus 5 ac12a3146a feat: recover missing conversations, fix media attachment, add manual inbox tab
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>
2026-08-10 11:03:33 +01:00

203 lines
8.4 KiB
TypeScript

import express from 'express';
import type { AddressInfo } from 'node:net';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { MediaResponseSchema } from '../../packages/api-contracts/src/media.js';
import type { WebContext } from '../../src/web/context.js';
import { createMediaRouter } from '../../src/web/routes/media.js';
const servers: Array<ReturnType<express.Express['listen']>> = [];
async function request(app: express.Express, path: string, init?: RequestInit): Promise<Response> {
const server = app.listen(0);
servers.push(server);
await new Promise<void>((resolve) => server.once('listening', resolve));
const { port } = server.address() as AddressInfo;
return fetch(`http://127.0.0.1:${port}${path}`, init);
}
afterEach(async () => {
await Promise.all(servers.splice(0).map((server) => new Promise<void>((resolve) => server.close(() => resolve()))));
});
describe('media route username presentation', () => {
it('lists persisted received media for accounts whose runtimes are stopped', async () => {
const storedItem = (accountId: string, mediaId: string, sender: string) => ({
accountId, mediaId, chatUsername: 'alice', chatRoomId: `room-${accountId}`, sender,
timestamp: 1, originalUrl: `mxc://${mediaId}`, fileName: `${mediaId}.jpg`, relativePath: `${mediaId}.jpg`, createdAt: 2,
});
const accounts = [
{ accountId: 't2_running', redditUsername: 'running-owner' },
{ accountId: 't2_stopped', redditUsername: 'stopped-owner' },
];
const listMedia = vi.fn((accountId: string) => accountId === 't2_running'
? [storedItem(accountId, 'running-received', 'alice')]
: [
storedItem(accountId, 'stopped-received', 'bob'),
storedItem(accountId, 'stopped-sent', '@t2_stopped:reddit.com'),
]);
const context = {
logger: { error: vi.fn() },
repos: { accountContent: { listMedia } },
services: {
media: { getMediaItems: vi.fn().mockResolvedValue([]) },
chat: {
getCachedConversations: vi.fn().mockResolvedValue([]),
resolveSenderDisplay: vi.fn((identity: string) => identity),
},
redditAccounts: {
list: vi.fn().mockReturnValue(accounts),
get: vi.fn((accountId: string) => accounts.find((account) => account.accountId === accountId) ?? null),
},
accountRuntimes: {
list: vi.fn().mockReturnValue([{ accountId: 't2_running', state: 'running' }]),
get: vi.fn().mockReturnValue({ resources: {
mediaService: { getMediaItems: vi.fn().mockResolvedValue([]) },
chatService: { getCachedConversations: vi.fn().mockResolvedValue([]) },
} }),
},
},
} as unknown as WebContext;
const app = express().use('/api', createMediaRouter(context));
const response = await request(app, '/api/media');
const body = await response.json();
expect(listMedia).toHaveBeenCalledTimes(2);
expect(body).toEqual([
expect.objectContaining({ id: 'running-received', owner: accounts[0] }),
expect.objectContaining({ id: 'stopped-received', owner: accounts[1] }),
]);
});
it('lists received media while excluding items sent by the owning account', async () => {
const mediaItem = (id: string, sender: string) => ({
accountId: 't2_owner', id, chatUsername: 'alice', chatRoomId: 'room-one', sender,
timestamp: 1, originalUrl: `mxc://${id}`, fileName: `${id}.jpg`, relativePath: `${id}.jpg`, createdAt: 2,
});
const context = {
logger: { error: vi.fn() },
services: {
media: { getMediaItems: vi.fn().mockResolvedValue([
mediaItem('received', 'alice'),
mediaItem('sent-marker', 'Me'),
mediaItem('sent-matrix-id', '@t2_owner:reddit.com'),
mediaItem('sent-username', 'owner'),
]) },
chat: {
getCachedConversations: vi.fn().mockResolvedValue([]),
resolveSenderDisplay: vi.fn((identity: string) => identity),
},
redditAccounts: {
list: vi.fn().mockReturnValue([]),
get: vi.fn().mockReturnValue({ accountId: 't2_owner', redditUsername: 'owner' }),
},
},
} as unknown as WebContext;
const app = express().use('/api', createMediaRouter(context));
const response = await request(app, '/api/media');
expect(response.status).toBe(200);
expect(await response.json()).toEqual([
expect.objectContaining({ id: 'received', sender: 'alice' }),
]);
});
it('upgrades historical t2 fields through the chat username map', async () => {
const context = {
logger: { error: vi.fn() },
services: {
media: { getMediaItems: vi.fn().mockResolvedValue([{
id: 'one', chatUsername: 't2_known', chatRoomId: 'room-one', sender: 't2_known',
timestamp: 1, originalUrl: 'mxc://one', fileName: 'one.jpg', relativePath: 'one.jpg', createdAt: 2,
}]) },
chat: {
getCachedConversations: vi.fn().mockResolvedValue([]),
resolveSenderDisplay: vi.fn((identity: string) => identity === 't2_known' ? 'alice' : identity),
},
},
} as unknown as WebContext;
const app = express().use('/api', createMediaRouter(context));
const response = await request(app, '/api/media');
expect(response.status).toBe(200);
expect(await response.json()).toEqual([expect.objectContaining({ chatUsername: 'alice', sender: 'alice' })]);
});
it('uses a safe fallback when no username mapping exists', async () => {
const context = {
logger: { error: vi.fn() },
services: {
media: { getMediaItems: vi.fn().mockResolvedValue([{
id: 'one', chatUsername: 't2_missing', chatRoomId: 'room-one', sender: '@t2_missing:reddit.com',
timestamp: 1, originalUrl: 'mxc://one', fileName: 'one.jpg', relativePath: 'one.jpg', createdAt: 2,
}]) },
chat: {
getCachedConversations: vi.fn().mockResolvedValue([]),
resolveSenderDisplay: vi.fn((identity: string) => identity),
},
},
} as unknown as WebContext;
const app = express().use('/api', createMediaRouter(context));
const response = await request(app, '/api/media');
const body = await response.json();
expect(JSON.stringify(body)).not.toContain('t2_missing');
expect(body[0]).toMatchObject({ chatUsername: null, sender: 'Unknown Reddit user' });
});
it('keeps the internal account ID out of the public media item', async () => {
const context = {
logger: { error: vi.fn() },
services: {
media: { getMediaItems: vi.fn().mockResolvedValue([{
accountId: 't2_owner', id: 'one', chatUsername: 'alice', chatRoomId: 'room-one', sender: 'alice',
timestamp: 1, originalUrl: 'mxc://one', fileName: 'one.jpg', relativePath: 'one.jpg', createdAt: 2,
}]) },
chat: {
getCachedConversations: vi.fn().mockResolvedValue([]),
resolveSenderDisplay: vi.fn((identity: string) => identity),
},
redditAccounts: {
list: vi.fn().mockReturnValue([]),
get: vi.fn().mockReturnValue({ accountId: 't2_owner', redditUsername: 'owner' }),
},
},
} as unknown as WebContext;
const app = express().use('/api', createMediaRouter(context));
const response = await request(app, '/api/media');
const body: unknown = await response.json();
expect(MediaResponseSchema.parse(body)).toEqual([
expect.objectContaining({
id: 'one',
owner: { accountId: 't2_owner', redditUsername: 'owner' },
}),
]);
expect(body).not.toEqual([expect.objectContaining({ accountId: expect.anything() })]);
});
it('returns the recorded failure instead of claiming a manual full sync succeeded', async () => {
const context = {
logger: { error: vi.fn() },
broadcaster: { push: vi.fn() },
services: {
media: { getMediaItems: vi.fn().mockResolvedValue([]) },
mediaSync: {
triggerSync: vi.fn().mockResolvedValue(false),
getStatus: vi.fn().mockReturnValue({ lastError: '1 conversation failed' }),
},
},
} as unknown as WebContext;
const app = express().use('/api', createMediaRouter(context));
const response = await request(app, '/api/media/sync/full', { method: 'POST' });
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ success: false, reason: '1 conversation failed' });
});
});