import { Test, TestingModule } from "@nestjs/testing"; import { MatrixService } from "./matrix.service"; import { MatrixRoomService } from "./matrix-room.service"; import { StitcherService } from "../../stitcher/stitcher.service"; import { CommandParserService } from "../parser/command-parser.service"; import { vi, describe, it, expect, beforeEach } from "vitest"; import type { ChatMessage } from "../interfaces"; // Mock matrix-bot-sdk const mockMessageCallbacks: Array<(roomId: string, event: Record) => void> = []; const mockEventCallbacks: Array<(roomId: string, event: Record) => void> = []; const mockClient = { start: vi.fn().mockResolvedValue(undefined), stop: vi.fn(), on: vi .fn() .mockImplementation( (event: string, callback: (roomId: string, evt: Record) => void) => { if (event === "room.message") { mockMessageCallbacks.push(callback); } if (event === "room.event") { mockEventCallbacks.push(callback); } } ), sendMessage: vi.fn().mockResolvedValue("$event-id-123"), sendEvent: vi.fn().mockResolvedValue("$event-id-456"), }; vi.mock("matrix-bot-sdk", () => { return { MatrixClient: class MockMatrixClient { start = mockClient.start; stop = mockClient.stop; on = mockClient.on; sendMessage = mockClient.sendMessage; sendEvent = mockClient.sendEvent; }, SimpleFsStorageProvider: class MockStorageProvider { constructor(_filename: string) { // No-op for testing } }, AutojoinRoomsMixin: { setupOnClient: vi.fn(), }, }; }); describe("MatrixService", () => { let service: MatrixService; let stitcherService: StitcherService; let commandParser: CommandParserService; let matrixRoomService: MatrixRoomService; const mockStitcherService = { dispatchJob: vi.fn().mockResolvedValue({ jobId: "test-job-id", queueName: "main", status: "PENDING", }), trackJobEvent: vi.fn().mockResolvedValue(undefined), }; const mockMatrixRoomService = { getWorkspaceForRoom: vi.fn().mockResolvedValue(null), getRoomForWorkspace: vi.fn().mockResolvedValue(null), provisionRoom: vi.fn().mockResolvedValue(null), linkWorkspaceToRoom: vi.fn().mockResolvedValue(undefined), unlinkWorkspace: vi.fn().mockResolvedValue(undefined), }; beforeEach(async () => { // Set environment variables for testing process.env.MATRIX_HOMESERVER_URL = "https://matrix.example.com"; process.env.MATRIX_ACCESS_TOKEN = "test-access-token"; process.env.MATRIX_BOT_USER_ID = "@mosaic-bot:example.com"; process.env.MATRIX_CONTROL_ROOM_ID = "!test-room:example.com"; process.env.MATRIX_WORKSPACE_ID = "test-workspace-id"; // Clear callbacks mockMessageCallbacks.length = 0; mockEventCallbacks.length = 0; const module: TestingModule = await Test.createTestingModule({ providers: [ MatrixService, CommandParserService, { provide: StitcherService, useValue: mockStitcherService, }, { provide: MatrixRoomService, useValue: mockMatrixRoomService, }, ], }).compile(); service = module.get(MatrixService); stitcherService = module.get(StitcherService); commandParser = module.get(CommandParserService); matrixRoomService = module.get(MatrixRoomService) as MatrixRoomService; // Clear all mocks vi.clearAllMocks(); }); describe("Connection Management", () => { it("should connect to Matrix", async () => { await service.connect(); expect(mockClient.start).toHaveBeenCalled(); }); it("should disconnect from Matrix", async () => { await service.connect(); await service.disconnect(); expect(mockClient.stop).toHaveBeenCalled(); }); it("should check connection status", async () => { expect(service.isConnected()).toBe(false); await service.connect(); expect(service.isConnected()).toBe(true); await service.disconnect(); expect(service.isConnected()).toBe(false); }); }); describe("Message Handling", () => { it("should send a message to a room", async () => { await service.connect(); await service.sendMessage("!test-room:example.com", "Hello, Matrix!"); expect(mockClient.sendMessage).toHaveBeenCalledWith("!test-room:example.com", { msgtype: "m.text", body: "Hello, Matrix!", }); }); it("should throw error if client is not connected", async () => { await expect(service.sendMessage("!room:example.com", "Test")).rejects.toThrow( "Matrix client is not connected" ); }); }); describe("Thread Management", () => { it("should create a thread by sending an initial message", async () => { await service.connect(); const threadId = await service.createThread({ channelId: "!test-room:example.com", name: "Job #42", message: "Starting job...", }); expect(threadId).toBe("$event-id-123"); expect(mockClient.sendMessage).toHaveBeenCalledWith("!test-room:example.com", { msgtype: "m.text", body: "[Job #42] Starting job...", }); }); it("should send a message to a thread with m.thread relation", async () => { await service.connect(); await service.sendThreadMessage({ threadId: "$root-event-id", channelId: "!test-room:example.com", content: "Step completed", }); expect(mockClient.sendMessage).toHaveBeenCalledWith("!test-room:example.com", { msgtype: "m.text", body: "Step completed", "m.relates_to": { rel_type: "m.thread", event_id: "$root-event-id", is_falling_back: true, "m.in_reply_to": { event_id: "$root-event-id", }, }, }); }); it("should fall back to controlRoomId when channelId is empty", async () => { await service.connect(); await service.sendThreadMessage({ threadId: "$root-event-id", channelId: "", content: "Fallback message", }); expect(mockClient.sendMessage).toHaveBeenCalledWith("!test-room:example.com", { msgtype: "m.text", body: "Fallback message", "m.relates_to": { rel_type: "m.thread", event_id: "$root-event-id", is_falling_back: true, "m.in_reply_to": { event_id: "$root-event-id", }, }, }); }); it("should throw error when creating thread without connection", async () => { await expect( service.createThread({ channelId: "!room:example.com", name: "Test", message: "Test", }) ).rejects.toThrow("Matrix client is not connected"); }); it("should throw error when sending thread message without connection", async () => { await expect( service.sendThreadMessage({ threadId: "$event-id", channelId: "!room:example.com", content: "Test", }) ).rejects.toThrow("Matrix client is not connected"); }); }); describe("Command Parsing with shared CommandParserService", () => { it("should parse @mosaic fix #42 via shared parser", () => { const message: ChatMessage = { id: "msg-1", channelId: "!room:example.com", authorId: "@user:example.com", authorName: "@user:example.com", content: "@mosaic fix #42", timestamp: new Date(), }; const command = service.parseCommand(message); expect(command).not.toBeNull(); expect(command?.command).toBe("fix"); expect(command?.args).toContain("#42"); }); it("should parse !mosaic fix #42 by normalizing to @mosaic for the shared parser", () => { const message: ChatMessage = { id: "msg-1", channelId: "!room:example.com", authorId: "@user:example.com", authorName: "@user:example.com", content: "!mosaic fix #42", timestamp: new Date(), }; const command = service.parseCommand(message); expect(command).not.toBeNull(); expect(command?.command).toBe("fix"); expect(command?.args).toContain("#42"); }); it("should parse @mosaic status command via shared parser", () => { const message: ChatMessage = { id: "msg-2", channelId: "!room:example.com", authorId: "@user:example.com", authorName: "@user:example.com", content: "@mosaic status job-123", timestamp: new Date(), }; const command = service.parseCommand(message); expect(command).not.toBeNull(); expect(command?.command).toBe("status"); expect(command?.args).toContain("job-123"); }); it("should parse @mosaic cancel command via shared parser", () => { const message: ChatMessage = { id: "msg-3", channelId: "!room:example.com", authorId: "@user:example.com", authorName: "@user:example.com", content: "@mosaic cancel job-456", timestamp: new Date(), }; const command = service.parseCommand(message); expect(command).not.toBeNull(); expect(command?.command).toBe("cancel"); }); it("should parse @mosaic help command via shared parser", () => { const message: ChatMessage = { id: "msg-6", channelId: "!room:example.com", authorId: "@user:example.com", authorName: "@user:example.com", content: "@mosaic help", timestamp: new Date(), }; const command = service.parseCommand(message); expect(command).not.toBeNull(); expect(command?.command).toBe("help"); }); it("should return null for non-command messages", () => { const message: ChatMessage = { id: "msg-7", channelId: "!room:example.com", authorId: "@user:example.com", authorName: "@user:example.com", content: "Just a regular message", timestamp: new Date(), }; const command = service.parseCommand(message); expect(command).toBeNull(); }); it("should return null for messages without @mosaic or !mosaic mention", () => { const message: ChatMessage = { id: "msg-8", channelId: "!room:example.com", authorId: "@user:example.com", authorName: "@user:example.com", content: "fix 42", timestamp: new Date(), }; const command = service.parseCommand(message); expect(command).toBeNull(); }); it("should return null for @mosaic mention without a command", () => { const message: ChatMessage = { id: "msg-11", channelId: "!room:example.com", authorId: "@user:example.com", authorName: "@user:example.com", content: "@mosaic", timestamp: new Date(), }; const command = service.parseCommand(message); expect(command).toBeNull(); }); }); describe("Event-driven message reception", () => { it("should ignore messages from the bot itself", async () => { await service.connect(); const parseCommandSpy = vi.spyOn(commandParser, "parseCommand"); // Simulate a message from the bot expect(mockMessageCallbacks.length).toBeGreaterThan(0); const callback = mockMessageCallbacks[0]; callback?.("!test-room:example.com", { event_id: "$msg-1", sender: "@mosaic-bot:example.com", origin_server_ts: Date.now(), content: { msgtype: "m.text", body: "@mosaic fix #42", }, }); // Should not attempt to parse expect(parseCommandSpy).not.toHaveBeenCalled(); }); it("should ignore messages in unmapped rooms", async () => { // MatrixRoomService returns null for unknown rooms mockMatrixRoomService.getWorkspaceForRoom.mockResolvedValue(null); await service.connect(); const callback = mockMessageCallbacks[0]; callback?.("!unknown-room:example.com", { event_id: "$msg-1", sender: "@user:example.com", origin_server_ts: Date.now(), content: { msgtype: "m.text", body: "@mosaic fix #42", }, }); // Wait for async processing await new Promise((resolve) => setTimeout(resolve, 50)); // Should not dispatch to stitcher expect(stitcherService.dispatchJob).not.toHaveBeenCalled(); }); it("should process commands in the control room (fallback workspace)", async () => { // MatrixRoomService returns null, but room matches controlRoomId mockMatrixRoomService.getWorkspaceForRoom.mockResolvedValue(null); await service.connect(); const callback = mockMessageCallbacks[0]; callback?.("!test-room:example.com", { event_id: "$msg-1", sender: "@user:example.com", origin_server_ts: Date.now(), content: { msgtype: "m.text", body: "@mosaic help", }, }); // Wait for async processing await new Promise((resolve) => setTimeout(resolve, 50)); // Should send help message expect(mockClient.sendMessage).toHaveBeenCalledWith( "!test-room:example.com", expect.objectContaining({ body: expect.stringContaining("Available commands:"), }) ); }); it("should process commands in rooms mapped via MatrixRoomService", async () => { // MatrixRoomService resolves the workspace mockMatrixRoomService.getWorkspaceForRoom.mockResolvedValue("mapped-workspace-id"); await service.connect(); const callback = mockMessageCallbacks[0]; callback?.("!mapped-room:example.com", { event_id: "$msg-1", sender: "@user:example.com", origin_server_ts: Date.now(), content: { msgtype: "m.text", body: "@mosaic fix #42", }, }); // Wait for async processing await new Promise((resolve) => setTimeout(resolve, 50)); // Should dispatch with the mapped workspace ID expect(stitcherService.dispatchJob).toHaveBeenCalledWith( expect.objectContaining({ workspaceId: "mapped-workspace-id", }) ); }); it("should handle !mosaic prefix in incoming messages", async () => { mockMatrixRoomService.getWorkspaceForRoom.mockResolvedValue("test-workspace-id"); await service.connect(); const callback = mockMessageCallbacks[0]; callback?.("!test-room:example.com", { event_id: "$msg-1", sender: "@user:example.com", origin_server_ts: Date.now(), content: { msgtype: "m.text", body: "!mosaic help", }, }); // Wait for async processing await new Promise((resolve) => setTimeout(resolve, 50)); // Should send help message (normalized !mosaic -> @mosaic for parser) expect(mockClient.sendMessage).toHaveBeenCalledWith( "!test-room:example.com", expect.objectContaining({ body: expect.stringContaining("Available commands:"), }) ); }); it("should send help text when user tries an unknown command", async () => { mockMatrixRoomService.getWorkspaceForRoom.mockResolvedValue("test-workspace-id"); await service.connect(); const callback = mockMessageCallbacks[0]; callback?.("!test-room:example.com", { event_id: "$msg-1", sender: "@user:example.com", origin_server_ts: Date.now(), content: { msgtype: "m.text", body: "@mosaic invalidcommand", }, }); // Wait for async processing await new Promise((resolve) => setTimeout(resolve, 50)); // Should send error/help message (CommandParserService returns help text for unknown actions) expect(mockClient.sendMessage).toHaveBeenCalledWith( "!test-room:example.com", expect.objectContaining({ body: expect.stringContaining("Available commands"), }) ); }); it("should ignore non-text messages", async () => { mockMatrixRoomService.getWorkspaceForRoom.mockResolvedValue("test-workspace-id"); await service.connect(); const callback = mockMessageCallbacks[0]; callback?.("!test-room:example.com", { event_id: "$msg-1", sender: "@user:example.com", origin_server_ts: Date.now(), content: { msgtype: "m.image", body: "photo.jpg", }, }); // Wait for async processing await new Promise((resolve) => setTimeout(resolve, 50)); // Should not attempt any message sending expect(mockClient.sendMessage).not.toHaveBeenCalled(); }); }); describe("Command Execution", () => { it("should forward fix command to stitcher and create a thread", async () => { const message: ChatMessage = { id: "msg-1", channelId: "!test-room:example.com", authorId: "@user:example.com", authorName: "@user:example.com", content: "@mosaic fix 42", timestamp: new Date(), }; await service.connect(); await service.handleCommand({ command: "fix", args: ["42"], message, }); expect(stitcherService.dispatchJob).toHaveBeenCalledWith({ workspaceId: "test-workspace-id", type: "code-task", priority: 10, metadata: { issueNumber: 42, command: "fix", channelId: "!test-room:example.com", threadId: "$event-id-123", authorId: "@user:example.com", authorName: "@user:example.com", }, }); }); it("should handle fix with #-prefixed issue number", async () => { const message: ChatMessage = { id: "msg-1", channelId: "!test-room:example.com", authorId: "@user:example.com", authorName: "@user:example.com", content: "@mosaic fix #42", timestamp: new Date(), }; await service.connect(); await service.handleCommand({ command: "fix", args: ["#42"], message, }); expect(stitcherService.dispatchJob).toHaveBeenCalledWith( expect.objectContaining({ metadata: expect.objectContaining({ issueNumber: 42, }), }) ); }); it("should respond with help message", async () => { const message: ChatMessage = { id: "msg-1", channelId: "!test-room:example.com", authorId: "@user:example.com", authorName: "@user:example.com", content: "@mosaic help", timestamp: new Date(), }; await service.connect(); await service.handleCommand({ command: "help", args: [], message, }); expect(mockClient.sendMessage).toHaveBeenCalledWith( "!test-room:example.com", expect.objectContaining({ body: expect.stringContaining("Available commands:"), }) ); }); it("should include retry command in help output", async () => { const message: ChatMessage = { id: "msg-1", channelId: "!test-room:example.com", authorId: "@user:example.com", authorName: "@user:example.com", content: "@mosaic help", timestamp: new Date(), }; await service.connect(); await service.handleCommand({ command: "help", args: [], message, }); expect(mockClient.sendMessage).toHaveBeenCalledWith( "!test-room:example.com", expect.objectContaining({ body: expect.stringContaining("retry"), }) ); }); it("should send error for fix command without issue number", async () => { const message: ChatMessage = { id: "msg-1", channelId: "!test-room:example.com", authorId: "@user:example.com", authorName: "@user:example.com", content: "@mosaic fix", timestamp: new Date(), }; await service.connect(); await service.handleCommand({ command: "fix", args: [], message, }); expect(mockClient.sendMessage).toHaveBeenCalledWith( "!test-room:example.com", expect.objectContaining({ body: expect.stringContaining("Usage:"), }) ); }); it("should send error for fix command with non-numeric issue", async () => { const message: ChatMessage = { id: "msg-1", channelId: "!test-room:example.com", authorId: "@user:example.com", authorName: "@user:example.com", content: "@mosaic fix abc", timestamp: new Date(), }; await service.connect(); await service.handleCommand({ command: "fix", args: ["abc"], message, }); expect(mockClient.sendMessage).toHaveBeenCalledWith( "!test-room:example.com", expect.objectContaining({ body: expect.stringContaining("Invalid issue number"), }) ); }); it("should dispatch fix command with workspace from MatrixRoomService", async () => { mockMatrixRoomService.getWorkspaceForRoom.mockResolvedValue("dynamic-workspace-id"); await service.connect(); const callback = mockMessageCallbacks[0]; callback?.("!mapped-room:example.com", { event_id: "$msg-1", sender: "@user:example.com", origin_server_ts: Date.now(), content: { msgtype: "m.text", body: "@mosaic fix #99", }, }); // Wait for async processing await new Promise((resolve) => setTimeout(resolve, 50)); expect(stitcherService.dispatchJob).toHaveBeenCalledWith( expect.objectContaining({ workspaceId: "dynamic-workspace-id", metadata: expect.objectContaining({ issueNumber: 99, }), }) ); }); }); describe("Configuration", () => { it("should throw error if MATRIX_HOMESERVER_URL is not set", async () => { delete process.env.MATRIX_HOMESERVER_URL; const module: TestingModule = await Test.createTestingModule({ providers: [ MatrixService, CommandParserService, { provide: StitcherService, useValue: mockStitcherService, }, { provide: MatrixRoomService, useValue: mockMatrixRoomService, }, ], }).compile(); const newService = module.get(MatrixService); await expect(newService.connect()).rejects.toThrow("MATRIX_HOMESERVER_URL is required"); // Restore for other tests process.env.MATRIX_HOMESERVER_URL = "https://matrix.example.com"; }); it("should throw error if MATRIX_ACCESS_TOKEN is not set", async () => { delete process.env.MATRIX_ACCESS_TOKEN; const module: TestingModule = await Test.createTestingModule({ providers: [ MatrixService, CommandParserService, { provide: StitcherService, useValue: mockStitcherService, }, { provide: MatrixRoomService, useValue: mockMatrixRoomService, }, ], }).compile(); const newService = module.get(MatrixService); await expect(newService.connect()).rejects.toThrow("MATRIX_ACCESS_TOKEN is required"); // Restore for other tests process.env.MATRIX_ACCESS_TOKEN = "test-access-token"; }); it("should throw error if MATRIX_BOT_USER_ID is not set", async () => { delete process.env.MATRIX_BOT_USER_ID; const module: TestingModule = await Test.createTestingModule({ providers: [ MatrixService, CommandParserService, { provide: StitcherService, useValue: mockStitcherService, }, { provide: MatrixRoomService, useValue: mockMatrixRoomService, }, ], }).compile(); const newService = module.get(MatrixService); await expect(newService.connect()).rejects.toThrow("MATRIX_BOT_USER_ID is required"); // Restore for other tests process.env.MATRIX_BOT_USER_ID = "@mosaic-bot:example.com"; }); it("should throw error if MATRIX_WORKSPACE_ID is not set", async () => { delete process.env.MATRIX_WORKSPACE_ID; const module: TestingModule = await Test.createTestingModule({ providers: [ MatrixService, CommandParserService, { provide: StitcherService, useValue: mockStitcherService, }, { provide: MatrixRoomService, useValue: mockMatrixRoomService, }, ], }).compile(); const newService = module.get(MatrixService); await expect(newService.connect()).rejects.toThrow("MATRIX_WORKSPACE_ID is required"); // Restore for other tests process.env.MATRIX_WORKSPACE_ID = "test-workspace-id"; }); it("should use configured workspace ID from environment", async () => { const testWorkspaceId = "configured-workspace-456"; process.env.MATRIX_WORKSPACE_ID = testWorkspaceId; const module: TestingModule = await Test.createTestingModule({ providers: [ MatrixService, CommandParserService, { provide: StitcherService, useValue: mockStitcherService, }, { provide: MatrixRoomService, useValue: mockMatrixRoomService, }, ], }).compile(); const newService = module.get(MatrixService); const message: ChatMessage = { id: "msg-1", channelId: "!test-room:example.com", authorId: "@user:example.com", authorName: "@user:example.com", content: "@mosaic fix 42", timestamp: new Date(), }; await newService.connect(); await newService.handleCommand({ command: "fix", args: ["42"], message, }); expect(mockStitcherService.dispatchJob).toHaveBeenCalledWith( expect.objectContaining({ workspaceId: testWorkspaceId, }) ); // Restore for other tests process.env.MATRIX_WORKSPACE_ID = "test-workspace-id"; }); }); describe("Error Logging Security", () => { it("should sanitize sensitive data in error logs", async () => { const loggerErrorSpy = vi.spyOn( (service as Record)["logger"] as { error: (...args: unknown[]) => void }, "error" ); await service.connect(); // Trigger room.event handler with null event to exercise error path expect(mockEventCallbacks.length).toBeGreaterThan(0); mockEventCallbacks[0]?.("!room:example.com", null as unknown as Record); // Verify error was logged expect(loggerErrorSpy).toHaveBeenCalled(); // Get the logged error const loggedArgs = loggerErrorSpy.mock.calls[0]; const loggedError = loggedArgs?.[1] as Record; // Verify non-sensitive error info is preserved expect(loggedError).toBeDefined(); expect((loggedError as { message: string }).message).toBe("Received null event from Matrix"); }); it("should not include access token in error output", () => { // Verify the access token is stored privately and not exposed const serviceAsRecord = service as unknown as Record; // The accessToken should exist but should not appear in any public-facing method output expect(serviceAsRecord["accessToken"]).toBe("test-access-token"); // Verify isConnected does not leak token const connected = service.isConnected(); expect(String(connected)).not.toContain("test-access-token"); }); }); describe("MatrixRoomService reverse lookup", () => { it("should call getWorkspaceForRoom when processing messages", async () => { mockMatrixRoomService.getWorkspaceForRoom.mockResolvedValue("resolved-workspace"); await service.connect(); const callback = mockMessageCallbacks[0]; callback?.("!some-room:example.com", { event_id: "$msg-1", sender: "@user:example.com", origin_server_ts: Date.now(), content: { msgtype: "m.text", body: "@mosaic help", }, }); // Wait for async processing await new Promise((resolve) => setTimeout(resolve, 50)); expect(matrixRoomService.getWorkspaceForRoom).toHaveBeenCalledWith("!some-room:example.com"); }); it("should fall back to control room workspace when MatrixRoomService returns null", async () => { mockMatrixRoomService.getWorkspaceForRoom.mockResolvedValue(null); await service.connect(); const callback = mockMessageCallbacks[0]; // Send to the control room (fallback path) callback?.("!test-room:example.com", { event_id: "$msg-1", sender: "@user:example.com", origin_server_ts: Date.now(), content: { msgtype: "m.text", body: "@mosaic fix #10", }, }); // Wait for async processing await new Promise((resolve) => setTimeout(resolve, 50)); // Should dispatch with the env-configured workspace expect(stitcherService.dispatchJob).toHaveBeenCalledWith( expect.objectContaining({ workspaceId: "test-workspace-id", }) ); }); }); });