Files
RedditBot/tests/web/conversations-route.test.ts
T

1051 lines
44 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('lists authoritative replacement targets and generates a private preview', 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: '[Image]', isFromUs: false, unread: true,
messages: [{
sender: 'alice', text: '[Image]', timestamp: 2, isSelf: false,
media: [{ originalUrl: 'mxc://reddit.com/one', localPath: 'account/one.jpg', fileName: 'one.jpg' }],
}],
};
const asset = {
id: assetId, tags: ['selfie'], safety: 'sfw', classificationStatus: 'ready', description: 'phone template', confidence: 1,
fileName: 'green-phone.jpg', relativePath: 'green-phone.jpg', mimeType: 'image/jpeg', sizeBytes: 4,
width: 10, height: 10, enabled: true, consentConfirmedAt: 1, createdAt: 1, updatedAt: 1,
sendCount: 0, lastSentAt: null,
};
const imageReplacement = {
generate: vi.fn().mockResolvedValue({ bytes: Buffer.from('generated'), mimeType: 'image/jpeg', width: 100, height: 200 }),
};
const runtime = {
snapshot: () => ({ state: 'running' }),
resources: {
chatService: { getCachedConversations: vi.fn().mockResolvedValue([conversation]) },
messagesRepo: {
getByChatRoomId: vi.fn().mockReturnValue({ postImageUrl: 'https://i.redd.it/original.jpg' }),
getLatestByRecipient: vi.fn(),
},
},
};
const context = {
config: { reddit: { userAgent: 'test-agent' } },
logger: { error: vi.fn() },
repos: { messages: {} },
services: {
chat: {}, imageReplacement,
mediaLibrary: {
list: vi.fn().mockReturnValue([asset]),
get: vi.fn().mockReturnValue(asset),
resolvePath: vi.fn().mockReturnValue('/private/green-phone.jpg'),
},
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(`${encodeURIComponent(accountId)}~room-one`);
const optionsResponse = await request(app, `/api/conversations/${ownedId}/image-replacement-options`);
expect(optionsResponse.status).toBe(200);
expect(await optionsResponse.json()).toMatchObject({
reason: null,
sources: [{ id: assetId, urlPath: '/library-media/green-phone.jpg' }],
targets: [
{ id: 'original-post', label: 'Original post image', urlPath: 'https://i.redd.it/original.jpg' },
{ id: 'message-0-0', urlPath: '/media/account/one.jpg' },
],
});
const generateResponse = await request(app, `/api/conversations/${ownedId}/generate-image-replacement`, {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ sourceAssetId: assetId, targetId: 'message-0-0' }),
});
expect(generateResponse.status).toBe(200);
expect(await generateResponse.json()).toEqual({
imageDataUrl: `data:image/jpeg;base64,${Buffer.from('generated').toString('base64')}`,
mimeType: 'image/jpeg', fileName: 'replacement-alice.jpg', width: 100, height: 200,
});
expect(imageReplacement.generate).toHaveBeenCalledWith('/private/green-phone.jpg', {
kind: 'conversation-media', localPath: 'account/one.jpg',
});
});
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('forced seller sweep', () => {
const sweepConfig = {
reddit: { sellerKeywords: ['onlyfans'], sellerDomains: ['fansly.com'] },
conversations: {
killOnSellerKeyword: true,
aiReplyScreening: { enabled: false, killConfidence: 0.8, flagConfidence: 0.6 },
},
};
function chat(username: string, roomId: string, snippet: string) {
return {
username, roomName: username, roomId, lastMessageTime: 500,
lastMessageSnippet: snippet, isFromUs: false, unread: false,
messages: [{ sender: username, text: snippet, timestamp: 500, isSelf: false }],
};
}
function sweepContext(conversations: unknown[], ai?: unknown, states = new Map<string, unknown>()) {
const runtime = {
session: { accountId: '@t2_one:reddit.com', redditUsername: 'owner-one' },
snapshot: () => ({ state: 'running' }),
resources: {
chatService: { getCachedConversations: vi.fn().mockResolvedValue(conversations) },
conversationStateRepo: {
get: (id: string) => states.get(id),
save: vi.fn((id: string, state: unknown) => { states.set(id, state); }),
},
conversationReadStateRepo: { setStatus: vi.fn() },
},
};
const context = {
logger: { error: vi.fn(), warn: vi.fn(), info: vi.fn() },
broadcaster: { push: vi.fn() },
config: sweepConfig,
services: {
chat: { getCachedConversations: vi.fn().mockResolvedValue([]) },
ai,
accountRuntimes: {
list: () => [{ accountId: '@t2_one:reddit.com', redditUsername: 'owner-one', state: 'running' }],
get: () => runtime,
},
},
} as unknown as WebContext;
return { context, runtime, states };
}
it('flags keyword hits for free and sends only the unsettled conversations to the classifier', async () => {
const ai = { screenConversationsForSellers: vi.fn().mockResolvedValue([]) };
const { context, runtime } = sweepContext(
[chat('alice', 'room-alice', 'everything else is on my onlyfans'), chat('bob', 'room-bob', 'how was your day?')],
ai,
);
const app = express().use(express.json()).use('/api', createConversationsRouter(context));
const response = await request(app, '/api/conversations/seller-scan', { method: 'POST' });
const body = await response.json();
expect(response.status).toBe(200);
expect(body).toMatchObject({ success: true, scanned: 2, deterministic: 1, aiRequests: 1 });
expect(body.flagged).toEqual([expect.objectContaining({
// Flags carry the owned id so the dashboard can match its list rows.
conversationId: `${encodeURIComponent('@t2_one:reddit.com')}~room-alice`,
username: 'alice', source: 'funnel', killed: true,
})]);
// The keyword hit never reaches the model; only the clean row is screened.
expect(ai.screenConversationsForSellers.mock.calls[0][0]).toEqual([
expect.objectContaining({ username: 'bob' }),
]);
expect(runtime.resources.conversationReadStateRepo.setStatus).toHaveBeenCalledWith('room-alice', 'done', null);
});
it('batches the conversations the rules could not settle into one classifier request', async () => {
const ai = {
screenConversationsForSellers: vi.fn().mockResolvedValue([
{ id: '1', seller: true, confidence: 0.9, evidence: 'sells customs' },
{ id: '2', seller: false, confidence: 0.05, evidence: 'ordinary chat' },
]),
};
const { context } = sweepContext(
[chat('alice', 'room-alice', 'dm me about customs'), chat('bob', 'room-bob', 'how was your day?')],
ai,
);
const app = express().use(express.json()).use('/api', createConversationsRouter(context));
const body = await (await request(app, '/api/conversations/seller-scan', { method: 'POST' })).json();
expect(ai.screenConversationsForSellers).toHaveBeenCalledTimes(1);
expect(body).toMatchObject({ scanned: 2, aiScreened: 2, aiRequests: 1 });
expect(body.flagged).toEqual([expect.objectContaining({ username: 'alice', source: 'ai', confidence: 0.9 })]);
});
it('screens conversations whose transcript was never cached, using the summary snippet', async () => {
const ai = {
screenConversationsForSellers: vi.fn().mockResolvedValue([
{ id: '1', seller: true, confidence: 0.95, evidence: 'links to a paid page' },
]),
};
const { context } = sweepContext([{
username: 'alice', roomName: 'alice', roomId: 'room-alice', lastMessageTime: 500,
lastMessageSnippet: 'come see my page for the good stuff', isFromUs: false, unread: false,
}], ai);
const app = express().use(express.json()).use('/api', createConversationsRouter(context));
const body = await (await request(app, '/api/conversations/seller-scan', { method: 'POST' })).json();
expect(ai.screenConversationsForSellers.mock.calls[0][0][0].messages).toEqual([
{ sender: 'alice', text: 'come see my page for the good stuff', isSelf: false },
]);
expect(body.flagged).toHaveLength(1);
});
it('reports a degraded sweep instead of failing when screening is unavailable', async () => {
const ai = { screenConversationsForSellers: vi.fn().mockRejectedValue(new Error('router down')) };
const { context } = sweepContext([chat('alice', 'room-alice', 'hey there stranger')], ai);
const app = express().use(express.json()).use('/api', createConversationsRouter(context));
const response = await request(app, '/api/conversations/seller-scan', { method: 'POST' });
expect(response.status).toBe(200);
expect(await response.json()).toMatchObject({ success: true, degraded: true, flagged: [] });
});
it('refuses to scan when no chat service is initialized', async () => {
const context = { logger: { error: vi.fn() }, services: {} } as unknown as WebContext;
const app = express().use(express.json()).use('/api', createConversationsRouter(context));
expect((await request(app, '/api/conversations/seller-scan', { method: 'POST' })).status).toBe(503);
});
});
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',
});
});
});
describe('removed accounts', () => {
const ARCHIVED = {
username: 'alice', roomName: 'alice', roomId: 'room-alice', lastMessageTime: 500,
lastMessageSnippet: 'thanks!', isFromUs: false, unread: false,
messages: [{ sender: 'alice', text: 'thanks!', timestamp: 500, isSelf: false }],
};
const OWNED_ID = `${encodeURIComponent('@t2_gone:reddit.com')}~${encodeURIComponent('room-alice')}`;
/** A retired account: listed by the service, but with no runtime at all. */
function retiredContext() {
return {
logger: { error: vi.fn(), warn: vi.fn() },
repos: {
accountContent: {
listConversations: vi.fn((accountId: string) => accountId === '@t2_gone:reddit.com'
? [{ conversationId: 'room-alice', payloadJson: JSON.stringify(ARCHIVED) }]
: []),
},
messages: { getByChatRoomId: vi.fn(), getLatestByRecipient: vi.fn() },
},
services: {
chat: {
getCachedConversations: vi.fn().mockResolvedValue([]),
getSyncStatus: () => ({ syncing: false }),
loadConversationDetails: vi.fn(),
saveCacheExternal: vi.fn(),
},
// Present so suggest-reply reaches the read-only guard rather than
// bailing out earlier on a missing AI client.
ai: { generateReply: vi.fn() },
accountRuntimes: { list: () => [], get: () => null },
redditAccounts: {
listRemoved: () => [{ accountId: '@t2_gone:reddit.com', redditUsername: 'gone-bot' }],
},
},
} as unknown as WebContext;
}
it('keeps a retired account\'s conversations listed, read-only and out of the reply queue', async () => {
const app = express().use('/api', createConversationsRouter(retiredContext()));
const body = await (await request(app, '/api/conversations')).json();
expect(body).toEqual([expect.objectContaining({
roomId: 'room-alice',
readOnly: true,
needsReply: false,
hasUnreadInbound: false,
// Open, not resolved: the dashboard's default filter hides resolved
// conversations, and archived history has to stay browsable.
triageStatus: 'open',
owner: { accountId: '@t2_gone:reddit.com', redditUsername: 'gone-bot' },
})]);
});
it('serves the stored transcript without trying to sync more of it', async () => {
const context = retiredContext();
const app = express().use('/api', createConversationsRouter(context));
const body = await (await request(app, `/api/conversations/${encodeURIComponent(OWNED_ID)}`)).json();
expect(body).toMatchObject({ roomId: 'room-alice', readOnly: true });
expect(body.messages).toHaveLength(1);
// The fallback chat service belongs to another account: touching it would
// both sync the wrong session and mutate the wrong cache.
expect(context.services.chat!.loadConversationDetails).not.toHaveBeenCalled();
expect(context.services.chat!.saveCacheExternal).not.toHaveBeenCalled();
});
it('refuses to send, suggest or mark anything in a retired conversation', async () => {
const app = express().use(express.json()).use('/api', createConversationsRouter(retiredContext()));
const path = `/api/conversations/${encodeURIComponent(OWNED_ID)}`;
const responses = await Promise.all([
request(app, `${path}/send-reply`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: 'hello again' }),
}),
request(app, `${path}/suggest-reply`, { method: 'POST' }),
request(app, `${path}/read`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}',
}),
request(app, `${path}/share-media`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ assetId: '00000000-0000-4000-8000-000000000000' }),
}),
]);
expect(responses.map((response) => response.status)).toEqual([409, 409, 409, 409]);
expect((await responses[0].json()).error).toMatch(/removed account/i);
});
});
});