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>
754 lines
30 KiB
TypeScript
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',
|
|
});
|
|
});
|
|
});
|
|
});
|