372 lines
15 KiB
TypeScript
372 lines
15 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']>> = [];
|
|
|
|
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('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]);
|
|
expect(response.status).toBe(200);
|
|
expect(await response.json()).toEqual([{ ...conversation, hasReceivedImages: 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 });
|
|
});
|
|
});
|