Files
RedditBot/tests/web/conversations-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

754 lines
30 KiB
TypeScript

import express from 'express';
import type { AddressInfo } from 'node:net';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { WebContext } from '../../src/web/context.js';
import { createConversationsRouter } from '../../src/web/routes/conversations.js';
import { buildConversationState, conversationMessageFingerprint } from '../../src/services/conversation-state.js';
const servers: Array<ReturnType<express.Express['listen']>> = [];
/**
* Triage fields the route projects onto every conversation when no operator
* read state has been recorded yet: an inbound newest message is unread and
* needs a reply, and the conversation is still open.
*/
const UNREAD_INBOUND_TRIAGE = {
hasUnreadInbound: true,
needsReply: true,
triageStatus: 'open',
snoozedUntil: null,
lastReadMessageTs: 0,
} as const;
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('conversations route', () => {
it('aggregates simultaneous account runtimes and labels identical room IDs with their owner', async () => {
const chat = (username: string) => ({
username, roomName: username, roomId: 'same-room', lastMessageTime: 1,
lastMessageSnippet: `from ${username}`, isFromUs: false, unread: false,
});
const runtime = (accountId: string, redditUsername: string, conversationUsername: string) => ({
session: { accountId, redditUsername },
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')],
['@t2_two:reddit.com', runtime('@t2_two:reddit.com', 'owner-two', 'bob')],
]);
const context = {
logger: { error: vi.fn() },
services: {
chat: { getCachedConversations: vi.fn() },
accountRuntimes: {
list: () => [...runtimes].map(([accountId, item]) => ({ accountId, redditUsername: item.session.redditUsername, state: 'running' })),
get: (accountId: string) => runtimes.get(accountId) ?? null,
},
},
} as unknown as WebContext;
const app = express().use('/api', createConversationsRouter(context));
const response = await request(app, '/api/conversations');
const body = await response.json();
expect(body).toHaveLength(2);
expect(body).toEqual(expect.arrayContaining([
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' } }),
]));
});
it('sends an owned conversation reply only through its account runtime', async () => {
const firstAccountId = '@t2_one:reddit.com';
const secondAccountId = '@t2_two:reddit.com';
const conversation = {
username: 'alice', roomName: 'alice', roomId: 'same-room', url: '/room/same-room',
lastMessageTime: 1, lastMessageSnippet: 'hello', isFromUs: false, unread: false,
messages: [{ sender: 'alice', text: 'hello', timestamp: 1, isSelf: false }],
};
const runtime = (accountId: string, redditUsername: string) => ({
session: { accountId, redditUsername },
snapshot: () => ({ state: 'running' }),
resources: {
chatService: {
getCachedConversations: vi.fn().mockResolvedValue([conversation]),
sendChatMessage: vi.fn().mockResolvedValue(true),
refreshTopChats: vi.fn().mockResolvedValue(undefined),
},
conversationStateRepo: { get: vi.fn(), save: vi.fn() },
activityLogRepo: { insert: vi.fn() },
},
});
const first = runtime(firstAccountId, 'owner-one');
const second = runtime(secondAccountId, 'owner-two');
const singletonSend = vi.fn();
const runtimes = new Map([[firstAccountId, first], [secondAccountId, second]]);
const context = {
logger: { error: vi.fn() },
activity: { push: vi.fn() },
broadcaster: { push: vi.fn() },
services: {
chat: { sendChatMessage: singletonSend },
accountRuntimes: {
list: () => [...runtimes.values()].map((item) => ({
accountId: item.session.accountId,
redditUsername: item.session.redditUsername,
state: 'running',
})),
get: (accountId: string) => runtimes.get(accountId) ?? null,
},
},
} as unknown as WebContext;
const app = express()
.use(express.json())
.use('/api', createConversationsRouter(context));
const ownedId = `${encodeURIComponent(secondAccountId)}~${encodeURIComponent('same-room')}`;
const response = await request(app, `/api/conversations/${encodeURIComponent(ownedId)}/send-reply`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ message: 'reply from account two' }),
});
expect(response.status).toBe(200);
expect(second.resources.chatService.sendChatMessage).toHaveBeenCalledWith(
'/room/same-room',
'reply from account two',
);
expect(first.resources.chatService.sendChatMessage).not.toHaveBeenCalled();
expect(singletonSend).not.toHaveBeenCalled();
expect(second.resources.activityLogRepo.insert).toHaveBeenCalledWith(expect.objectContaining({
type: 'sent', recipient: 'alice',
}));
expect(second.resources.conversationStateRepo.save).toHaveBeenCalledWith(
'same-room',
expect.objectContaining({
automation: expect.objectContaining({
status: 'user-overridden',
handoffReason: 'A manual reply was sent in this conversation.',
}),
}),
);
expect(first.resources.activityLogRepo.insert).not.toHaveBeenCalled();
});
it('lists every enabled image and shares the operator-selected one through the owning runtime', async () => {
const accountId = '@t2_one:reddit.com';
const assetId = '11111111-1111-4111-8111-111111111111';
const conversation = {
username: 'alice', roomName: 'alice', roomId: 'room-one', url: '/room/room-one',
lastMessageTime: 2, lastMessageSnippet: 'send a selfie', isFromUs: false, unread: true,
messages: [
{ sender: 'me', text: 'hello', timestamp: 1, isSelf: true },
{ eventId: '$request', sender: 'alice', text: 'send a selfie', timestamp: 2, isSelf: false },
],
};
const asset = {
id: assetId, tags: ['selfie'], safety: 'sfw', classificationStatus: 'ready', description: 'selfie', confidence: 1,
fileName: 'selfie.jpg', relativePath: 'nested/selfie.jpg', mimeType: 'image/jpeg', sizeBytes: 4,
width: 10, height: 10, enabled: true, consentConfirmedAt: 1, createdAt: 1, updatedAt: 1,
sendCount: 0, lastSentAt: null,
};
const mediaExchange = {
listShareableManual: vi.fn().mockReturnValue([asset]),
detect: vi.fn().mockReturnValue('selfie'),
shareManual: vi.fn().mockResolvedValue({ kind: 'sent', assetId, requestKind: 'generic' }),
};
const runtime = {
snapshot: () => ({ state: 'running' }),
resources: {
chatService: {
getCachedConversations: vi.fn().mockResolvedValue([conversation]),
loadConversationDetails: vi.fn().mockResolvedValue(undefined),
refreshTopChats: vi.fn().mockResolvedValue(undefined),
},
conversationStateRepo: { get: vi.fn(), save: vi.fn() },
activityLogRepo: { insert: vi.fn() },
mediaExchange,
rateLimiter: { throttle: vi.fn((work: () => Promise<unknown>) => work()) },
},
};
const context = {
logger: { error: vi.fn() }, activity: { push: vi.fn() }, broadcaster: { push: vi.fn() },
services: {
chat: {},
accountRuntimes: {
list: () => [{ accountId, redditUsername: 'owner-one', state: 'running' }],
get: (requested: string) => requested === accountId ? runtime : null,
},
},
} as unknown as WebContext;
const app = express().use(express.json()).use('/api', createConversationsRouter(context));
const ownedId = `${encodeURIComponent(accountId)}~room-one`;
const optionsResponse = await request(app, `/api/conversations/${encodeURIComponent(ownedId)}/shareable-media`);
expect(optionsResponse.status).toBe(200);
expect(await optionsResponse.json()).toEqual({
requestKind: 'selfie', reason: null, warning: null,
assets: [expect.objectContaining({ id: assetId, urlPath: '/library-media/nested/selfie.jpg' })],
});
expect(mediaExchange.listShareableManual).toHaveBeenCalledWith('room-one');
const sendResponse = await request(app, `/api/conversations/${encodeURIComponent(ownedId)}/share-media`, {
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ assetId }),
});
expect(sendResponse.status).toBe(200);
expect(await sendResponse.json()).toEqual({ success: true });
expect(mediaExchange.shareManual).toHaveBeenCalledWith('room-one', assetId);
expect(runtime.resources.conversationStateRepo.save).toHaveBeenCalledWith('room-one', expect.objectContaining({
automation: expect.objectContaining({ status: 'user-overridden' }),
}));
});
it('rejects an unowned conversation ID while account runtimes are active', async () => {
const context = {
logger: { error: vi.fn() },
services: {
chat: { getCachedConversations: vi.fn().mockResolvedValue([]) },
accountRuntimes: {
list: () => [{ accountId: '@t2_one:reddit.com', redditUsername: 'owner-one', state: 'running' }],
get: vi.fn(),
},
},
} as unknown as WebContext;
const app = express().use('/api', createConversationsRouter(context));
const response = await request(app, '/api/conversations/same-room');
expect(response.status).toBe(404);
expect(context.services.chat.getCachedConversations).not.toHaveBeenCalled();
});
it('reports an unavailable chat service instead of presenting it as an empty inbox', async () => {
const context = {
logger: { error: vi.fn() },
services: {},
} as unknown as WebContext;
const app = express().use('/api', createConversationsRouter(context));
const response = await request(app, '/api/conversations');
expect(response.status).toBe(503);
expect(await response.json()).toEqual({ error: 'Chat service not initialized' });
});
it('reports a cache read failure instead of presenting it as an empty inbox', async () => {
const logger = { error: vi.fn() };
const context = {
logger,
services: {
chat: {
getCachedConversations: vi.fn().mockRejectedValue(new Error('cache unavailable')),
},
},
} as unknown as WebContext;
const app = express().use('/api', createConversationsRouter(context));
const response = await request(app, '/api/conversations');
expect(response.status).toBe(500);
expect(await response.json()).toEqual({ error: 'Failed to fetch conversations' });
expect(logger.error).toHaveBeenCalledWith('Failed to fetch conversations:', expect.any(Error));
});
it('marks conversations that have received images in the durable media index', async () => {
const conversation = {
username: 'alice',
roomName: 'alice',
roomId: 'room-alice',
lastMessageTime: 1_786_000_000_000,
lastMessageSnippet: '[Image]',
isFromUs: false,
unread: true,
};
const context = {
logger: { error: vi.fn() },
services: {
chat: { getCachedConversations: vi.fn().mockResolvedValue([conversation]) },
media: {
getMediaItems: vi.fn().mockResolvedValue([
{
id: 'image-one',
chatUsername: 'alice',
chatRoomId: 'room-alice',
sender: 'alice',
timestamp: 1_786_000_000_000,
originalUrl: 'mxc://reddit.com/image-one',
fileName: 'image-one.jpg',
relativePath: 'image-one.jpg',
createdAt: 1_786_000_000_100,
},
]),
},
},
} as unknown as WebContext;
const app = express().use('/api', createConversationsRouter(context));
const legacyResponse = await request(app, '/api/conversations');
const response = await request(app, '/api/conversations?includeImageMetadata=true');
expect(legacyResponse.status).toBe(200);
expect(await legacyResponse.json()).toEqual([{ ...conversation, ...UNREAD_INBOUND_TRIAGE }]);
expect(response.status).toBe(200);
expect(await response.json()).toEqual([
{ ...conversation, ...UNREAD_INBOUND_TRIAGE, hasReceivedImages: true },
]);
});
it('marks conversations with a durable seller detection', async () => {
const conversation = {
username: 'seller', roomName: 'seller', roomId: 'room-seller',
lastMessageTime: 1, lastMessageSnippet: 'my page', isFromUs: false, unread: true,
};
const context = {
logger: { error: vi.fn() },
services: {
chat: { getCachedConversations: vi.fn().mockResolvedValue([conversation]) },
conversationState: {
get: vi.fn(() => ({ ...buildConversationState([]), sellerDetected: true })),
},
},
} as unknown as WebContext;
const app = express().use('/api', createConversationsRouter(context));
const response = await request(app, '/api/conversations');
expect(response.status).toBe(200);
expect(await response.json()).toEqual([
{
...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],
]);
});
it('serves conversation details without starting a delta sync', async () => {
const conversation = {
username: 'alice',
roomName: 'alice',
roomId: 'room-alice',
lastMessageTime: 1_786_000_000_000,
lastMessageSnippet: 'Hello',
isFromUs: false,
unread: false,
messages: [
{ sender: 'me', text: 'Automated hello', timestamp: 1_785_999_900_000, isSelf: true },
{ sender: 'alice', text: 'Hello', timestamp: 1_786_000_000_000, isSelf: false },
],
};
const savedState = buildConversationState(conversation.messages);
savedState.automation.turnsUsed = 1;
savedState.automation.aiMessageFingerprints = [conversationMessageFingerprint('Automated hello')];
const refreshTopChats = vi.fn();
const loadConversationDetails = vi.fn();
const context = {
logger: { error: vi.fn(), warn: vi.fn() },
repos: {
messages: { getLatestByRecipient: vi.fn().mockReturnValue(undefined) },
},
services: {
conversationState: { get: vi.fn().mockReturnValue(savedState), save: vi.fn() },
chat: {
getSyncStatus: vi.fn().mockReturnValue({
syncing: false,
syncingKind: null,
progress: null,
}),
getCachedConversations: vi.fn().mockResolvedValue([conversation]),
refreshTopChats,
loadConversationDetails,
saveCacheExternal: vi.fn(),
},
},
} as unknown as WebContext;
const app = express().use('/api', createConversationsRouter(context));
const response = await request(app, '/api/conversations/room-alice');
expect(response.status).toBe(200);
const body = await response.json();
expect(body.username).toBe('alice');
expect(body.automation).toEqual({
turnsUsed: 1,
remainingTurns: 2,
status: 'active',
handoffReason: null,
});
expect(body.messages[0]).toMatchObject({ text: 'Automated hello', isAi: true });
expect(body.messages[1].isAi).toBeUndefined();
expect(refreshTopChats).not.toHaveBeenCalled();
expect(loadConversationDetails).not.toHaveBeenCalled();
});
it('attaches the exact room origin with its saved URL, title, and image', async () => {
const conversation = {
username: 'alice', roomName: 'alice', roomId: 'room-alice',
lastMessageTime: 10, lastMessageSnippet: 'Hello', isFromUs: true, unread: false,
messages: [{ sender: 'me', text: 'Hello', timestamp: 10, isSelf: true }],
};
const roomOrigin = {
id: 7, postId: 'post-exact', recipient: 'alice', subject: 'alice', message: 'Hello',
sentAt: 9, gotResponse: false, responseCheckedAt: null, strategyId: null,
chatRoomId: 'room-alice', subreddit: 'example', postTitle: 'Exact source post',
postUrl: 'https://www.reddit.com/r/example/comments/post-exact/exact_source_post/',
postImageUrl: 'https://i.redd.it/exact-source.jpg',
postCreatedAt: 8,
};
const latestRecipientOrigin = { ...roomOrigin, postId: 'wrong-latest-post', postTitle: 'Wrong post' };
const context = {
logger: { error: vi.fn(), warn: vi.fn() },
repos: { messages: {
getByChatRoomId: vi.fn().mockReturnValue(roomOrigin),
getLatestByRecipient: vi.fn().mockReturnValue(latestRecipientOrigin),
} },
services: { chat: {
getSyncStatus: vi.fn().mockReturnValue({ syncing: false, syncingKind: null, progress: null }),
getCachedConversations: vi.fn().mockResolvedValue([conversation]),
loadConversationDetails: vi.fn(), saveCacheExternal: vi.fn(),
} },
} as unknown as WebContext;
const app = express().use('/api', createConversationsRouter(context));
const response = await request(app, '/api/conversations/room-alice');
expect(response.status).toBe(200);
const body = await response.json();
expect(body.triggeringPost).toEqual({
id: 'post-exact', subreddit: 'example', title: 'Exact source post',
url: roomOrigin.postUrl, imageUrl: roomOrigin.postImageUrl, postedAt: 8, sentAt: 9,
});
expect(body.messages[0]).toMatchObject({ text: 'Hello', isAi: true });
expect(context.repos.messages.getLatestByRecipient).not.toHaveBeenCalled();
});
it('waits for the conversation delta sync after sending a reply', async () => {
const conversation = {
username: 'alice', roomName: 'alice', roomId: 'room-alice',
url: 'https://chat.reddit.com/room/room-alice',
lastMessageTime: 10, lastMessageSnippet: 'Hello', isFromUs: false, unread: false,
messages: [{ sender: 'alice', text: 'Hello', timestamp: 10, isSelf: false }],
};
let completeSync: (() => void) | undefined;
const refreshTopChats = vi.fn(() => new Promise<void>((resolve) => {
completeSync = resolve;
}));
const context = {
logger: { error: vi.fn() },
activity: { push: vi.fn() },
broadcaster: { push: vi.fn() },
services: { chat: {
getCachedConversations: vi.fn().mockResolvedValue([conversation]),
sendChatMessage: vi.fn().mockResolvedValue(true),
refreshTopChats,
} },
} as unknown as WebContext;
const app = express()
.use(express.json())
.use('/api', createConversationsRouter(context));
let responseSettled = false;
const responsePromise = request(app, '/api/conversations/room-alice/send-reply', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: 'AI-assisted reply' }),
}).then((response) => {
responseSettled = true;
return response;
});
await vi.waitFor(() => expect(refreshTopChats).toHaveBeenCalledWith(5));
expect(responseSettled).toBe(false);
completeSync?.();
const response = await responsePromise;
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ success: true });
});
describe('operator read state', () => {
const conversation = {
username: 'alice', roomName: 'alice', roomId: 'room-alice',
lastMessageTime: 500, lastMessageSnippet: 'Hello', isFromUs: false, unread: false,
messages: [{ sender: 'alice', text: 'Hello', timestamp: 500, isSelf: false }],
};
function contextWithReadState(readState: Record<string, unknown>) {
return {
logger: { error: vi.fn(), warn: vi.fn() },
repos: { messages: { getByChatRoomId: vi.fn(), getLatestByRecipient: vi.fn() } },
services: {
chat: {
getCachedConversations: vi.fn().mockResolvedValue([conversation]),
getSyncStatus: vi.fn(() => ({ syncing: false, progress: null, syncingKind: null })),
loadConversationDetails: vi.fn(),
saveCacheExternal: vi.fn(),
},
conversationReadState: readState,
},
} as unknown as WebContext;
}
function readStateStub(record?: Record<string, unknown>) {
return {
get: vi.fn(() => record),
list: vi.fn(() => (record ? [record] : [])),
expireSnoozes: vi.fn(() => []),
markRead: vi.fn(),
markUnread: vi.fn(),
setStatus: vi.fn(),
};
}
it('reports an inbound message newer than the watermark as unread and needing a reply', async () => {
const readState = readStateStub({
conversationId: 'room-alice', lastReadMessageTs: 100, lastReadAt: 100,
status: 'open', snoozedUntil: null,
});
const app = express().use('/api', createConversationsRouter(contextWithReadState(readState)));
const body = await (await request(app, '/api/conversations')).json();
expect(body[0]).toMatchObject({
hasUnreadInbound: true, needsReply: true, triageStatus: 'open', lastReadMessageTs: 100,
});
expect(readState.expireSnoozes).toHaveBeenCalled();
});
it('clears unread once the watermark reaches the newest message', async () => {
const readState = readStateStub({
conversationId: 'room-alice', lastReadMessageTs: 500, lastReadAt: 600,
status: 'open', snoozedUntil: null,
});
const app = express().use('/api', createConversationsRouter(contextWithReadState(readState)));
const body = await (await request(app, '/api/conversations')).json();
expect(body[0]).toMatchObject({ hasUnreadInbound: false, needsReply: true });
});
it('stops treating a done conversation as needing a reply', async () => {
const readState = readStateStub({
conversationId: 'room-alice', lastReadMessageTs: 500, lastReadAt: 600,
status: 'done', snoozedUntil: null,
});
const app = express().use('/api', createConversationsRouter(contextWithReadState(readState)));
const body = await (await request(app, '/api/conversations')).json();
expect(body[0]).toMatchObject({ needsReply: false, triageStatus: 'done' });
});
it('marks read through the newest message when no timestamp is supplied', async () => {
const readState = readStateStub();
const app = express()
.use(express.json())
.use('/api', createConversationsRouter(contextWithReadState(readState)));
const response = await request(app, '/api/conversations/room-alice/read', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
expect(response.status).toBe(200);
expect(readState.markRead).toHaveBeenCalledWith('room-alice', 500);
});
it('marks read only through an explicitly supplied timestamp', async () => {
const readState = readStateStub();
const app = express()
.use(express.json())
.use('/api', createConversationsRouter(contextWithReadState(readState)));
await request(app, '/api/conversations/room-alice/read', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ throughTimestamp: 200 }),
});
expect(readState.markRead).toHaveBeenCalledWith('room-alice', 200);
});
it('marks a conversation unread again', async () => {
const readState = readStateStub();
const app = express()
.use(express.json())
.use('/api', createConversationsRouter(contextWithReadState(readState)));
const response = await request(app, '/api/conversations/room-alice/unread', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
expect(response.status).toBe(200);
expect(readState.markUnread).toHaveBeenCalledWith('room-alice', 500);
});
it('rejects a snooze with no deadline', async () => {
const readState = readStateStub();
const app = express()
.use(express.json())
.use('/api', createConversationsRouter(contextWithReadState(readState)));
const response = await request(app, '/api/conversations/room-alice/status', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'snoozed' }),
});
expect(response.status).toBe(400);
expect(readState.setStatus).not.toHaveBeenCalled();
});
it('rejects an unrecognised triage status', async () => {
const readState = readStateStub();
const app = express()
.use(express.json())
.use('/api', createConversationsRouter(contextWithReadState(readState)));
const response = await request(app, '/api/conversations/room-alice/status', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'archived' }),
});
expect(response.status).toBe(400);
expect(readState.setStatus).not.toHaveBeenCalled();
});
it('stores a snooze deadline', async () => {
const readState = readStateStub();
const app = express()
.use(express.json())
.use('/api', createConversationsRouter(contextWithReadState(readState)));
const response = await request(app, '/api/conversations/room-alice/status', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'snoozed', snoozeUntil: 9_000 }),
});
expect(response.status).toBe(200);
expect(readState.setStatus).toHaveBeenCalledWith('room-alice', 'snoozed', 9_000);
});
it('reports unresolvable ids in a bulk mark-read rather than failing the batch', async () => {
const readState = readStateStub();
const app = express()
.use(express.json())
.use('/api', createConversationsRouter(contextWithReadState(readState)));
const response = await request(app, '/api/conversations/bulk-read', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ conversationIds: ['room-alice', 'room-missing'] }),
});
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ success: true, updated: 1, failed: ['room-missing'] });
expect(readState.markRead).toHaveBeenCalledTimes(1);
});
it('rejects an empty bulk mark-read', async () => {
const app = express()
.use(express.json())
.use('/api', createConversationsRouter(contextWithReadState(readStateStub())));
const response = await request(app, '/api/conversations/bulk-read', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ conversationIds: [] }),
});
expect(response.status).toBe(400);
});
it('exposes the read watermark on the conversation detail so the divider can be placed', async () => {
const readState = readStateStub({
conversationId: 'room-alice', lastReadMessageTs: 100, lastReadAt: 100,
status: 'open', snoozedUntil: null,
});
const app = express().use('/api', createConversationsRouter(contextWithReadState(readState)));
const body = await (await request(app, '/api/conversations/room-alice')).json();
expect(body).toMatchObject({
lastReadMessageTs: 100, hasUnreadInbound: true, needsReply: true, triageStatus: 'open',
});
});
});
});