feat(#378): Install matrix-bot-sdk and create MatrixService skeleton
- Add matrix-bot-sdk dependency to @mosaic/api - Create MatrixService implementing IChatProvider interface - Support connect/disconnect, message sending, thread management - Parse @mosaic and !mosaic command prefixes - Delegate commands to StitcherService (same flow as Discord) - Add comprehensive unit tests with mocked MatrixClient (31 tests) - Add Matrix env vars to .env.example Refs #378 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
658
apps/api/src/bridge/matrix/matrix.service.spec.ts
Normal file
658
apps/api/src/bridge/matrix/matrix.service.spec.ts
Normal file
@@ -0,0 +1,658 @@
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { MatrixService } from "./matrix.service";
|
||||
import { StitcherService } from "../../stitcher/stitcher.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<string, unknown>) => void> = [];
|
||||
const mockEventCallbacks: Array<(roomId: string, event: Record<string, unknown>) => void> = [];
|
||||
|
||||
const mockClient = {
|
||||
start: vi.fn().mockResolvedValue(undefined),
|
||||
stop: vi.fn(),
|
||||
on: vi
|
||||
.fn()
|
||||
.mockImplementation(
|
||||
(event: string, callback: (roomId: string, evt: Record<string, unknown>) => 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;
|
||||
|
||||
const mockStitcherService = {
|
||||
dispatchJob: vi.fn().mockResolvedValue({
|
||||
jobId: "test-job-id",
|
||||
queueName: "main",
|
||||
status: "PENDING",
|
||||
}),
|
||||
trackJobEvent: 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,
|
||||
{
|
||||
provide: StitcherService,
|
||||
useValue: mockStitcherService,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<MatrixService>(MatrixService);
|
||||
stitcherService = module.get<StitcherService>(StitcherService);
|
||||
|
||||
// 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",
|
||||
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 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",
|
||||
content: "Test",
|
||||
})
|
||||
).rejects.toThrow("Matrix client is not connected");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Command Parsing", () => {
|
||||
it("should parse @mosaic fix command", () => {
|
||||
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).toEqual({
|
||||
command: "fix",
|
||||
args: ["42"],
|
||||
message,
|
||||
});
|
||||
});
|
||||
|
||||
it("should parse !mosaic fix command", () => {
|
||||
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).toEqual({
|
||||
command: "fix",
|
||||
args: ["42"],
|
||||
message,
|
||||
});
|
||||
});
|
||||
|
||||
it("should parse @mosaic status command", () => {
|
||||
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).toEqual({
|
||||
command: "status",
|
||||
args: ["job-123"],
|
||||
message,
|
||||
});
|
||||
});
|
||||
|
||||
it("should parse @mosaic cancel command", () => {
|
||||
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).toEqual({
|
||||
command: "cancel",
|
||||
args: ["job-456"],
|
||||
message,
|
||||
});
|
||||
});
|
||||
|
||||
it("should parse @mosaic verbose command", () => {
|
||||
const message: ChatMessage = {
|
||||
id: "msg-4",
|
||||
channelId: "!room:example.com",
|
||||
authorId: "@user:example.com",
|
||||
authorName: "@user:example.com",
|
||||
content: "@mosaic verbose job-789",
|
||||
timestamp: new Date(),
|
||||
};
|
||||
|
||||
const command = service.parseCommand(message);
|
||||
|
||||
expect(command).toEqual({
|
||||
command: "verbose",
|
||||
args: ["job-789"],
|
||||
message,
|
||||
});
|
||||
});
|
||||
|
||||
it("should parse @mosaic quiet command", () => {
|
||||
const message: ChatMessage = {
|
||||
id: "msg-5",
|
||||
channelId: "!room:example.com",
|
||||
authorId: "@user:example.com",
|
||||
authorName: "@user:example.com",
|
||||
content: "@mosaic quiet",
|
||||
timestamp: new Date(),
|
||||
};
|
||||
|
||||
const command = service.parseCommand(message);
|
||||
|
||||
expect(command).toEqual({
|
||||
command: "quiet",
|
||||
args: [],
|
||||
message,
|
||||
});
|
||||
});
|
||||
|
||||
it("should parse @mosaic help command", () => {
|
||||
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).toEqual({
|
||||
command: "help",
|
||||
args: [],
|
||||
message,
|
||||
});
|
||||
});
|
||||
|
||||
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 handle commands with multiple arguments", () => {
|
||||
const message: ChatMessage = {
|
||||
id: "msg-9",
|
||||
channelId: "!room:example.com",
|
||||
authorId: "@user:example.com",
|
||||
authorName: "@user:example.com",
|
||||
content: "@mosaic fix 42 high-priority",
|
||||
timestamp: new Date(),
|
||||
};
|
||||
|
||||
const command = service.parseCommand(message);
|
||||
|
||||
expect(command).toEqual({
|
||||
command: "fix",
|
||||
args: ["42", "high-priority"],
|
||||
message,
|
||||
});
|
||||
});
|
||||
|
||||
it("should return null for invalid commands", () => {
|
||||
const message: ChatMessage = {
|
||||
id: "msg-10",
|
||||
channelId: "!room:example.com",
|
||||
authorId: "@user:example.com",
|
||||
authorName: "@user:example.com",
|
||||
content: "@mosaic invalidcommand 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("Command Execution", () => {
|
||||
it("should forward fix command to stitcher", 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 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 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"),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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,
|
||||
{
|
||||
provide: StitcherService,
|
||||
useValue: mockStitcherService,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
const newService = module.get<MatrixService>(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,
|
||||
{
|
||||
provide: StitcherService,
|
||||
useValue: mockStitcherService,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
const newService = module.get<MatrixService>(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_WORKSPACE_ID is not set", async () => {
|
||||
delete process.env.MATRIX_WORKSPACE_ID;
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
MatrixService,
|
||||
{
|
||||
provide: StitcherService,
|
||||
useValue: mockStitcherService,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
const newService = module.get<MatrixService>(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,
|
||||
{
|
||||
provide: StitcherService,
|
||||
useValue: mockStitcherService,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
const newService = module.get<MatrixService>(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<string, unknown>)["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<string, unknown>);
|
||||
|
||||
// Verify error was logged
|
||||
expect(loggerErrorSpy).toHaveBeenCalled();
|
||||
|
||||
// Get the logged error
|
||||
const loggedArgs = loggerErrorSpy.mock.calls[0];
|
||||
const loggedError = loggedArgs?.[1] as Record<string, unknown>;
|
||||
|
||||
// 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<string, unknown>;
|
||||
// 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user