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:
1 parent
725b5fc4e6
commit
ac12a3146a
35 files changed
+1164
-129
No files matched your search
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
]));
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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[] }> = [
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 } };
|
||||
|
||||
@@ -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],
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user