Fix chat visibility and conversation sync
This commit is contained in:
1 parent
f4e8fdc42f
commit
34ae532cdd
10 files changed
+416
-23
No files matched your search
@@ -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',
|
||||
}]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -179,6 +179,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 () => {
|
||||
|
||||
@@ -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(), {
|
||||
|
||||
@@ -6,12 +6,16 @@ import { createConversationsRouter } from '../../src/web/routes/conversations.js
|
||||
|
||||
const servers: Array<ReturnType<express.Express['listen']>> = [];
|
||||
|
||||
async function request(app: express.Express, path: string): Promise<Response> {
|
||||
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}`);
|
||||
return fetch(`http://127.0.0.1:${port}${path}`, init);
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -177,4 +181,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 });
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user