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'); }); });