1051 lines
44 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|