import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { MatrixAdapter, type MatrixAdapterConfig } from './adapter.js'; import type { InboundMessage } from '../types.js'; const mockFetch = vi.fn(); function jsonResponse(body: unknown, status = 200): Response { return { ok: status >= 200 && status < 300, status, json: async () => body, text: async () => JSON.stringify(body), } as Response; } describe('MatrixAdapter', () => { const baseConfig: MatrixAdapterConfig = { homeserverUrl: 'https://matrix.example.org', accessToken: 'syt_test_token', allowedRoomIds: ['!room1:example.org'], requireMention: true, syncTimeoutMs: 30_000, displayName: 'Flynn', }; let adapter: MatrixAdapter; beforeEach(() => { vi.stubGlobal('fetch', mockFetch); vi.clearAllMocks(); adapter = new MatrixAdapter(baseConfig); }); afterEach(async () => { await adapter.disconnect(); vi.restoreAllMocks(); }); it('has name "matrix"', () => { expect(adapter.name).toBe('matrix'); }); it('starts as disconnected', () => { expect(adapter.status).toBe('disconnected'); }); it('connect resolves whoami and sets connected', async () => { mockFetch.mockImplementation(async (url: string) => { if (url.endsWith('/_matrix/client/v3/account/whoami')) { return jsonResponse({ user_id: '@flynn:example.org' }); } if (url.includes('/account_data/m.direct')) { return jsonResponse({}); } // /sync long-poll hangs if (url.includes('/_matrix/client/v3/sync')) { return new Promise(() => {}); } throw new Error(`Unexpected fetch URL: ${url}`); }); await adapter.connect(); expect(adapter.status).toBe('connected'); expect(mockFetch).toHaveBeenCalledWith( 'https://matrix.example.org/_matrix/client/v3/account/whoami', expect.objectContaining({ method: 'GET', headers: expect.objectContaining({ Authorization: 'Bearer syt_test_token', }), }), ); }); it('disconnect is safe when not connected', async () => { await adapter.disconnect(); expect(adapter.status).toBe('disconnected'); }); it('send throws when not connected', async () => { await expect(adapter.send('!room1:example.org', { text: 'hello' })).rejects.toThrow( 'Matrix adapter not connected', ); }); it('send delivers a message via PUT', async () => { let syncStarted = false; mockFetch.mockImplementation(async (url: string, init?: any) => { if (url.endsWith('/_matrix/client/v3/account/whoami')) { return jsonResponse({ user_id: '@flynn:example.org' }); } if (url.includes('/account_data/m.direct')) { return jsonResponse({}); } if (url.includes('/_matrix/client/v3/sync')) { syncStarted = true; return new Promise(() => {}); } if (init?.method === 'PUT' && url.includes('/send/m.room.message/')) { const body = JSON.parse(init.body); expect(body.msgtype).toBe('m.text'); expect(body.body).toBe('Hello there'); return jsonResponse({ event_id: '$sent1' }); } throw new Error(`Unexpected fetch URL: ${url}`); }); await adapter.connect(); expect(syncStarted).toBe(true); await adapter.send('!room1:example.org', { text: 'Hello there' }); }); it('inbound message requires mention in non-DM rooms', async () => { const handler = vi.fn(); adapter.onMessage(handler); let didSync = false; mockFetch.mockImplementation(async (url: string) => { if (url.endsWith('/_matrix/client/v3/account/whoami')) { return jsonResponse({ user_id: '@flynn:example.org' }); } if (url.includes('/account_data/m.direct')) { return jsonResponse({}); } if (url.includes('/_matrix/client/v3/sync')) { if (didSync) { return new Promise(() => {}); } didSync = true; return jsonResponse({ next_batch: 's1', rooms: { join: { '!room1:example.org': { timeline: { events: [ { type: 'm.room.message', event_id: '$e1', sender: '@alice:example.org', origin_server_ts: 1700000000000, content: { msgtype: 'm.text', body: 'hello without mention' }, }, ], }, }, }, }, }); } throw new Error(`Unexpected fetch URL: ${url}`); }); await adapter.connect(); await new Promise((r) => setTimeout(r, 0)); expect(handler).not.toHaveBeenCalled(); }); it('DM rooms bypass mention requirement (m.direct from sync)', async () => { const handler = vi.fn(); adapter.onMessage(handler); let didSync = false; mockFetch.mockImplementation(async (url: string) => { if (url.endsWith('/_matrix/client/v3/account/whoami')) { return jsonResponse({ user_id: '@flynn:example.org' }); } if (url.includes('/account_data/m.direct')) { return jsonResponse({}); } if (url.includes('/_matrix/client/v3/sync')) { if (didSync) { return new Promise(() => {}); } didSync = true; return jsonResponse({ next_batch: 's1', account_data: { events: [ { type: 'm.direct', content: { '@alice:example.org': ['!room1:example.org'] } }, ], }, rooms: { join: { '!room1:example.org': { timeline: { events: [ { type: 'm.room.message', event_id: '$e1', sender: '@alice:example.org', origin_server_ts: 1700000000000, content: { msgtype: 'm.text', body: 'hello dm no mention' }, }, ], }, }, }, }, }); } throw new Error(`Unexpected fetch URL: ${url}`); }); await adapter.connect(); await new Promise((r) => setTimeout(r, 0)); expect(handler).toHaveBeenCalledTimes(1); const msg: InboundMessage = handler.mock.calls[0][0]; expect(msg.channel).toBe('matrix'); expect(msg.senderId).toBe('!room1:example.org'); expect(msg.senderName).toBe('alice'); expect(msg.text).toBe('hello dm no mention'); }); it('inbound message with mention is accepted and mention is stripped', async () => { const handler = vi.fn(); adapter.onMessage(handler); let didSync = false; mockFetch.mockImplementation(async (url: string) => { if (url.endsWith('/_matrix/client/v3/account/whoami')) { return jsonResponse({ user_id: '@flynn:example.org' }); } if (url.includes('/account_data/m.direct')) { return jsonResponse({}); } if (url.includes('/_matrix/client/v3/sync')) { if (didSync) { return new Promise(() => {}); } didSync = true; return jsonResponse({ next_batch: 's1', rooms: { join: { '!room1:example.org': { timeline: { events: [ { type: 'm.room.message', event_id: '$e1', sender: '@alice:example.org', origin_server_ts: 1700000000000, content: { msgtype: 'm.text', body: '@Flynn Hello there' }, }, ], }, }, }, }, }); } throw new Error(`Unexpected fetch URL: ${url}`); }); await adapter.connect(); await new Promise((r) => setTimeout(r, 0)); expect(handler).toHaveBeenCalledTimes(1); const msg: InboundMessage = handler.mock.calls[0][0]; expect(msg.text).toBe('Hello there'); }); });