Merge remote-tracking branch 'origin/main'

# Conflicts:
#	src/browser/client.ts
#	src/services/reddit-session-importer.ts
#	src/web/routes/conversations.ts
This commit is contained in:
jjbrunton committed 2026-08-08 20:17:19 +01:00
commit 48492d5238
30 files changed
+1041 -181

No files matched your search

+22 -6
View File
@@ -10,7 +10,7 @@ describe('AccountAuthContext', () => {
const refreshMatrixCredentials = vi.fn();
await expect(authenticateCurrentAccount({
getRedditUsername: vi.fn().mockResolvedValue(null),
getRedditIdentity: vi.fn().mockResolvedValue(null),
refreshMatrixCredentials,
})).resolves.toBeNull();
@@ -20,7 +20,7 @@ describe('AccountAuthContext', () => {
it('fails closed until Reddit and Matrix resolve to one authenticated account', async () => {
const auth = new AccountAuthContext(createMockLogger());
const authenticate = vi.fn().mockResolvedValue({
accountId: '@t2_account:reddit.com',
accountId: 't2_account',
redditUsername: 'account',
matrixUserId: '@t2_account:reddit.com',
});
@@ -34,7 +34,7 @@ describe('AccountAuthContext', () => {
expect(auth.isFullyAuthenticated()).toBe(true);
expect(auth.getSnapshot()).toMatchObject({
status: 'authenticated',
accountId: '@t2_account:reddit.com',
accountId: 't2_account',
});
});
@@ -52,7 +52,7 @@ describe('AccountAuthContext', () => {
const second = auth.ensureFullyAuthenticated();
await vi.waitFor(() => expect(authenticate).toHaveBeenCalledOnce());
resolveAuth?.({
accountId: '@t2_account:reddit.com',
accountId: 't2_account',
redditUsername: 'account',
matrixUserId: '@t2_account:reddit.com',
});
@@ -63,7 +63,7 @@ describe('AccountAuthContext', () => {
it('invalidates the whole account context when either login layer expires', async () => {
const auth = new AccountAuthContext(createMockLogger());
auth.markAuthenticated({
accountId: '@t2_account:reddit.com',
accountId: 't2_account',
redditUsername: 'account',
matrixUserId: '@t2_account:reddit.com',
});
@@ -86,7 +86,7 @@ describe('AccountAuthContext', () => {
now: () => now,
});
auth.markAuthenticated({
accountId: '@t2_account:reddit.com',
accountId: 't2_account',
redditUsername: 'account',
matrixUserId: '@t2_account:reddit.com',
});
@@ -115,4 +115,20 @@ describe('AccountAuthContext', () => {
await auth.ensureFullyAuthenticated();
expect(authenticate).toHaveBeenCalledTimes(2);
});
it('fails closed when Reddit and Matrix belong to different accounts', async () => {
const auth = new AccountAuthContext(createMockLogger());
auth.setAuthenticator(vi.fn().mockResolvedValue({
accountId: 't2_cookie',
redditUsername: 'cookie-account',
matrixUserId: '@t2_chat:reddit.com',
}));
await expect(auth.ensureFullyAuthenticated()).resolves.toBe(false);
expect(auth.getSnapshot()).toMatchObject({
status: 'login-required',
accountId: null,
reason: expect.stringMatching(/does not match Matrix account/),
});
});
});
+6 -2
View File
@@ -36,10 +36,10 @@ describe('BrowserClient', () => {
expect(JSON.stringify(logger)).not.toContain('secret');
});
it('reads the username from Reddit\'s nested me.json account response', async () => {
it('reads the username and stable account ID from Reddit\'s nested me.json response', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ kind: 't2', data: { name: 'bot-account' } }),
json: async () => ({ kind: 't2', data: { name: 'bot-account', id: 'abc123' } }),
});
vi.stubGlobal('fetch', fetchMock);
const page = {
@@ -50,6 +50,10 @@ describe('BrowserClient', () => {
const client = new BrowserClient(getTestConfig(), createLogger(), runtime);
await expect(client.getAuthenticatedUsername()).resolves.toBe('bot-account');
await expect(client.getAuthenticatedIdentity()).resolves.toEqual({
username: 'bot-account',
accountId: 't2_abc123',
});
expect(fetchMock).toHaveBeenCalledWith(
'https://www.reddit.com/api/me.json',
{ credentials: 'include' },
@@ -0,0 +1,67 @@
import { describe, expect, it, vi } from 'vitest';
import os from 'os';
import path from 'path';
import type { RedditUsersRepository } from '../src/database/repositories.js';
import { ChatCache } from '../src/services/chat/cache.js';
import { ChatService } from '../src/services/chat/service.js';
import { MatrixClient } from '../src/services/chat/matrix/client.js';
import { createMockLogger } from './mockLogger.js';
import { authenticatedAccountAuth } from './testUtils.js';
const creds = {
homeserver: 'https://matrix.example.test',
accessToken: 'tok',
userId: '@t2_me:reddit.com',
capturedAt: Date.now(),
};
describe('ChatService direct-room repair', () => {
it('repairs cached outbound rooms during delta sync', async () => {
const logger = createMockLogger();
const matrix = new MatrixClient(creds, logger, {
minRequestIntervalMs: 0,
fetchImpl: vi.fn(),
});
matrix.sync = vi.fn().mockResolvedValue({ next_batch: 'next', rooms: { join: {} } });
matrix.repairDirectRoomMappings = vi.fn().mockResolvedValue(1);
const users = {
getAll: () => [{ t2Id: 't2_alice', username: 'alice' }],
getByUsername: () => ({ t2Id: 't2_alice', username: 'alice' }),
upsert: vi.fn(),
} as unknown as RedditUsersRepository;
const cache = new ChatCache(
logger,
path.join(os.tmpdir(), `chat-direct-repair-${Date.now()}-${Math.random()}.json`),
);
cache.set([{
username: 'alice',
roomName: 'alice',
lastMessageTime: Date.now(),
lastMessageSnippet: 'hello',
isFromUs: true,
unread: false,
url: 'https://chat.reddit.com/room/room',
roomId: '!room:reddit.com',
messages: [],
}]);
const service = new ChatService(
matrix,
logger,
undefined, undefined, undefined, undefined,
cache,
undefined,
users,
authenticatedAccountAuth,
);
await service.refreshTopChats();
expect(matrix.repairDirectRoomMappings).toHaveBeenCalledWith([{
recipientMxid: '@t2_alice:reddit.com',
roomId: '!room:reddit.com',
}]);
});
});
+96
View File
@@ -0,0 +1,96 @@
import fs from 'fs/promises';
import os from 'os';
import path from 'path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { ChatCache } from '../src/services/chat/cache.js';
import { MatrixClient } from '../src/services/chat/matrix/client.js';
import { ChatService } from '../src/services/chat/service.js';
import { createMockLogger } from './mockLogger.js';
import { authenticatedAccountAuth } from './testUtils.js';
const temporaryDirectories: string[] = [];
afterEach(async () => {
await Promise.all(temporaryDirectories.splice(0).map((directory) =>
fs.rm(directory, { recursive: true, force: true }),
));
});
describe('ChatService text delta sync', () => {
it('merges a sent text event into conversation history without a media service', async () => {
const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'chat-text-sync-'));
temporaryDirectories.push(directory);
const cacheFile = path.join(directory, 'chat-cache.json');
const now = Date.now();
await fs.writeFile(cacheFile, JSON.stringify({
version: 1,
savedAt: now,
chats: [{
username: 'alice',
roomName: 'alice',
roomId: '!alice:example.test',
lastMessageTime: now - 1_000,
lastMessageSnippet: 'hello',
isFromUs: false,
unread: false,
messages: [{ sender: 'alice', text: 'hello', timestamp: now - 1_000, isSelf: false }],
}],
nextBatch: 's1',
lastDeltaSyncAt: now,
}), 'utf-8');
const logger = createMockLogger();
const matrix = new MatrixClient({
homeserver: 'https://matrix.example.test',
accessToken: 'token',
userId: '@me:example.test',
capturedAt: now,
}, logger, { minRequestIntervalMs: 0 });
matrix.sync = vi.fn(async () => ({
next_batch: 's2',
rooms: {
join: {
'!alice:example.test': {
timeline: {
events: [{
event_id: '$sent',
type: 'm.room.message',
sender: '@me:example.test',
origin_server_ts: now,
content: { msgtype: 'm.text', body: 'AI-assisted reply' },
}],
},
},
},
},
})) as typeof matrix.sync;
const cache = new ChatCache(logger, cacheFile);
const service = new ChatService(
matrix,
logger,
undefined,
undefined,
undefined,
undefined,
cache,
undefined,
undefined,
authenticatedAccountAuth,
);
await service.loadCache();
await service.refreshTopChats();
const [conversation] = await service.getCachedConversations();
expect(conversation.messages).toEqual([
{ sender: 'alice', text: 'hello', timestamp: now - 1_000, isSelf: false },
{ sender: 'Me', text: 'AI-assisted reply', timestamp: now, isSelf: true },
]);
const persisted = JSON.parse(await fs.readFile(cacheFile, 'utf-8')) as {
chats: Array<{ messages: unknown[] }>;
};
expect(persisted.chats[0].messages).toHaveLength(2);
});
});
+8
View File
@@ -182,6 +182,14 @@ describe('ConversationsPage', () => {
'src',
'/media/received%20image.jpg',
);
expect(screen.getByRole('link', { name: 'View original image received from u/alice' })).toHaveAttribute(
'href',
'/media/received%20image.jpg',
);
expect(screen.getByRole('link', { name: 'View original image received from u/alice' })).toHaveAttribute(
'target',
'_blank',
);
});
it('turns a suggestion into an editable draft and confirms sending without losing failed input', async () => {
+33
View File
@@ -34,4 +34,37 @@ describe('LogBroadcaster realtime bridge', () => {
expect.objectContaining({ timestamp: occurredAt.getTime() }),
]);
});
it('sanitizes circular network errors before buffering or publishing them', () => {
const hub = new RealtimeEventHub({ capacity: 10 });
const received: RealtimeEvent[] = [];
hub.subscribe((event) => received.push(event));
const broadcaster = new LogBroadcaster(hub);
const socket: Record<string, unknown> = { constructorName: 'TLSSocket' };
const parser: Record<string, unknown> = { socket };
socket.parser = parser;
const networkError = Object.assign(new Error('upstream request failed'), {
code: 'ECONNRESET',
request: socket,
});
let serializedRecent = '';
broadcaster.push('error', 'Matrix request failed', {
error: networkError,
socket,
authorization: 'Bearer must-not-leak',
});
broadcaster.recent(
{ query: {} } as never,
{ json: (value: unknown) => { serializedRecent = JSON.stringify(value); } } as never,
);
expect(() => JSON.stringify(received)).not.toThrow();
expect(() => JSON.parse(serializedRecent)).not.toThrow();
expect(serializedRecent).toContain('ECONNRESET');
expect(serializedRecent).toContain('[Circular]');
expect(serializedRecent).toContain('[Redacted]');
expect(serializedRecent).not.toContain('must-not-leak');
expect(serializedRecent).not.toContain('"request"');
});
});
+59
View File
@@ -93,6 +93,65 @@ describe('MatrixClient HTTP timeout', () => {
}, 10_000);
});
describe('MatrixClient direct room registration', () => {
it('creates rooms with the direct-message flag', async () => {
let requestBody: unknown;
const client = new MatrixClient(creds, createMockLogger(), {
minRequestIntervalMs: 0,
fetchImpl: vi.fn(async (_url, init) => {
requestBody = JSON.parse(String(init?.body));
return new Response(JSON.stringify({ room_id: '!new:example.test' }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}),
});
await expect(client.createDirectRoom('@them:example.test'))
.resolves.toBe('!new:example.test');
expect(requestBody).toEqual({
preset: 'reddit_dm',
invite: ['@them:example.test'],
is_direct: true,
});
});
it('additively repairs m.direct account data without removing existing rooms', async () => {
const requests: Array<{ method: string; url: string; body?: unknown }> = [];
const client = new MatrixClient(creds, createMockLogger(), {
minRequestIntervalMs: 0,
fetchImpl: vi.fn(async (url, init) => {
requests.push({
method: init?.method ?? 'GET',
url: String(url),
...(init?.body ? { body: JSON.parse(String(init.body)) } : {}),
});
if (init?.method === 'GET') {
return new Response(JSON.stringify({
'@existing:example.test': ['!old:example.test'],
'@them:example.test': ['!already:example.test'],
}), { status: 200, headers: { 'content-type': 'application/json' } });
}
return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } });
}),
});
await expect(client.repairDirectRoomMappings([
{ recipientMxid: '@them:example.test', roomId: '!already:example.test' },
{ recipientMxid: '@them:example.test', roomId: '!new:example.test' },
])).resolves.toBe(1);
expect(requests).toHaveLength(2);
expect(requests[1]).toMatchObject({
method: 'PUT',
body: {
'@existing:example.test': ['!old:example.test'],
'@them:example.test': ['!already:example.test', '!new:example.test'],
},
});
});
});
describe('MatrixClient authentication classification', () => {
it('does not classify a room-level 403 as an expired account token', async () => {
const client = new MatrixClient(creds, createMockLogger(), {
+59
View File
@@ -105,6 +105,31 @@ describe('MonitorService', () => {
expect(messagesRepo.hasSentTo).toHaveBeenCalledWith('already_msgd');
});
it('records seller posts as processed and continues to a clean candidate', async () => {
const sellerPost: RedditPost = {
id: 't3_seller', subreddit: 'sub1', author: 'seller', title: 'My page',
selftext: 'OnlyFans in bio', url: 'http://example.com/1.jpg', permalink: '/r/sub1/1', thumbnail: '',
is_self: false, created_utc: 2, over_18: true, spoiler: false,
};
const cleanPost: RedditPost = {
id: 't3_clean', subreddit: 'sub1', author: 'clean', title: 'Hello',
url: 'http://example.com/2.jpg', permalink: '/r/sub1/2', thumbnail: '',
is_self: false, created_utc: 1, over_18: true, spoiler: false,
};
const redditApi = createMockRedditApi([sellerPost, cleanPost]);
vi.mocked(redditApi.isSellerProfile).mockImplementation(async (username) => username === 'seller');
const postsRepo = createMockPostsRepo();
const config = getTestConfig({ reddit: { subreddits: [{ name: 'sub1', type: 'image' }] } });
monitor = new MonitorService(redditApi, postsRepo, createMockMessagesRepo(), config, logger, 0);
await expect(monitor.getUnprocessedPosts()).resolves.toEqual([cleanPost]);
expect(redditApi.isSellerProfile).toHaveBeenCalledWith(
'seller', config.reddit.sellerKeywords, config.reddit.sellerDomains, sellerPost,
);
expect(postsRepo.insert).toHaveBeenCalledWith(expect.objectContaining({ id: 't3_seller' }));
expect(postsRepo.setError).toHaveBeenCalledWith('t3_seller', 'Seller signals detected');
});
it('returns newest post when multiple valid posts exist', async () => {
const post1: RedditPost = {
id: 't3_1', subreddit: 'sub1', author: 'user1', title: 'Older',
@@ -127,6 +152,40 @@ describe('MonitorService', () => {
expect(result.length).toBe(1);
expect(result[0].id).toBe('t3_2');
expect(redditApi.isSellerProfile).toHaveBeenCalledTimes(1);
expect(redditApi.isSellerProfile).toHaveBeenCalledWith(
'user2', config.reddit.sellerKeywords, config.reddit.sellerDomains, post2,
);
});
it('finishes the seller and gender checks for one candidate before moving to the next', async () => {
const newer: RedditPost = {
id: 't3_newer', subreddit: 'chat-sub', author: 'newer', title: '[30M] hello',
url: '', permalink: '/r/chat-sub/newer', thumbnail: '', is_self: true,
created_utc: 2, over_18: false, spoiler: false,
};
const eligible: RedditPost = {
id: 't3_eligible', subreddit: 'chat-sub', author: 'eligible', title: '[25F] hello',
url: '', permalink: '/r/chat-sub/eligible', thumbnail: '', is_self: true,
created_utc: 1, over_18: false, spoiler: false,
};
const redditApi = createMockRedditApi([eligible, newer]);
const config = getTestConfig({
reddit: {
subreddits: [{ name: 'chat-sub', type: 'chat' }],
targetGenders: ['female'],
},
});
const postsRepo = createMockPostsRepo();
monitor = new MonitorService(
redditApi, postsRepo, createMockMessagesRepo(), config, logger, 0,
);
await expect(monitor.getUnprocessedPosts()).resolves.toEqual([eligible]);
expect(vi.mocked(redditApi.isSellerProfile).mock.calls.map(([username]) => username))
.toEqual(['newer', 'eligible']);
expect(postsRepo.insert).toHaveBeenCalledWith(expect.objectContaining({ id: 't3_newer' }));
expect(postsRepo.setError).toHaveBeenCalledWith('t3_newer', 'Gender tag "male" not targeted');
});
it('tries next subreddit when first has no valid posts', async () => {
+44
View File
@@ -242,6 +242,38 @@ describe('RedditApiClient', () => {
});
describe('isSellerProfile', () => {
it('returns true for a seller signal in the current post without fetching the profile', async () => {
const get = vi.fn();
vi.mocked(axios.create).mockReturnValue({ get } as any);
const client2 = new RedditApiClient(getTestConfig(), logger);
const post = {
id: 'post-1', name: 't3_post-1', subreddit: 'test', author: 'testuser',
title: 'Come and subscribe', selftext: 'My page is at https://onlyfans.com/example',
url: 'https://reddit.com/post-1', permalink: '/r/test/post-1', thumbnail: '',
is_self: true, created_utc: 1, over_18: true, spoiler: false,
};
await expect(client2.isSellerProfile('testuser', ['onlyfans'], ['onlyfans.com'], post))
.resolves.toBe(true);
expect(get).not.toHaveBeenCalled();
});
it('detects seller signals in recent profile activity when the bio is clean', async () => {
const get = vi.fn()
.mockResolvedValueOnce({ data: { data: { subreddit: { public_description: 'Just me' } } } })
.mockResolvedValueOnce({
data: { data: { children: [{ data: { title: 'New content', subreddit: 'OnlyFansPromotions' } }] } },
});
vi.mocked(axios.create).mockReturnValue({ get } as any);
const client2 = new RedditApiClient(getTestConfig(), logger);
await expect(client2.isSellerProfile('testuser', ['onlyfans'])).resolves.toBe(true);
expect(get).toHaveBeenNthCalledWith(2, '/user/testuser/overview.json', {
params: { limit: 25, raw_json: 1 },
headers: {},
});
});
it('returns true when seller keyword matches', async () => {
const mockResponse = {
data: {
@@ -332,6 +364,18 @@ describe('RedditApiClient', () => {
expect(result).toBe(false);
});
it('propagates profile rate limits so the main loop backs off', async () => {
const error = {
isAxiosError: true,
response: { status: 429, headers: { 'retry-after': '42' } },
};
vi.mocked(axios.create).mockReturnValue({ get: vi.fn().mockRejectedValue(error) } as any);
const client2 = new RedditApiClient(getTestConfig(), logger);
await expect(client2.isSellerProfile('testuser', ['onlyfans']))
.rejects.toMatchObject({ retryAfterMs: 42_000 });
});
it('returns true when a domain in the bio matches sellerDomains', async () => {
const mockResponse = {
data: {
+20 -1
View File
@@ -1,5 +1,8 @@
import { describe, expect, it } from 'vitest';
import { createSessionExportDownload } from '../extensions/reddit-session-exporter/session-export.js';
import {
assertMatchingSessionIdentity,
createSessionExportDownload,
} from '../extensions/reddit-session-exporter/session-export.js';
describe('createSessionExportDownload', () => {
it('creates a JSON data URL that Chrome can download from a service worker', () => {
@@ -49,3 +52,19 @@ describe('createSessionExportDownload', () => {
expect(() => createSessionExportDownload([], null)).toThrow(/Reddit Chat/i);
});
});
describe('assertMatchingSessionIdentity', () => {
it('accepts a Matrix user bound to the current Reddit account ID', () => {
expect(() => assertMatchingSessionIdentity(
{ username: 'bot-account', accountId: 'bot' },
'@t2_bot:reddit.com',
)).not.toThrow();
});
it('rejects a stale Matrix capture from another Reddit account', () => {
expect(() => assertMatchingSessionIdentity(
{ username: 'bot-account', accountId: 't2_bot' },
'@t2_other:reddit.com',
)).toThrow(/Reload Reddit Chat after switching accounts/);
});
});
+25 -5
View File
@@ -18,12 +18,12 @@ describe('RedditSessionImporter', () => {
const browser = {
importRedditCookies: vi.fn(),
isSessionValid: vi.fn(async () => true),
getAuthenticatedUsername: vi.fn(async () => 'bot-account'),
getAuthenticatedIdentity: vi.fn(async () => ({ username: 'bot-account', accountId: 't2_bot' })),
};
const matrix = { whoami: vi.fn(async () => credentials.userId) };
const chat = { setMatrixClient: vi.fn() };
const save = vi.fn();
const accountAuth = { markAuthenticated: vi.fn() };
const accountAuth = { markAuthenticated: vi.fn(), invalidate: vi.fn() };
const importer = new RedditSessionImporter(browser, chat as never, vi.fn(() => matrix as never), save, accountAuth as never);
await importer.import(cookies, credentials);
@@ -33,20 +33,40 @@ describe('RedditSessionImporter', () => {
expect(chat.setMatrixClient).toHaveBeenCalledWith(matrix);
expect(save).toHaveBeenCalledWith(credentials);
expect(accountAuth.markAuthenticated).toHaveBeenCalledWith({
accountId: credentials.userId,
accountId: 't2_bot',
redditUsername: 'bot-account',
matrixUserId: credentials.userId,
});
});
it('rejects a dump whose Reddit cookies and Matrix token belong to different accounts', async () => {
const browser = {
importRedditCookies: vi.fn(),
isSessionValid: vi.fn(async () => true),
getAuthenticatedIdentity: vi.fn(async () => ({ username: 'other-account', accountId: 't2_other' })),
};
const matrix = { whoami: vi.fn(async () => credentials.userId) };
const chat = { setMatrixClient: vi.fn() };
const save = vi.fn();
const accountAuth = { markAuthenticated: vi.fn(), invalidate: vi.fn() };
const importer = new RedditSessionImporter(browser, chat as never, vi.fn(() => matrix as never), save, accountAuth as never);
await expect(importer.import(cookies, credentials)).rejects.toThrow(/does not match Matrix account/);
expect(accountAuth.invalidate).toHaveBeenCalledWith(expect.stringMatching(/does not match Matrix account/));
expect(save).not.toHaveBeenCalled();
expect(chat.setMatrixClient).not.toHaveBeenCalled();
expect(accountAuth.markAuthenticated).not.toHaveBeenCalled();
});
it('rejects non-Reddit Matrix homeservers without making a request', async () => {
const createMatrix = vi.fn();
const importer = new RedditSessionImporter(
{ importRedditCookies: vi.fn(), isSessionValid: vi.fn(), getAuthenticatedUsername: vi.fn() },
{ importRedditCookies: vi.fn(), isSessionValid: vi.fn(), getAuthenticatedIdentity: vi.fn() },
{ setMatrixClient: vi.fn() } as never,
createMatrix,
vi.fn(),
{ markAuthenticated: vi.fn() } as never,
{ markAuthenticated: vi.fn(), invalidate: vi.fn() } as never,
);
await expect(importer.import(cookies, { ...credentials, homeserver: 'https://attacker.example' }))
+44
View File
@@ -300,4 +300,48 @@ describe('conversations route', () => {
});
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 });
});
});