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>
This commit is contained in:
Jamie BruntonandClaude Opus 5 committed 2026-08-10 11:03:33 +01:00
1 parent 725b5fc4e6
commit ac12a3146a
35 files changed
+1164 -129

No files matched your search

+25
View File
@@ -6,6 +6,7 @@ import path from 'node:path';
import {
AccountContentRepository,
RedditAccountsRepository,
RedditRecipientAccountStatusRepository,
} from '../src/database/account-repositories.js';
import { LEGACY_UNASSIGNED_ACCOUNT_ID, runMigrations } from '../src/database/client.js';
import { importLegacyAccountContent } from '../src/database/legacy-content-import.js';
@@ -54,6 +55,15 @@ describe('multi-account database ownership', () => {
});
});
it('stores recipient account state case-insensitively and allows later recovery', () => {
const statuses = new RedditRecipientAccountStatusRepository(db);
statuses.upsert('Alice', 'deleted', 100);
expect(statuses.get('alice')).toEqual({ username: 'Alice', status: 'deleted', checkedAt: 100 });
statuses.upsert('ALICE', 'active', 200);
expect(statuses.get('alice')).toEqual({ username: 'Alice', status: 'active', checkedAt: 200 });
});
it('updates matrix credentials in place without touching cookies or status', () => {
addAccount('t2_alice', 'alice-bot', 100);
@@ -196,4 +206,19 @@ describe('multi-account database ownership', () => {
fs.rmSync(dataDirectory, { recursive: true, force: true });
}
});
it('stores separate media occurrences that share one downloaded file', () => {
addAccount('t2_alice', 'alice-bot', 100);
const base = {
accountId: 't2_alice', chatUsername: 'sender', chatRoomId: 'room-one', sender: 'sender',
originalUrl: 'mxc://reddit.com/shared', fileName: 'shared.jpg', relativePath: 'shared.jpg',
createdAt: 10,
};
content.upsertMedia({ ...base, mediaId: 'event-one', timestamp: 1 });
content.upsertMedia({ ...base, mediaId: 'event-two', timestamp: 2 });
expect(content.listMedia('t2_alice')).toHaveLength(2);
expect(content.getMediaByOriginalUrl('t2_alice', base.originalUrl)?.mediaId).toBe('event-one');
});
});
+1 -1
View File
@@ -75,7 +75,7 @@ describe('ChatService account authentication gate', () => {
await vi.waitFor(() => expect(matrix.sync).toHaveBeenCalledOnce());
finishSync({ next_batch: 's1' });
await expect(Promise.all([first, second])).resolves.toEqual([undefined, undefined]);
await expect(Promise.all([first, second])).resolves.toEqual([0, 0]);
expect(matrix.sync).toHaveBeenCalledOnce();
});
+77
View File
@@ -198,4 +198,81 @@ describe('ChatService delta sync — media attachment', () => {
const svc = new ChatService(matrix, logger, undefined, undefined, undefined, undefined, cache, undefined, undefined, authenticatedAccountAuth);
await expect(svc.getActiveChats()).resolves.toBeDefined();
});
it('backfills the room when Matrix marks a delta timeline as limited', async () => {
const logger = createMockLogger();
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'chat-limited-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: {
limited: true,
prev_batch: 'gap-token',
events: [],
},
} } },
})) as typeof matrix.sync;
matrix.getRoomMessages = vi.fn(async () => ({
start: 'latest',
chunk: [{
event_id: '$missed-image', type: 'm.room.message', sender: '@t2_abc:example.test',
origin_server_ts: 1_000,
content: { msgtype: 'm.image', info: { thumbnail_url: 'mxc://example.test/missed' } },
}],
}));
const media = new MediaService(buildDownloader(), logger, tmpDir);
const svc = new ChatService(
matrix, logger, undefined, media, undefined, undefined,
new ChatCache(logger, path.join(tmpDir, 'chat-cache.json')),
undefined, undefined, authenticatedAccountAuth,
);
await expect(svc.refreshTopChats()).resolves.toBe(1);
expect(matrix.getRoomMessages).toHaveBeenCalledWith(
'!room1:example.test', { from: undefined, limit: 100, dir: 'b' },
);
expect(await media.getMediaItems()).toEqual([
expect.objectContaining({ originalUrl: 'mxc://example.test/missed' }),
]);
} finally {
await fs.rm(tmpDir, { recursive: true, force: true });
}
});
it('does not persist the next delta cursor when a media download fails', async () => {
const logger = createMockLogger();
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'chat-media-retry-cursor-'));
const cacheFile = path.join(tmpDir, 'chat-cache.json');
try {
await fs.writeFile(cacheFile, JSON.stringify({
version: 1, savedAt: 1, chats: [], nextBatch: 's1',
lastFullSyncAt: 1, lastDeltaSyncAt: 1, cooldown: null,
}));
const matrix = new MatrixClient(creds, logger, { minRequestIntervalMs: 0 });
matrix.sync = vi.fn(async () => ({
next_batch: 's2',
rooms: { join: { '!room1:example.test': { timeline: { events: [{
event_id: '$image', type: 'm.room.message', sender: '@t2_abc:example.test',
origin_server_ts: 1_000, content: { msgtype: 'm.image', url: 'mxc://example.test/fails' },
}] } } } },
})) as typeof matrix.sync;
const media = new MediaService({ fetch: vi.fn().mockResolvedValue(null) }, 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();
await expect(svc.refreshTopChats()).rejects.toThrow('Failed to retrieve');
const persisted = JSON.parse(await fs.readFile(cacheFile, 'utf-8')) as { nextBatch: string };
expect(persisted.nextBatch).toBe('s1');
} finally {
await fs.rm(tmpDir, { recursive: true, force: true });
}
});
});
@@ -191,4 +191,62 @@ describe('ChatService progressive full backfill', () => {
await fs.rm(tmpDir, { recursive: true, force: true });
}
});
it('rejects a required-complete backfill and leaves the full-sync marker unset when a room fails', async () => {
const logger = createMockLogger();
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'chat-incomplete-backfill-'));
try {
const matrix = new MatrixClient(credentials, logger, { minRequestIntervalMs: 0 });
matrix.sync = vi.fn(async () => ({ next_batch: 's1', rooms: { join: {} } })) as typeof matrix.sync;
matrix.getJoinedRooms = vi.fn(async () => ['!alice:reddit.com']);
matrix.getRoomMessages = vi.fn().mockRejectedValue(new Error('temporary history failure'));
const cache = new ChatCache(logger, path.join(tmpDir, 'chat-cache.json'));
const service = new ChatService(
matrix, logger, undefined, undefined, undefined, undefined, cache,
undefined, undefined, authenticatedAccountAuth,
);
await expect(service.fetchAllMessages({ requireComplete: true }))
.rejects.toThrow('1 of 1 conversations failed');
expect(cache.getMarkers().lastFullSyncAt).toBeNull();
} finally {
await fs.rm(tmpDir, { recursive: true, force: true });
}
});
it('paginates beyond the former 50-page history ceiling', async () => {
const logger = createMockLogger();
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'chat-long-backfill-'));
try {
const matrix = new MatrixClient(credentials, logger, { minRequestIntervalMs: 0 });
matrix.sync = vi.fn(async () => ({ next_batch: 's1', rooms: { join: {} } })) as typeof matrix.sync;
matrix.getJoinedRooms = vi.fn(async () => ['!long:reddit.com']);
let page = 0;
matrix.getRoomMessages = vi.fn(async () => {
page++;
return {
start: `start-${page}`,
end: page < 51 ? `page-${page}` : undefined,
chunk: [{
event_id: `$event-${page}`,
type: 'm.room.message',
sender: '@t2_long:reddit.com',
origin_server_ts: page,
content: { msgtype: 'm.text', body: `message ${page}` },
}],
};
});
const service = new ChatService(
matrix, logger, undefined, undefined, undefined, undefined,
new ChatCache(logger, path.join(tmpDir, 'chat-cache.json')),
undefined, undefined, authenticatedAccountAuth,
);
const [chat] = await service.fetchAllMessages({ requireComplete: true });
expect(matrix.getRoomMessages).toHaveBeenCalledTimes(51);
expect(chat.messages).toHaveLength(51);
} finally {
await fs.rm(tmpDir, { recursive: true, force: true });
}
});
});
+54 -2
View File
@@ -6,8 +6,10 @@ import {
filterCounts,
isConversationFilter,
isConversationSort,
isManualFlow,
isUnread,
isWaitingOnThem,
manualFlowLabel,
matchesFilter,
matchesSearch,
needsReply,
@@ -63,6 +65,33 @@ describe('conversation triage', () => {
it('defaults a conversation with no stored state to open', () => {
expect(triageStatus(chat({ username: 'a' }))).toBe('open');
});
it('treats every handed-over reply-loop status as a manual conversation', () => {
for (const status of ['user-overridden', 'image-received', 'boundary', 'human-required'] as const) {
expect(isManualFlow(chat({ username: 'a', automationStatus: status }))).toBe(true);
}
});
it('treats a manual policy as manual even while the reply loop is still nominally active', () => {
expect(isManualFlow(chat({
username: 'a', automationStatus: 'active', automationPolicyMode: 'manual',
}))).toBe(true);
});
it('does not call an actively automated or never-automated conversation manual', () => {
expect(isManualFlow(chat({ username: 'a', automationStatus: 'active' }))).toBe(false);
expect(isManualFlow(chat({ username: 'b' }))).toBe(false);
});
it('explains why a conversation is being handled by hand', () => {
expect(manualFlowLabel(chat({ username: 'a', automationStatus: 'user-overridden' })))
.toBe('Manual — you replied');
expect(manualFlowLabel(chat({ username: 'b', automationStatus: 'image-received' })))
.toBe('Manual — image received');
expect(manualFlowLabel(chat({ username: 'c', automationStatus: 'active', automationPolicyMode: 'manual' })))
.toBe('Manual by policy');
expect(manualFlowLabel(chat({ username: 'd', automationStatus: 'active' }))).toBeNull();
});
});
describe('filters', () => {
@@ -71,6 +100,7 @@ describe('conversation triage', () => {
const snoozed = chat({ username: 'snoozed', triageStatus: 'snoozed' });
const done = chat({ username: 'done', triageStatus: 'done' });
const withImage = chat({ username: 'image', hasReceivedImages: true });
const deleted = chat({ username: 'deleted', recipientAccountStatus: 'deleted' });
it('excludes snoozed and resolved conversations from the default view', () => {
expect(matchesFilter(open, 'all')).toBe(true);
@@ -84,6 +114,13 @@ describe('conversation triage', () => {
expect(matchesFilter(done, 'done')).toBe(true);
});
it('moves deleted accounts out of actionable buckets into their own bucket', () => {
expect(matchesFilter(deleted, 'all')).toBe(false);
expect(matchesFilter(deleted, 'needs-reply')).toBe(false);
expect(matchesFilter(deleted, 'account-deleted')).toBe(true);
expect(needsReply(deleted)).toBe(false);
});
it('scopes unread and image buckets to open conversations', () => {
expect(matchesFilter(open, 'unread')).toBe(true);
expect(matchesFilter(chat({ username: 'x', hasUnreadInbound: true, triageStatus: 'done' }), 'unread')).toBe(false);
@@ -91,6 +128,17 @@ describe('conversation triage', () => {
expect(matchesFilter(chat({ username: 'y', hasReceivedImages: true, triageStatus: 'done' }), 'received-images')).toBe(false);
});
it('collects conversations the operator is driving into the manual bucket', () => {
const manual = chat({ username: 'manual', automationStatus: 'user-overridden' });
expect(matchesFilter(manual, 'manual')).toBe(true);
expect(matchesFilter(open, 'manual')).toBe(false);
// Resolved work leaves the manual queue the same way it leaves the others.
expect(matchesFilter(
chat({ username: 'z', automationStatus: 'user-overridden', triageStatus: 'done' }),
'manual',
)).toBe(false);
});
it('matches search against username, snippet and loaded message text', () => {
const conversation = chat({
username: 'alice',
@@ -176,14 +224,18 @@ describe('conversation triage', () => {
chat({ username: 'replied', isFromUs: true }),
chat({ username: 'snoozed', triageStatus: 'snoozed' }),
chat({ username: 'done', triageStatus: 'done' }),
chat({ username: 'manual', isFromUs: true, automationStatus: 'user-overridden' }),
chat({ username: 'deleted', recipientAccountStatus: 'deleted' }),
], filters());
expect(counts.all).toBe(3);
expect(counts.all).toBe(4);
expect(counts.unread).toBe(1);
expect(counts['needs-reply']).toBe(2);
expect(counts['awaiting-reply']).toBe(1);
expect(counts['awaiting-reply']).toBe(2);
expect(counts.manual).toBe(1);
expect(counts.snoozed).toBe(1);
expect(counts.done).toBe(1);
expect(counts['account-deleted']).toBe(1);
});
it('respects the search term so a pill never promises rows the list will not show', () => {
+30
View File
@@ -20,6 +20,8 @@ interface ChatFixtureOptions {
lastMessageSnippet?: string;
hasReceivedImages?: boolean;
sellerDetected?: boolean;
automationStatus?: 'active' | 'image-received' | 'human-required' | 'boundary' | 'user-overridden';
automationHandoffReason?: string;
}
function chatFixture(options: ChatFixtureOptions) {
@@ -34,6 +36,10 @@ function chatFixture(options: ChatFixtureOptions) {
unread: options.unread ?? false,
...(options.hasReceivedImages === undefined ? {} : { hasReceivedImages: options.hasReceivedImages }),
...(options.sellerDetected === undefined ? {} : { sellerDetected: options.sellerDetected }),
...(options.automationStatus === undefined ? {} : { automationStatus: options.automationStatus }),
...(options.automationHandoffReason === undefined
? {}
: { automationHandoffReason: options.automationHandoffReason }),
};
}
@@ -269,6 +275,30 @@ describe('ConversationsPage', () => {
expect(screen.getByRole('img', { name: 'Image received from u/alice' })).not.toHaveClass('is-blurred');
});
it('marks and filters conversations that are being replied to by hand', async () => {
const manual = chatFixture({
username: 'alice',
roomId: 'room-alice',
isFromUs: true,
automationStatus: 'user-overridden',
automationHandoffReason: 'A manual reply was sent in this conversation.',
});
const automated = chatFixture({ username: 'bob', roomId: 'room-bob', automationStatus: 'active' });
server.use(http.get('/api/conversations', () => HttpResponse.json([manual, automated])));
const user = userEvent.setup();
renderConversations();
await screen.findByRole('button', { name: /u\/alice/ });
expect(screen.getByText('Manual', { selector: '.conversation-chip' })).toHaveAttribute(
'title',
'Manual — you replied — A manual reply was sent in this conversation.',
);
await user.click(screen.getByRole('button', { name: 'Manual, 1' }));
expect(screen.getByRole('button', { name: /u\/alice/ })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /u\/bob/ })).not.toBeInTheDocument();
});
it('turns a suggestion into an editable draft and confirms sending without losing failed input', async () => {
installConversationList();
let sendRequests = 0;
+2 -1
View File
@@ -107,13 +107,14 @@ describe('Database Client', () => {
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")
.all() as { name: string }[];
// Existing domain/config tables plus account, conversation, sync and media ownership stores.
expect(tables.length).toBe(21);
expect(tables.length).toBe(22);
expect(tables.map((table) => table.name)).toEqual(expect.arrayContaining([
'candidate_queue',
'runtime_state',
'media_library_assets',
'media_library_sends',
'conversation_read_state',
'reddit_recipient_account_status',
]));
});
+51
View File
@@ -20,4 +20,55 @@ describe('MediaSyncService account authentication gate', () => {
expect(chat.refreshTopChats).not.toHaveBeenCalled();
expect(chat.fetchAllMessages).not.toHaveBeenCalled();
});
it('reports the actual number of rooms changed by an incremental sync', async () => {
const chat = {
refreshTopChats: vi.fn().mockResolvedValue(3),
fetchAllMessages: vi.fn(),
};
const service = new MediaSyncService(
chat as never,
createMockLogger(),
30 * 60 * 1000,
{ isFullyAuthenticated: () => true },
);
await expect(service.triggerSync()).resolves.toBe(true);
expect(service.getStatus().lastSyncChats).toBe(3);
});
it('requires a complete historical backfill for a full media sync', async () => {
const chat = {
refreshTopChats: vi.fn(),
fetchAllMessages: vi.fn().mockResolvedValue([]),
};
const service = new MediaSyncService(
chat as never,
createMockLogger(),
30 * 60 * 1000,
{ isFullyAuthenticated: () => true },
);
await service.triggerSync(true);
expect(chat.fetchAllMessages).toHaveBeenCalledWith({ requireComplete: true });
});
it('returns failure and preserves the operator-visible error when sync work is incomplete', async () => {
const chat = {
refreshTopChats: vi.fn().mockRejectedValue(new Error('media history incomplete')),
fetchAllMessages: vi.fn(),
};
const service = new MediaSyncService(
chat as never,
createMockLogger(),
30 * 60 * 1000,
{ isFullyAuthenticated: () => true },
);
await expect(service.triggerSync()).resolves.toBe(false);
expect(service.getStatus()).toMatchObject({
lastSyncAt: null,
lastError: 'media history incomplete',
});
});
});
+53 -8
View File
@@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import fs from 'fs/promises';
import os from 'os';
import path from 'path';
import { MediaService } from '../src/services/media.js';
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';
@@ -47,11 +47,24 @@ describe('MediaService', () => {
});
describe('resolveFullMediaUrl', () => {
it('converts blurred thumbnail to full resolution', () => {
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';
// We can't test private method directly, but we can test via attachMedia
// For now, verify the service is created
expect(service).toBeDefined();
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', () => {
@@ -109,7 +122,7 @@ describe('MediaService', () => {
expect(result[0].media).toBeUndefined();
});
it('handles download failures gracefully', async () => {
it('surfaces download failures so the sync cursor can be retried', async () => {
const failingDownloader = createMockDownloader(null);
service = new MediaService(failingDownloader, logger);
@@ -118,8 +131,7 @@ describe('MediaService', () => {
{ sender: 'user1', text: 'Test', timestamp: Date.now(), isSelf: false, mediaUrls: ['http://example.com/img.jpg'] },
];
const result = await service.attachMedia(chat, messages);
expect(result[0].media).toBeUndefined();
await expect(service.attachMedia(chat, messages)).rejects.toBeInstanceOf(MediaAttachmentError);
});
it('caches downloaded media by URL hash', async () => {
@@ -147,6 +159,39 @@ describe('MediaService', () => {
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[] }> = [
+62
View File
@@ -0,0 +1,62 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import Database from 'better-sqlite3';
import { RedditRecipientAccountStatusRepository } from '../src/database/account-repositories.js';
import { runMigrations } from '../src/database/client.js';
import { RecipientAccountStatusService } from '../src/services/recipient-account-status.js';
import { createMockLogger } from './mockLogger.js';
describe('RecipientAccountStatusService', () => {
let db: Database.Database;
beforeEach(() => {
db = new Database(':memory:');
runMigrations(db);
});
afterEach(() => db.close());
it('checks each real username once, persists results, and skips fresh checks', async () => {
let now = 10_000;
const repository = new RedditRecipientAccountStatusRepository(db);
const lookup = {
getUserAccountStatus: vi.fn(async (username: string) => username === 'Alice' ? 'deleted' as const : 'active' as const),
};
const service = new RecipientAccountStatusService(
{
getCachedConversations: vi.fn(async () => [
{ username: 'Alice' }, { username: 'alice' }, { username: 'Bob' },
{ username: 'unknown' }, { username: 't2_abc' },
]),
} as never,
lookup,
repository,
createMockLogger(),
{ isFullyAuthenticated: () => true, invalidate: () => undefined },
4_000,
() => now,
);
await expect(service.checkNow()).resolves.toBe(2);
expect(lookup.getUserAccountStatus).toHaveBeenCalledTimes(2);
expect(repository.get('alice')).toMatchObject({ status: 'deleted', checkedAt: 10_000 });
now = 12_000;
await expect(service.checkNow()).resolves.toBe(0);
now = 15_000;
await expect(service.checkNow()).resolves.toBe(2);
});
it('does no profile work while account authentication is unavailable', async () => {
const lookup = { getUserAccountStatus: vi.fn() };
const service = new RecipientAccountStatusService(
{ getCachedConversations: vi.fn() } as never,
lookup,
new RedditRecipientAccountStatusRepository(db),
createMockLogger(),
{ isFullyAuthenticated: () => false, invalidate: () => undefined },
);
await expect(service.checkNow()).resolves.toBe(0);
expect(lookup.getUserAccountStatus).not.toHaveBeenCalled();
});
});
+30
View File
@@ -540,6 +540,36 @@ describe('RedditApiClient', () => {
});
});
describe('getUserAccountStatus', () => {
it('distinguishes active, suspended and deleted accounts', async () => {
const mockAxios = { get: vi.fn() };
vi.mocked(axios.create).mockReturnValue(mockAxios as unknown as AxiosInstance);
const client2 = new RedditApiClient(getTestConfig(), logger);
mockAxios.get.mockResolvedValueOnce({ data: { data: {} } });
await expect(client2.getUserAccountStatus('active-user')).resolves.toBe('active');
mockAxios.get.mockResolvedValueOnce({ data: { data: { is_suspended: true } } });
await expect(client2.getUserAccountStatus('suspended-user')).resolves.toBe('suspended');
mockAxios.get.mockRejectedValueOnce({ isAxiosError: true, response: { status: 404 } });
await expect(client2.getUserAccountStatus('deleted-user')).resolves.toBe('deleted');
});
it('does not turn temporary or malformed responses into deletion signals', async () => {
const mockAxios = { get: vi.fn() };
vi.mocked(axios.create).mockReturnValue(mockAxios as unknown as AxiosInstance);
const client2 = new RedditApiClient(getTestConfig(), logger);
const temporary = { isAxiosError: true, response: { status: 503 } };
mockAxios.get.mockRejectedValueOnce(temporary);
await expect(client2.getUserAccountStatus('alice')).rejects.toBe(temporary);
mockAxios.get.mockResolvedValueOnce({ data: {} });
await expect(client2.getUserAccountStatus('alice')).rejects.toThrow();
});
});
describe('getNewPosts', () => {
it('throws a permanent-disable signal for a plain 404 subreddit', async () => {
const error = { isAxiosError: true, response: { status: 404 } };
+65 -4
View File
@@ -44,7 +44,14 @@ describe('conversations route', () => {
});
const runtime = (accountId: string, redditUsername: string, conversationUsername: string) => ({
session: { accountId, redditUsername },
resources: { chatService: { getCachedConversations: vi.fn().mockResolvedValue([chat(conversationUsername)]) } },
resources: {
chatService: { getCachedConversations: vi.fn().mockResolvedValue([chat(conversationUsername)]) },
recipientAccountStatusRepo: {
get: vi.fn((username: string) => username === 'alice'
? { username, status: 'deleted', checkedAt: 123 }
: null),
},
},
});
const runtimes = new Map([
['@t2_one:reddit.com', runtime('@t2_one:reddit.com', 'owner-one', 'alice')],
@@ -67,7 +74,11 @@ describe('conversations route', () => {
expect(body).toHaveLength(2);
expect(body).toEqual(expect.arrayContaining([
expect.objectContaining({ roomId: 'same-room', username: 'alice', owner: { accountId: '@t2_one:reddit.com', redditUsername: 'owner-one' } }),
expect.objectContaining({
roomId: 'same-room', username: 'alice', needsReply: false,
recipientAccountStatus: 'deleted', recipientAccountStatusCheckedAt: 123,
owner: { accountId: '@t2_one:reddit.com', redditUsername: 'owner-one' },
}),
expect.objectContaining({ roomId: 'same-room', username: 'bob', owner: { accountId: '@t2_two:reddit.com', redditUsername: 'owner-two' } }),
]));
});
@@ -319,7 +330,7 @@ describe('conversations route', () => {
services: {
chat: { getCachedConversations: vi.fn().mockResolvedValue([conversation]) },
conversationState: {
get: vi.fn(() => ({ sellerDetected: true })),
get: vi.fn(() => ({ ...buildConversationState([]), sellerDetected: true })),
},
},
} as unknown as WebContext;
@@ -329,7 +340,57 @@ describe('conversations route', () => {
expect(response.status).toBe(200);
expect(await response.json()).toEqual([
{ ...conversation, ...UNREAD_INBOUND_TRIAGE, sellerDetected: true },
{
...conversation,
...UNREAD_INBOUND_TRIAGE,
sellerDetected: true,
automationStatus: 'active',
automationPolicyMode: 'legacy',
automationHandoffReason: null,
},
]);
});
it('projects the reply-loop status onto the list so manual conversations can be filtered', async () => {
const automated = {
username: 'alice', roomName: 'alice', roomId: 'room-alice',
lastMessageTime: 1, lastMessageSnippet: 'hey', isFromUs: false, unread: true,
};
const manual = {
username: 'bob', roomName: 'bob', roomId: 'room-bob',
lastMessageTime: 2, lastMessageSnippet: 'thanks', isFromUs: true, unread: false,
};
const untouched = {
username: 'carol', roomName: 'carol', roomId: 'room-carol',
lastMessageTime: 3, lastMessageSnippet: 'hi', isFromUs: false, unread: true,
};
const handedOver = { ...buildConversationState([]) };
handedOver.automation.status = 'user-overridden';
handedOver.automation.handoffReason = 'A manual reply was sent in this conversation.';
const context = {
logger: { error: vi.fn() },
services: {
chat: { getCachedConversations: vi.fn().mockResolvedValue([automated, manual, untouched]) },
conversationState: {
get: vi.fn((roomId: string) => {
if (roomId === 'room-alice') return buildConversationState([]);
if (roomId === 'room-bob') return handedOver;
return undefined;
}),
},
},
} as unknown as WebContext;
const app = express().use('/api', createConversationsRouter(context));
const response = await request(app, '/api/conversations');
expect(response.status).toBe(200);
const body = await response.json() as Array<Record<string, unknown>>;
expect(body.map((item) => [item.username, item.automationStatus, item.automationHandoffReason])).toEqual([
['alice', 'active', null],
['bob', 'user-overridden', 'A manual reply was sent in this conversation.'],
// Never through the reply loop, so there is nothing to report.
['carol', undefined, undefined],
]);
});
+22 -2
View File
@@ -7,12 +7,12 @@ import { createMediaRouter } from '../../src/web/routes/media.js';
const servers: Array<ReturnType<express.Express['listen']>> = [];
async function request(app: express.Express, path: string): Promise<Response> {
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}`);
return fetch(`http://127.0.0.1:${port}${path}`, init);
}
afterEach(async () => {
@@ -179,4 +179,24 @@ describe('media route username presentation', () => {
]);
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' });
});
});