Files
Sauci/apps/api/test/chat.routes.test.ts
T
jjbrunton c7ee8a4851 perf(app): reduce redundant data fetching
Share bounded database pools across API repositories, consolidate sync and chat queries, and make mobile refreshes cache-aware and foreground-aware.

Add focused regression coverage, client freshness documentation, and repository agent role configuration.
2026-08-29 21:47:27 +01:00

81 lines
4.9 KiB
TypeScript

import { Hono } from 'hono';
import { describe, expect, it, vi } from 'vitest';
import type { AuthIdentity } from '../src/auth.js';
import type { ChatRepository } from '../src/domains/chat/repository.js';
import { registerChatRoutes } from '../src/domains/chat/routes.js';
const identity: AuthIdentity = { id: '11111111-1111-4111-8111-111111111111', email: null, name: null, avatarUrl: null };
const matchId = '22222222-2222-4222-8222-222222222222';
const messageId = '33333333-3333-4333-8333-333333333333';
const message = { id: messageId, match_id: matchId, user_id: identity.id, content: 'hello', created_at: new Date().toISOString(), read_at: null, delivered_at: null, deleted_at: null, media_path: null, media_type: null, media_expires_at: null, media_expired: false, media_viewed_at: null, version: 1, encrypted_content: null, encryption_iv: null, keys_metadata: null, moderation_status: null, flag_reason: null, category: null };
function setup() {
const repository: ChatRepository = {
listMessages: vi.fn(async () => ({ messages: [message], removed_ids: [], server_time: new Date().toISOString(), complete: true })),
sendText: vi.fn(async () => message),
unreadCounts: vi.fn(async () => ({ total: 1, by_match: { [matchId]: 1 } })),
markMatchRead: vi.fn(async () => ({ updated: 1, read_at: new Date().toISOString() })),
markDelivered: vi.fn(async () => message), deleteForSelf: vi.fn(async () => undefined),
deleteForEveryone: vi.fn(async () => ({ ...message, deleted_at: new Date().toISOString() })),
report: vi.fn(async () => undefined), setTyping: vi.fn(async () => undefined),
getPartnerTyping: vi.fn(async () => ({ typing: true, expires_at: new Date().toISOString() })),
close: vi.fn(async () => undefined),
};
const app = new Hono<{ Variables: { identity: AuthIdentity } }>();
app.use('/v1/*', async (c, next) => { c.set('identity', identity); await next(); });
registerChatRoutes(app, repository);
return { app, repository };
}
describe('chat routes', () => {
it('derives sender and read ownership from the bearer identity', async () => {
const { app, repository } = setup();
const sent = await app.request(`/v1/matches/${matchId}/messages`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ content: ' hello ' }) });
expect(sent.status).toBe(201);
expect(repository.sendText).toHaveBeenCalledWith(identity.id, matchId, 'hello');
await app.request(`/v1/matches/${matchId}/read`, { method: 'POST' });
expect(repository.markMatchRead).toHaveBeenCalledWith(identity.id, matchId);
});
it('rejects client-supplied ownership fields', async () => {
const { app, repository } = setup();
const response = await app.request(`/v1/matches/${matchId}/messages`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ content: 'hello', user_id: 'attacker' }) });
expect(response.status).toBe(400);
expect(repository.sendText).not.toHaveBeenCalled();
});
it('passes a delta cursor and folded typing check through to the repository', async () => {
const { app, repository } = setup();
const since = '2026-08-28T10:00:00.000Z';
const delta = await app.request(`/v1/matches/${matchId}/messages?since=${encodeURIComponent(since)}&typing=true`);
expect(delta.status).toBe(200);
expect(repository.listMessages).toHaveBeenCalledWith(identity.id, matchId, { since, includeTyping: true });
// Omitting both keeps the full-snapshot contract the client had before.
const snapshot = await app.request(`/v1/matches/${matchId}/messages`);
expect(snapshot.status).toBe(200);
expect(await snapshot.json()).toMatchObject({ messages: [{ id: messageId }] });
expect(repository.listMessages).toHaveBeenLastCalledWith(identity.id, matchId, { includeTyping: false });
});
it('refuses a malformed cursor rather than silently reading everything', async () => {
const { app, repository } = setup();
const response = await app.request(`/v1/matches/${matchId}/messages?since=yesterday`);
expect(response.status).toBe(400);
expect(await response.json()).toMatchObject({ error: { code: 'invalid_since' } });
expect(repository.listMessages).not.toHaveBeenCalled();
});
it('supports unread, typing, deletion and reporting actions', async () => {
const { app, repository } = setup();
expect((await app.request('/v1/chat/unread')).status).toBe(200);
await app.request(`/v1/matches/${matchId}/typing`, { method: 'PUT' });
await app.request(`/v1/messages/${messageId}?scope=self`, { method: 'DELETE' });
await app.request(`/v1/messages/${messageId}/reports`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ reason: 'spam' }) });
expect(repository.setTyping).toHaveBeenCalledWith(identity.id, matchId, 4_000);
expect(repository.deleteForSelf).toHaveBeenCalledWith(identity.id, messageId);
expect(repository.report).toHaveBeenCalledWith(identity.id, messageId, 'spam');
});
});