Files
RedditBot/tests/web/conversations-route.test.ts
T
Jamie BruntonandClaude Opus 5 725b5fc4e6 feat: application-level read state and inbox triage for conversations
Conversations were hard to manage because "unread" came straight from
Matrix's notification_count, and nothing in the app ever sends a read
receipt — so opening a conversation in the dashboard could never clear it
and the unread set only ever grew. The list also collapsed three
orthogonal facts into one word, labelling "they spoke last and we have
not replied" as "Up to date".

Read state is now ours:

- conversation_read_state table (migration 19) plus
  ConversationReadStateRepository, kept separate from conversation_states
  so a read write can never be lost to mergeConversationState. The
  watermark is monotonic, so a stale poll cannot resurrect an unread
  badge.
- hasUnreadInbound and needsReply are derived server-side and returned on
  every conversation. Matrix's `unread` stays on the contract but the
  dashboard no longer reads it.
- POST /conversations/{id}/read|unread|status and
  POST /conversations/bulk-read, with optimistic cache updates.

Inbox rework:

- List rows are a real grid (checkbox, open button, chip strip) instead of
  a full-bleed absolute overlay button, which previously forced a
  pointer-events escape hatch for anything interactive in a row. Rows use
  aria-current rather than aria-pressed. Not a listbox: options must not
  contain interactive descendants.
- Counted filter pills replace the select, independent state chips replace
  the collapsed status word, plus a sort control (including oldest
  unanswered), a per-account filter, age-coloured timestamps, and an
  explicit count of rows hidden by the seller filter.
- Keyboard: j/k/arrows, Enter, u, e, / and Escape. Shortcuts yield to any
  open dialog so Escape dismisses the modal, not the conversation behind
  it. Multi-select with bulk mark-read and mark-done.
- Detail panel: auto-mark-read on open, a "New messages" divider pinned at
  mount (deriving it from the live watermark made it vanish a second after
  opening), triage actions in the header, the automation banner moved out
  of the composer, day dividers with time-only stamps, and a flex column
  so history scrolls in its own region instead of painting over the
  composer.
- Row order freezes while a conversation is open, with a "N new — refresh"
  pill, so a background poll cannot reorder rows mid-triage.

Two database tests rewound user_version by an offset from the latest
version, which silently re-targets a different migration whenever one is
appended. They now pin absolute versions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 10:06:03 +01:00

693 lines
28 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)]) } },
});
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', 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(() => ({ 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 },
]);
});
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',
});
});
});
});