- Fix sendThreadMessage room mismatch: use channelId from options instead of hardcoded controlRoomId - Add .catch() to fire-and-forget handleRoomMessage to prevent silent error swallowing - Wrap dispatchJob in try-catch for user-visible error reporting in handleFixCommand - Add MATRIX_BOT_USER_ID validation in connect() to prevent infinite message loops - Fix streamResponse error masking: wrap finally/catch side-effects in try-catch - Replace unsafe type assertion with public getClient() in MatrixRoomService - Add orphaned room warning in provisionRoom on DB failure - Add provider identity to Herald error logs - Add channelId to ThreadMessageOptions interface and all callers - Add missing env var warnings in BridgeModule factory - Fix JSON injection in setup-bot.sh: use jq for safe JSON construction Fixes #377 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
980 lines
29 KiB
TypeScript
980 lines
29 KiB
TypeScript
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<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;
|
|
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>(MatrixService);
|
|
stitcherService = module.get<StitcherService>(StitcherService);
|
|
commandParser = module.get<CommandParserService>(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>(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>(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>(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>(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>(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");
|
|
});
|
|
});
|
|
|
|
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",
|
|
})
|
|
);
|
|
});
|
|
});
|
|
});
|