UNPKG

chat

Version:

Unified chat abstraction for Slack, Teams, Google Chat, and Discord

499 lines (401 loc) 14.8 kB
--- title: Testing adapters description: Write unit tests, integration tests, and replay tests for community Chat SDK adapters. type: guide prerequisites: - /docs/contributing/building related: - /docs/adapters --- ## Testing philosophy Chat SDK adapters are the trust boundary between your application and a platform. High test coverage is expected: unit tests for every public method, and integration tests that wire up the full `Chat` → `Adapter` → handler pipeline. All adapters in this repo use [vitest](https://vitest.dev) with `@vitest/coverage-v8`. Community adapters should follow the same convention. <Callout type="info"> This page covers the hand-rolled patterns used inside this repo's `packages/`. If you're testing a bot or a custom adapter as a **consumer** of Chat SDK, use [`@chat-adapter/tests`](/docs/testing) — it ships factories and Vitest matchers that cover most of these patterns in a few lines. </Callout> ## Unit tests ### Factory function Verify that the factory validates config, reads environment variables, and sets defaults. ```typescript title="src/factory.test.ts" lineNumbers import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { createMatrixAdapter } from "./factory"; describe("createMatrixAdapter", () => { const originalEnv = process.env; beforeEach(() => { process.env = { ...originalEnv }; }); afterEach(() => { process.env = originalEnv; }); it("creates adapter from explicit config", () => { const adapter = createMatrixAdapter({ homeserverUrl: "https://matrix.example.com", accessToken: "syt_test_token", }); expect(adapter.name).toBe("matrix"); }); it("reads environment variables as fallback", () => { process.env.MATRIX_HOMESERVER_URL = "https://matrix.example.com"; process.env.MATRIX_ACCESS_TOKEN = "syt_test_token"; const adapter = createMatrixAdapter(); expect(adapter.name).toBe("matrix"); }); it("throws when homeserver URL is missing", () => { expect(() => createMatrixAdapter({ accessToken: "tok" } as never)) .toThrow("homeserver URL"); }); it("throws when access token is missing", () => { expect(() => createMatrixAdapter({ homeserverUrl: "https://matrix.example.com", } as never) ).toThrow("access token"); }); }); ``` ### Thread ID encode/decode Verify that `encodeThreadId` and `decodeThreadId` roundtrip consistently and reject invalid formats. ```typescript title="src/thread-id.test.ts" lineNumbers import { describe, it, expect } from "vitest"; import { MatrixAdapter } from "./adapter"; const adapter = new MatrixAdapter({ homeserverUrl: "https://matrix.example.com", accessToken: "syt_test_token", }); describe("thread ID encoding", () => { it("roundtrips room-only thread ID", () => { const data = { roomId: "!abc123:matrix.org" }; const encoded = adapter.encodeThreadId(data); const decoded = adapter.decodeThreadId(encoded); expect(decoded.roomId).toBe(data.roomId); expect(encoded).toMatch(/^matrix:/); }); it("roundtrips thread ID with event", () => { const data = { roomId: "!abc123:matrix.org", eventId: "$event456", }; const encoded = adapter.encodeThreadId(data); const decoded = adapter.decodeThreadId(encoded); expect(decoded.roomId).toBe(data.roomId); expect(decoded.eventId).toBe(data.eventId); }); it("throws on invalid format", () => { expect(() => adapter.decodeThreadId("invalid")).toThrow(); expect(() => adapter.decodeThreadId("slack:C123:ts")).toThrow(); }); }); ``` ### Webhook signature verification Test the three key scenarios: missing headers, invalid signature, and valid signature. ```typescript title="src/webhook.test.ts" lineNumbers import { describe, it, expect } from "vitest"; import { MatrixAdapter } from "./adapter"; const adapter = new MatrixAdapter({ homeserverUrl: "https://matrix.example.com", accessToken: "syt_test_token", }); describe("handleWebhook", () => { it("returns 401 when signature header is missing", async () => { const request = new Request("https://example.com/webhook", { method: "POST", body: "{}", }); const response = await adapter.handleWebhook(request); expect(response.status).toBe(401); }); it("returns 401 when signature is invalid", async () => { const request = new Request("https://example.com/webhook", { method: "POST", headers: { "x-matrix-signature": "invalid" }, body: "{}", }); const response = await adapter.handleWebhook(request); expect(response.status).toBe(401); }); it("returns 200 for valid signed request", async () => { const body = JSON.stringify({ type: "m.room.message" }); const request = createSignedRequest(body); // Your signing helper const response = await adapter.handleWebhook(request); expect(response.status).toBe(200); }); }); ``` ### Message parsing Cover the main message types: plain text, bot messages, DMs, edited messages, attachments, and formatted text. ```typescript title="src/parse-message.test.ts" lineNumbers import { describe, it, expect } from "vitest"; import { MatrixAdapter } from "./adapter"; const adapter = new MatrixAdapter({ homeserverUrl: "https://matrix.example.com", accessToken: "syt_test_token", }); describe("parseMessage", () => { it("parses a plain text message", () => { const raw = { event_id: "$evt1", room_id: "!room1:matrix.org", body: "Hello world", sender: "@alice:matrix.org", sender_display_name: "Alice", origin_server_ts: 1700000000000, }; const message = adapter.parseMessage(raw); expect(message.text).toBe("Hello world"); expect(message.author.userId).toBe("@alice:matrix.org"); expect(message.author.isBot).toBe(false); }); it("detects bot messages", () => { const raw = { event_id: "$evt2", room_id: "!room1:matrix.org", body: "Automated response", sender: "@bot:matrix.org", origin_server_ts: 1700000000000, }; const message = adapter.parseMessage(raw); expect(message.author.isBot).toBe(true); }); }); ``` ### Format converter Test `toAst()` and `fromAst()` for each node type your platform supports, plus `renderPostable()` for all message variants. ```typescript title="src/format-converter.test.ts" lineNumbers import { describe, it, expect } from "vitest"; import { MatrixFormatConverter } from "./format-converter"; const converter = new MatrixFormatConverter(); describe("MatrixFormatConverter", () => { describe("toAst", () => { it("parses plain text", () => { const ast = converter.toAst("Hello world"); expect(ast.type).toBe("root"); }); it("parses bold text", () => { const ast = converter.toAst("**bold**"); // Verify the AST contains a strong node const paragraph = ast.children[0]; expect(paragraph.children[0].type).toBe("strong"); }); }); describe("fromAst", () => { it("renders bold text", () => { const ast = converter.toAst("**bold**"); const result = converter.fromAst(ast); expect(result).toContain("**bold**"); }); }); describe("renderPostable", () => { it("renders plain text", () => { const result = converter.renderPostable({ text: "Hello" }); expect(result).toBe("Hello"); }); it("renders card fallback", () => { const result = converter.renderPostable({ card: { type: "card", title: "Test Card", children: [], }, }); expect(result).toContain("Test Card"); }); }); }); ``` ## Integration testing Integration tests wire your adapter into a full `Chat` instance and verify end-to-end message handling. ### Test context factory Create a factory that sets up the full test environment: ```typescript title="src/test-utils.ts" lineNumbers import { Chat, type Message, type Thread, type ActionEvent, type ReactionEvent } from "chat"; import { createMemoryState } from "@chat-adapter/state-memory"; import { createMatrixAdapter } from "./factory"; interface CapturedMessages { mentionMessage: Message | null; mentionThread: Thread | null; followUpMessage: Message | null; followUpThread: Thread | null; } interface WaitUntilTracker { waitUntil: (task: Promise<unknown>) => void; waitForAll: () => Promise<void>; } function createWaitUntilTracker(): WaitUntilTracker { const tasks: Promise<unknown>[] = []; return { waitUntil: (task) => { tasks.push(task); }, waitForAll: async () => { await Promise.all(tasks); tasks.length = 0; }, }; } export function createMatrixTestContext(handlers: { onMention?: (thread: Thread, message: Message) => void | Promise<void>; onSubscribed?: (thread: Thread, message: Message) => void | Promise<void>; onAction?: (event: ActionEvent) => void | Promise<void>; onReaction?: (event: ReactionEvent) => void | Promise<void>; }) { const adapter = createMatrixAdapter({ homeserverUrl: "https://matrix.example.com", accessToken: "syt_test_token", }); const state = createMemoryState(); const chat = new Chat({ userName: "matrix-bot", adapters: { matrix: adapter }, state, logger: "error", }); const captured: CapturedMessages = { mentionMessage: null, mentionThread: null, followUpMessage: null, followUpThread: null, }; if (handlers.onMention) { const handler = handlers.onMention; chat.onNewMention(async (thread, message) => { captured.mentionMessage = message; captured.mentionThread = thread; await handler(thread, message); }); } if (handlers.onSubscribed) { const handler = handlers.onSubscribed; chat.onSubscribedMessage(async (thread, message) => { captured.followUpMessage = message; captured.followUpThread = thread; await handler(thread, message); }); } if (handlers.onAction) { chat.onAction(handlers.onAction); } if (handlers.onReaction) { chat.onReaction(handlers.onReaction); } const tracker = createWaitUntilTracker(); return { chat, adapter, state, tracker, captured, sendWebhook: async (fixture: unknown) => { const request = createSignedMatrixRequest(fixture); // Your signing helper await chat.webhooks.matrix(request, { waitUntil: tracker.waitUntil, }); await tracker.waitForAll(); }, }; } function createSignedMatrixRequest(payload: unknown): Request { const body = JSON.stringify(payload); // Add platform-specific signature headers return new Request("https://example.com/webhook/matrix", { method: "POST", headers: { "Content-Type": "application/json", "x-matrix-signature": computeSignature(body), }, body, }); } function computeSignature(body: string): string { // Platform-specific signature computation return "valid-signature"; } ``` ### Writing integration tests Use the test context to verify the full message flow: ```typescript title="src/integration.test.ts" lineNumbers import { describe, it, expect } from "vitest"; import { createMatrixTestContext } from "./test-utils"; describe("Matrix adapter integration", () => { it("handles mention → subscribe → follow-up flow", async () => { const ctx = createMatrixTestContext({ onMention: async (thread) => { await thread.subscribe(); await thread.post("Got it!"); }, onSubscribed: async (thread, message) => { await thread.post(`Echo: ${message.text}`); }, }); // Send a mention await ctx.sendWebhook({ type: "m.room.message", room_id: "!room1:matrix.org", event_id: "$evt1", body: "@bot hello", sender: "@alice:matrix.org", origin_server_ts: Date.now(), }); expect(ctx.captured.mentionMessage).not.toBeNull(); expect(ctx.captured.mentionMessage?.text).toBe("@bot hello"); // Send a follow-up in the same thread await ctx.sendWebhook({ type: "m.room.message", room_id: "!room1:matrix.org", thread_root_id: "$evt1", event_id: "$evt2", body: "follow up", sender: "@alice:matrix.org", origin_server_ts: Date.now(), }); expect(ctx.captured.followUpMessage).not.toBeNull(); expect(ctx.captured.followUpMessage?.text).toBe("follow up"); }); }); ``` ## What to test end-to-end Cover these flows in your integration tests: | Flow | What to verify | |------|----------------| | **Mention** | Bot detects @mention, handler fires, `mentionMessage` is captured | | **Subscribe + follow-up** | After `thread.subscribe()`, subsequent messages trigger `onSubscribedMessage` | | **Actions** | Button clicks fire `onAction` with correct action ID and user info | | **Reactions** | Emoji reactions fire `onReaction` with correct emoji and message ID | | **Self-message filtering** | Messages from the bot itself are ignored | | **DM flow** | Direct messages are detected and routed correctly | ## Recording and replay tests (advanced) For production debugging, Chat SDK supports recording webhook interactions and replaying them as test fixtures. 1. **Enable recording** — Set `RECORDING_ENABLED=true` in your deployed environment. Recordings are tagged with the current git SHA. 2. **Interact with the bot** — Send messages, click buttons, add reactions — each interaction is recorded. 3. **Export recordings** ```sh title="Terminal" pnpm recording:list pnpm recording:export session-<id> ``` 4. **Create test fixtures** — Extract webhook payloads from the exported recording and save them as JSON fixtures: ```json title="fixtures/replay/matrix-mention.json" { "botName": "matrix-bot", "botUserId": "@bot:matrix.org", "mention": { "type": "m.room.message", "body": "@bot help", "..." : "..." }, "followUp": { "type": "m.room.message", "body": "thanks", "..." : "..." } } ``` 5. **Write replay tests** — Use the fixtures in your test context: ```typescript title="src/replay.test.ts" lineNumbers import { describe, it, expect } from "vitest"; import fixture from "../fixtures/replay/matrix-mention.json"; import { createMatrixTestContext } from "./test-utils"; describe("replay: mention flow", () => { it("handles recorded mention interaction", async () => { const ctx = createMatrixTestContext({ onMention: async (thread) => { await thread.subscribe(); }, }); await ctx.sendWebhook(fixture.mention); expect(ctx.captured.mentionMessage).not.toBeNull(); await ctx.sendWebhook(fixture.followUp); expect(ctx.captured.followUpMessage).not.toBeNull(); }); }); ```