Files
stack/apps/api/src/bridge/matrix/matrix-bridge.integration.spec.ts
Jason Woltje 8d19ac1f4b
Some checks failed
ci/woodpecker/push/infra Pipeline was successful
ci/woodpecker/push/api Pipeline failed
fix(#377): remediate code review and security findings
- 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>
2026-02-15 03:00:53 -06:00

1066 lines
36 KiB
TypeScript

/**
* Matrix Bridge Integration Tests
*
* These tests verify cross-service interactions in the Matrix bridge subsystem.
* They use the NestJS Test module with mocked external dependencies (Prisma,
* matrix-bot-sdk, discord.js) but test ACTUAL service-to-service wiring.
*
* Scenarios covered:
* 1. BridgeModule DI: CHAT_PROVIDERS includes MatrixService when MATRIX_ACCESS_TOKEN is set
* 2. BridgeModule without Matrix: Matrix excluded when MATRIX_ACCESS_TOKEN unset
* 3. Command flow: room.message -> MatrixService -> CommandParserService -> StitcherService
* 4. Herald broadcast: HeraldService broadcasts to MatrixService as a CHAT_PROVIDERS entry
* 5. Room-workspace mapping: MatrixRoomService resolves workspace for MatrixService.handleRoomMessage
* 6. Streaming flow: MatrixStreamingService.streamResponse via MatrixService's client
* 7. Multi-provider coexistence: Both Discord and Matrix in CHAT_PROVIDERS
*/
import { Test, TestingModule } from "@nestjs/testing";
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { BridgeModule } from "../bridge.module";
import { CHAT_PROVIDERS } from "../bridge.constants";
import { MatrixService } from "./matrix.service";
import { MatrixRoomService } from "./matrix-room.service";
import { MatrixStreamingService } from "./matrix-streaming.service";
import { CommandParserService } from "../parser/command-parser.service";
import { DiscordService } from "../discord/discord.service";
import { StitcherService } from "../../stitcher/stitcher.service";
import { HeraldService } from "../../herald/herald.service";
import { PrismaService } from "../../prisma/prisma.service";
import { BullMqService } from "../../bullmq/bullmq.service";
import type { IChatProvider } from "../interfaces";
import { JOB_CREATED, JOB_STARTED } from "../../job-events/event-types";
// ---------------------------------------------------------------------------
// Mock discord.js
// ---------------------------------------------------------------------------
const mockDiscordReadyCallbacks: Array<() => void> = [];
const mockDiscordClient = {
login: vi.fn().mockImplementation(async () => {
mockDiscordReadyCallbacks.forEach((cb) => cb());
return Promise.resolve();
}),
destroy: vi.fn().mockResolvedValue(undefined),
on: vi.fn(),
once: vi.fn().mockImplementation((event: string, callback: () => void) => {
if (event === "ready") {
mockDiscordReadyCallbacks.push(callback);
}
}),
user: { tag: "TestBot#1234" },
channels: { fetch: vi.fn() },
guilds: { fetch: vi.fn() },
};
vi.mock("discord.js", () => ({
Client: class MockClient {
login = mockDiscordClient.login;
destroy = mockDiscordClient.destroy;
on = mockDiscordClient.on;
once = mockDiscordClient.once;
user = mockDiscordClient.user;
channels = mockDiscordClient.channels;
guilds = mockDiscordClient.guilds;
},
Events: {
ClientReady: "ready",
MessageCreate: "messageCreate",
Error: "error",
},
GatewayIntentBits: {
Guilds: 1 << 0,
GuildMessages: 1 << 9,
MessageContent: 1 << 15,
},
}));
// ---------------------------------------------------------------------------
// Mock matrix-bot-sdk
// ---------------------------------------------------------------------------
const mockMatrixMessageCallbacks: Array<(roomId: string, event: Record<string, unknown>) => void> =
[];
const mockMatrixEventCallbacks: Array<(roomId: string, event: Record<string, unknown>) => void> =
[];
const mockMatrixClient = {
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") {
mockMatrixMessageCallbacks.push(callback);
}
if (event === "room.event") {
mockMatrixEventCallbacks.push(callback);
}
}
),
sendMessage: vi.fn().mockResolvedValue("$mock-event-id"),
sendEvent: vi.fn().mockResolvedValue("$mock-edit-event-id"),
setTyping: vi.fn().mockResolvedValue(undefined),
createRoom: vi.fn().mockResolvedValue("!new-room:example.com"),
};
vi.mock("matrix-bot-sdk", () => ({
MatrixClient: class MockMatrixClient {
start = mockMatrixClient.start;
stop = mockMatrixClient.stop;
on = mockMatrixClient.on;
sendMessage = mockMatrixClient.sendMessage;
sendEvent = mockMatrixClient.sendEvent;
setTyping = mockMatrixClient.setTyping;
createRoom = mockMatrixClient.createRoom;
},
SimpleFsStorageProvider: class MockStorage {
constructor(_path: string) {
// no-op
}
},
AutojoinRoomsMixin: {
setupOnClient: vi.fn(),
},
}));
// ---------------------------------------------------------------------------
// Saved environment variables
// ---------------------------------------------------------------------------
interface SavedEnvVars {
DISCORD_BOT_TOKEN?: string;
DISCORD_GUILD_ID?: string;
DISCORD_CONTROL_CHANNEL_ID?: string;
DISCORD_WORKSPACE_ID?: string;
MATRIX_ACCESS_TOKEN?: string;
MATRIX_HOMESERVER_URL?: string;
MATRIX_BOT_USER_ID?: string;
MATRIX_CONTROL_ROOM_ID?: string;
MATRIX_WORKSPACE_ID?: string;
ENCRYPTION_KEY?: string;
}
const ENV_KEYS: (keyof SavedEnvVars)[] = [
"DISCORD_BOT_TOKEN",
"DISCORD_GUILD_ID",
"DISCORD_CONTROL_CHANNEL_ID",
"DISCORD_WORKSPACE_ID",
"MATRIX_ACCESS_TOKEN",
"MATRIX_HOMESERVER_URL",
"MATRIX_BOT_USER_ID",
"MATRIX_CONTROL_ROOM_ID",
"MATRIX_WORKSPACE_ID",
"ENCRYPTION_KEY",
];
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function saveAndClearEnv(): SavedEnvVars {
const saved: SavedEnvVars = {};
for (const key of ENV_KEYS) {
saved[key] = process.env[key];
delete process.env[key];
}
return saved;
}
function restoreEnv(saved: SavedEnvVars): void {
for (const [key, value] of Object.entries(saved)) {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
}
function setMatrixEnv(): void {
process.env.MATRIX_ACCESS_TOKEN = "test-matrix-token";
process.env.MATRIX_HOMESERVER_URL = "https://matrix.example.com";
process.env.MATRIX_BOT_USER_ID = "@bot:example.com";
process.env.MATRIX_CONTROL_ROOM_ID = "!control-room:example.com";
process.env.MATRIX_WORKSPACE_ID = "ws-integration-test";
}
function setDiscordEnv(): void {
process.env.DISCORD_BOT_TOKEN = "test-discord-token";
process.env.DISCORD_GUILD_ID = "test-guild-id";
process.env.DISCORD_CONTROL_CHANNEL_ID = "test-channel-id";
process.env.DISCORD_WORKSPACE_ID = "ws-discord-test";
}
function setEncryptionKey(): void {
process.env.ENCRYPTION_KEY = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
}
/**
* Compile the full BridgeModule with only external deps mocked
*/
async function compileBridgeModule(): Promise<TestingModule> {
return Test.createTestingModule({
imports: [BridgeModule],
})
.overrideProvider(PrismaService)
.useValue({})
.overrideProvider(BullMqService)
.useValue({})
.compile();
}
/**
* Create an async iterable from an array of string tokens
*/
async function* createTokenStream(tokens: string[]): AsyncGenerator<string, void, undefined> {
for (const token of tokens) {
yield token;
}
}
// ===========================================================================
// Integration Tests
// ===========================================================================
describe("Matrix Bridge Integration Tests", () => {
let savedEnv: SavedEnvVars;
beforeEach(() => {
savedEnv = saveAndClearEnv();
setEncryptionKey();
// Clear callback arrays
mockMatrixMessageCallbacks.length = 0;
mockMatrixEventCallbacks.length = 0;
mockDiscordReadyCallbacks.length = 0;
vi.clearAllMocks();
});
afterEach(() => {
restoreEnv(savedEnv);
});
// =========================================================================
// Scenario 1: BridgeModule DI with Matrix enabled
// =========================================================================
describe("BridgeModule DI: Matrix enabled", () => {
it("should include MatrixService in CHAT_PROVIDERS when MATRIX_ACCESS_TOKEN is set", async () => {
setMatrixEnv();
const module = await compileBridgeModule();
const providers = module.get<IChatProvider[]>(CHAT_PROVIDERS);
expect(providers).toBeDefined();
expect(providers.length).toBeGreaterThanOrEqual(1);
const matrixProvider = providers.find((p) => p instanceof MatrixService);
expect(matrixProvider).toBeDefined();
expect(matrixProvider).toBeInstanceOf(MatrixService);
});
it("should export MatrixService, MatrixRoomService, MatrixStreamingService, and CommandParserService", async () => {
setMatrixEnv();
const module = await compileBridgeModule();
expect(module.get(MatrixService)).toBeInstanceOf(MatrixService);
expect(module.get(MatrixRoomService)).toBeInstanceOf(MatrixRoomService);
expect(module.get(MatrixStreamingService)).toBeInstanceOf(MatrixStreamingService);
expect(module.get(CommandParserService)).toBeInstanceOf(CommandParserService);
});
it("should provide StitcherService to MatrixService via StitcherModule import", async () => {
setMatrixEnv();
const module = await compileBridgeModule();
const stitcher = module.get(StitcherService);
expect(stitcher).toBeDefined();
expect(stitcher).toBeInstanceOf(StitcherService);
});
});
// =========================================================================
// Scenario 2: BridgeModule without Matrix
// =========================================================================
describe("BridgeModule DI: Matrix disabled", () => {
it("should NOT include MatrixService in CHAT_PROVIDERS when MATRIX_ACCESS_TOKEN is unset", async () => {
// No Matrix env vars set - only encryption key
const module = await compileBridgeModule();
const providers = module.get<IChatProvider[]>(CHAT_PROVIDERS);
expect(providers).toBeDefined();
const matrixProvider = providers.find((p) => p instanceof MatrixService);
expect(matrixProvider).toBeUndefined();
});
it("should still register MatrixService as a provider even when not in CHAT_PROVIDERS", async () => {
// MatrixService is always registered (for optional injection), just not in CHAT_PROVIDERS
const module = await compileBridgeModule();
const matrixService = module.get(MatrixService);
expect(matrixService).toBeDefined();
expect(matrixService).toBeInstanceOf(MatrixService);
});
it("should produce empty CHAT_PROVIDERS when neither bridge is configured", async () => {
const module = await compileBridgeModule();
const providers = module.get<IChatProvider[]>(CHAT_PROVIDERS);
expect(providers).toEqual([]);
});
});
// =========================================================================
// Scenario 3: Command flow - message -> parser -> stitcher
// =========================================================================
describe("Command flow: message -> CommandParserService -> StitcherService", () => {
let matrixService: MatrixService;
let stitcherService: StitcherService;
let commandParser: CommandParserService;
const mockStitcher = {
dispatchJob: vi.fn().mockResolvedValue({
jobId: "job-integ-001",
queueName: "main",
status: "PENDING",
}),
trackJobEvent: vi.fn().mockResolvedValue(undefined),
};
const mockRoomService = {
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 () => {
setMatrixEnv();
const module = await Test.createTestingModule({
providers: [
MatrixService,
CommandParserService,
{
provide: StitcherService,
useValue: mockStitcher,
},
{
provide: MatrixRoomService,
useValue: mockRoomService,
},
],
}).compile();
matrixService = module.get(MatrixService);
stitcherService = module.get(StitcherService);
commandParser = module.get(CommandParserService);
});
it("should parse @mosaic fix #42 through CommandParserService and dispatch to StitcherService", async () => {
// MatrixRoomService returns a workspace for the room
mockRoomService.getWorkspaceForRoom.mockResolvedValue("ws-mapped-123");
await matrixService.connect();
// Simulate incoming Matrix message event
const callback = mockMatrixMessageCallbacks[0];
expect(callback).toBeDefined();
callback?.("!some-room:example.com", {
event_id: "$ev-fix-42",
sender: "@alice: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, 100));
// Verify StitcherService.dispatchJob was called with correct workspace
expect(stitcherService.dispatchJob).toHaveBeenCalledWith(
expect.objectContaining({
workspaceId: "ws-mapped-123",
type: "code-task",
priority: 10,
metadata: expect.objectContaining({
issueNumber: 42,
command: "fix",
authorId: "@alice:example.com",
}),
})
);
});
it("should normalize !mosaic prefix through CommandParserService and dispatch correctly", async () => {
mockRoomService.getWorkspaceForRoom.mockResolvedValue("ws-bang-prefix");
await matrixService.connect();
const callback = mockMatrixMessageCallbacks[0];
callback?.("!room:example.com", {
event_id: "$ev-bang-fix",
sender: "@bob:example.com",
origin_server_ts: Date.now(),
content: {
msgtype: "m.text",
body: "!mosaic fix #99",
},
});
await new Promise((resolve) => setTimeout(resolve, 100));
expect(stitcherService.dispatchJob).toHaveBeenCalledWith(
expect.objectContaining({
workspaceId: "ws-bang-prefix",
metadata: expect.objectContaining({
issueNumber: 99,
}),
})
);
});
it("should send help text when CommandParserService fails to parse an invalid command", async () => {
mockRoomService.getWorkspaceForRoom.mockResolvedValue("ws-test");
await matrixService.connect();
const callback = mockMatrixMessageCallbacks[0];
callback?.("!room:example.com", {
event_id: "$ev-bad-cmd",
sender: "@user:example.com",
origin_server_ts: Date.now(),
content: {
msgtype: "m.text",
body: "@mosaic invalidcmd",
},
});
await new Promise((resolve) => setTimeout(resolve, 100));
// Should NOT dispatch to stitcher
expect(stitcherService.dispatchJob).not.toHaveBeenCalled();
// Should send help text back to the room
expect(mockMatrixClient.sendMessage).toHaveBeenCalledWith(
"!room:example.com",
expect.objectContaining({
body: expect.stringContaining("Available commands"),
})
);
});
it("should create a thread and send confirmation after dispatching a fix command", async () => {
mockRoomService.getWorkspaceForRoom.mockResolvedValue("ws-thread-test");
await matrixService.connect();
const callback = mockMatrixMessageCallbacks[0];
callback?.("!room:example.com", {
event_id: "$ev-fix-thread",
sender: "@alice:example.com",
origin_server_ts: Date.now(),
content: {
msgtype: "m.text",
body: "@mosaic fix #10",
},
});
await new Promise((resolve) => setTimeout(resolve, 100));
// First sendMessage creates the thread root
const sendCalls = mockMatrixClient.sendMessage.mock.calls;
expect(sendCalls.length).toBeGreaterThanOrEqual(2);
// Thread root message
const threadRootCall = sendCalls[0];
expect(threadRootCall?.[0]).toBe("!room:example.com");
expect(threadRootCall?.[1]).toEqual(
expect.objectContaining({
body: expect.stringContaining("Job #10"),
})
);
// Confirmation message sent as thread reply (uses channelId from message, not hardcoded controlRoomId)
const confirmationCall = sendCalls[1];
expect(confirmationCall?.[0]).toBe("!room:example.com");
expect(confirmationCall?.[1]).toEqual(
expect.objectContaining({
body: expect.stringContaining("Job created: job-integ-001"),
"m.relates_to": expect.objectContaining({
rel_type: "m.thread",
}),
})
);
});
it("should verify CommandParserService is the real service (not a mock)", () => {
// This confirms the integration test wires up the actual CommandParserService
const result = commandParser.parseCommand("@mosaic fix #42");
expect(result.success).toBe(true);
if (result.success) {
expect(result.command.action).toBe("fix");
expect(result.command.issue?.number).toBe(42);
}
});
});
// =========================================================================
// Scenario 4: Herald broadcast to MatrixService via CHAT_PROVIDERS
// =========================================================================
describe("Herald broadcast via CHAT_PROVIDERS", () => {
it("should broadcast to MatrixService when it is connected", async () => {
setMatrixEnv();
// Create a connected mock MatrixService that tracks sendThreadMessage calls
const threadMessages: Array<{ threadId: string; channelId: string; content: string }> = [];
const mockMatrixProvider: IChatProvider = {
connect: vi.fn().mockResolvedValue(undefined),
disconnect: vi.fn().mockResolvedValue(undefined),
isConnected: vi.fn().mockReturnValue(true),
sendMessage: vi.fn().mockResolvedValue(undefined),
createThread: vi.fn().mockResolvedValue("$thread-id"),
sendThreadMessage: vi.fn().mockImplementation(async (options) => {
threadMessages.push(options as { threadId: string; channelId: string; content: string });
}),
parseCommand: vi.fn().mockReturnValue(null),
};
const mockPrisma = {
runnerJob: {
findUnique: vi.fn().mockResolvedValue({
id: "job-herald-001",
workspaceId: "ws-herald-test",
type: "code-task",
}),
},
jobEvent: {
findFirst: vi.fn().mockResolvedValue({
payload: {
metadata: {
threadId: "$thread-herald-root",
channelId: "!herald-room:example.com",
issueNumber: 55,
},
},
}),
},
};
const module = await Test.createTestingModule({
providers: [
HeraldService,
{
provide: PrismaService,
useValue: mockPrisma,
},
{
provide: CHAT_PROVIDERS,
useValue: [mockMatrixProvider],
},
],
}).compile();
const herald = module.get(HeraldService);
await herald.broadcastJobEvent("job-herald-001", {
id: "evt-001",
jobId: "job-herald-001",
type: JOB_STARTED,
timestamp: new Date(),
actor: "stitcher",
payload: {},
});
// Verify Herald sent the message via the MatrixService (CHAT_PROVIDERS)
expect(threadMessages).toHaveLength(1);
expect(threadMessages[0]?.threadId).toBe("$thread-herald-root");
expect(threadMessages[0]?.content).toContain("#55");
});
it("should skip disconnected providers and continue to next", async () => {
const disconnectedProvider: IChatProvider = {
connect: vi.fn().mockResolvedValue(undefined),
disconnect: vi.fn().mockResolvedValue(undefined),
isConnected: vi.fn().mockReturnValue(false),
sendMessage: vi.fn().mockResolvedValue(undefined),
createThread: vi.fn().mockResolvedValue("$t"),
sendThreadMessage: vi.fn().mockResolvedValue(undefined),
parseCommand: vi.fn().mockReturnValue(null),
};
const connectedProvider: IChatProvider = {
connect: vi.fn().mockResolvedValue(undefined),
disconnect: vi.fn().mockResolvedValue(undefined),
isConnected: vi.fn().mockReturnValue(true),
sendMessage: vi.fn().mockResolvedValue(undefined),
createThread: vi.fn().mockResolvedValue("$t"),
sendThreadMessage: vi.fn().mockResolvedValue(undefined),
parseCommand: vi.fn().mockReturnValue(null),
};
const mockPrisma = {
runnerJob: {
findUnique: vi.fn().mockResolvedValue({
id: "job-skip-001",
workspaceId: "ws-skip",
type: "code-task",
}),
},
jobEvent: {
findFirst: vi.fn().mockResolvedValue({
payload: {
metadata: {
threadId: "$thread-skip",
channelId: "!skip-room:example.com",
issueNumber: 1,
},
},
}),
},
};
const module = await Test.createTestingModule({
providers: [
HeraldService,
{
provide: PrismaService,
useValue: mockPrisma,
},
{
provide: CHAT_PROVIDERS,
useValue: [disconnectedProvider, connectedProvider],
},
],
}).compile();
const herald = module.get(HeraldService);
await herald.broadcastJobEvent("job-skip-001", {
id: "evt-002",
jobId: "job-skip-001",
type: JOB_CREATED,
timestamp: new Date(),
actor: "stitcher",
payload: {},
});
// Disconnected provider should NOT have received message
expect(disconnectedProvider.sendThreadMessage).not.toHaveBeenCalled();
// Connected provider SHOULD have received message
expect(connectedProvider.sendThreadMessage).toHaveBeenCalledTimes(1);
});
it("should continue broadcasting to other providers if one throws an error", async () => {
const failingProvider: IChatProvider = {
connect: vi.fn().mockResolvedValue(undefined),
disconnect: vi.fn().mockResolvedValue(undefined),
isConnected: vi.fn().mockReturnValue(true),
sendMessage: vi.fn().mockResolvedValue(undefined),
createThread: vi.fn().mockResolvedValue("$t"),
sendThreadMessage: vi.fn().mockRejectedValue(new Error("Network failure")),
parseCommand: vi.fn().mockReturnValue(null),
};
const healthyProvider: IChatProvider = {
connect: vi.fn().mockResolvedValue(undefined),
disconnect: vi.fn().mockResolvedValue(undefined),
isConnected: vi.fn().mockReturnValue(true),
sendMessage: vi.fn().mockResolvedValue(undefined),
createThread: vi.fn().mockResolvedValue("$t"),
sendThreadMessage: vi.fn().mockResolvedValue(undefined),
parseCommand: vi.fn().mockReturnValue(null),
};
const mockPrisma = {
runnerJob: {
findUnique: vi.fn().mockResolvedValue({
id: "job-err-001",
workspaceId: "ws-err",
type: "code-task",
}),
},
jobEvent: {
findFirst: vi.fn().mockResolvedValue({
payload: {
metadata: {
threadId: "$thread-err",
channelId: "!err-room:example.com",
issueNumber: 77,
},
},
}),
},
};
const module = await Test.createTestingModule({
providers: [
HeraldService,
{
provide: PrismaService,
useValue: mockPrisma,
},
{
provide: CHAT_PROVIDERS,
useValue: [failingProvider, healthyProvider],
},
],
}).compile();
const herald = module.get(HeraldService);
// Should not throw even though first provider fails
await expect(
herald.broadcastJobEvent("job-err-001", {
id: "evt-003",
jobId: "job-err-001",
type: JOB_STARTED,
timestamp: new Date(),
actor: "stitcher",
payload: {},
})
).resolves.toBeUndefined();
// Both providers should have been attempted
expect(failingProvider.sendThreadMessage).toHaveBeenCalledTimes(1);
expect(healthyProvider.sendThreadMessage).toHaveBeenCalledTimes(1);
});
});
// =========================================================================
// Scenario 5: Room-workspace mapping integration
// =========================================================================
describe("Room-workspace mapping: MatrixRoomService -> MatrixService", () => {
let matrixService: MatrixService;
const mockStitcher = {
dispatchJob: vi.fn().mockResolvedValue({
jobId: "job-room-001",
queueName: "main",
status: "PENDING",
}),
trackJobEvent: vi.fn().mockResolvedValue(undefined),
};
const mockPrisma = {
workspace: {
findFirst: vi.fn(),
findUnique: vi.fn(),
update: vi.fn(),
},
};
beforeEach(async () => {
setMatrixEnv();
const module = await Test.createTestingModule({
providers: [
MatrixService,
CommandParserService,
MatrixRoomService,
{
provide: StitcherService,
useValue: mockStitcher,
},
{
provide: PrismaService,
useValue: mockPrisma,
},
],
}).compile();
matrixService = module.get(MatrixService);
});
it("should resolve workspace from MatrixRoomService's Prisma lookup and dispatch command", async () => {
// Mock Prisma: room maps to workspace
mockPrisma.workspace.findFirst.mockResolvedValue({ id: "ws-prisma-resolved" });
await matrixService.connect();
const callback = mockMatrixMessageCallbacks[0];
callback?.("!mapped-room:example.com", {
event_id: "$ev-room-map",
sender: "@user:example.com",
origin_server_ts: Date.now(),
content: {
msgtype: "m.text",
body: "@mosaic fix #77",
},
});
await new Promise((resolve) => setTimeout(resolve, 100));
// MatrixRoomService should have queried Prisma with the room ID
expect(mockPrisma.workspace.findFirst).toHaveBeenCalledWith({
where: { matrixRoomId: "!mapped-room:example.com" },
select: { id: true },
});
// StitcherService should have been called with the resolved workspace
expect(mockStitcher.dispatchJob).toHaveBeenCalledWith(
expect.objectContaining({
workspaceId: "ws-prisma-resolved",
})
);
});
it("should fall back to control room workspace when room is not mapped in Prisma", async () => {
// Prisma returns no workspace for arbitrary rooms
mockPrisma.workspace.findFirst.mockResolvedValue(null);
await matrixService.connect();
const callback = mockMatrixMessageCallbacks[0];
// Send to the control room (which is !control-room:example.com from setMatrixEnv)
callback?.("!control-room:example.com", {
event_id: "$ev-control-fallback",
sender: "@user:example.com",
origin_server_ts: Date.now(),
content: {
msgtype: "m.text",
body: "@mosaic fix #5",
},
});
await new Promise((resolve) => setTimeout(resolve, 100));
// Should use the env-configured workspace ID as fallback
expect(mockStitcher.dispatchJob).toHaveBeenCalledWith(
expect.objectContaining({
workspaceId: "ws-integration-test",
})
);
});
it("should ignore messages in unmapped rooms that are not the control room", async () => {
mockPrisma.workspace.findFirst.mockResolvedValue(null);
await matrixService.connect();
const callback = mockMatrixMessageCallbacks[0];
callback?.("!unknown-room:example.com", {
event_id: "$ev-unmapped",
sender: "@user:example.com",
origin_server_ts: Date.now(),
content: {
msgtype: "m.text",
body: "@mosaic fix #1",
},
});
await new Promise((resolve) => setTimeout(resolve, 100));
expect(mockStitcher.dispatchJob).not.toHaveBeenCalled();
});
});
// =========================================================================
// Scenario 6: Streaming flow - MatrixStreamingService via MatrixService's client
// =========================================================================
describe("Streaming flow: MatrixStreamingService via MatrixService client", () => {
let streamingService: MatrixStreamingService;
let matrixService: MatrixService;
const mockStitcher = {
dispatchJob: vi.fn().mockResolvedValue({
jobId: "job-stream-001",
queueName: "main",
status: "PENDING",
}),
};
const mockRoomService = {
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 () => {
setMatrixEnv();
const module = await Test.createTestingModule({
providers: [
MatrixService,
MatrixStreamingService,
CommandParserService,
{
provide: StitcherService,
useValue: mockStitcher,
},
{
provide: MatrixRoomService,
useValue: mockRoomService,
},
],
}).compile();
matrixService = module.get(MatrixService);
streamingService = module.get(MatrixStreamingService);
});
it("should use the real MatrixService's client for streaming operations", async () => {
// Connect MatrixService so the client is available
await matrixService.connect();
// Verify the client is available via getClient
const client = matrixService.getClient();
expect(client).not.toBeNull();
// Verify MatrixStreamingService can use the client
expect(matrixService.isConnected()).toBe(true);
});
it("should stream response through MatrixStreamingService using MatrixService connection", async () => {
await matrixService.connect();
const tokens = ["Hello", " ", "world"];
const stream = createTokenStream(tokens);
await streamingService.streamResponse("!room:example.com", stream);
// Verify initial message was sent via the client
expect(mockMatrixClient.sendMessage).toHaveBeenCalledWith(
"!room:example.com",
expect.objectContaining({
msgtype: "m.text",
body: "Thinking...",
})
);
// Verify typing indicator was managed
expect(mockMatrixClient.setTyping).toHaveBeenCalledWith("!room:example.com", true, 30000);
// Last setTyping call should clear the indicator
const typingCalls = mockMatrixClient.setTyping.mock.calls;
const lastTypingCall = typingCalls[typingCalls.length - 1];
expect(lastTypingCall).toEqual(["!room:example.com", false, undefined]);
// Verify the final edit contains accumulated text
const editCalls = mockMatrixClient.sendEvent.mock.calls;
expect(editCalls.length).toBeGreaterThanOrEqual(1);
const lastEditCall = editCalls[editCalls.length - 1];
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
expect(lastEditCall[2]["m.new_content"].body).toBe("Hello world");
});
it("should throw when streaming without a connected MatrixService", async () => {
// Do NOT connect MatrixService
const stream = createTokenStream(["test"]);
await expect(streamingService.streamResponse("!room:example.com", stream)).rejects.toThrow(
"Matrix client is not connected"
);
});
it("should support threaded streaming via MatrixStreamingService", async () => {
await matrixService.connect();
const tokens = ["Threaded", " ", "reply"];
const stream = createTokenStream(tokens);
await streamingService.streamResponse("!room:example.com", stream, {
threadId: "$thread-root-event",
});
// Initial message should include thread relation
expect(mockMatrixClient.sendMessage).toHaveBeenCalledWith(
"!room:example.com",
expect.objectContaining({
"m.relates_to": expect.objectContaining({
rel_type: "m.thread",
event_id: "$thread-root-event",
}),
})
);
});
});
// =========================================================================
// Scenario 7: Multi-provider coexistence
// =========================================================================
describe("Multi-provider coexistence: Discord + Matrix", () => {
it("should include both DiscordService and MatrixService in CHAT_PROVIDERS when both tokens are set", async () => {
setDiscordEnv();
setMatrixEnv();
const module = await compileBridgeModule();
const providers = module.get<IChatProvider[]>(CHAT_PROVIDERS);
expect(providers).toHaveLength(2);
const discordProvider = providers.find((p) => p instanceof DiscordService);
const matrixProvider = providers.find((p) => p instanceof MatrixService);
expect(discordProvider).toBeInstanceOf(DiscordService);
expect(matrixProvider).toBeInstanceOf(MatrixService);
});
it("should maintain correct provider order: Discord first, then Matrix", async () => {
setDiscordEnv();
setMatrixEnv();
const module = await compileBridgeModule();
const providers = module.get<IChatProvider[]>(CHAT_PROVIDERS);
// The factory pushes Discord first, then Matrix (based on BridgeModule order)
expect(providers[0]).toBeInstanceOf(DiscordService);
expect(providers[1]).toBeInstanceOf(MatrixService);
});
it("should share the same CommandParserService and StitcherService across both providers", async () => {
setDiscordEnv();
setMatrixEnv();
const module = await compileBridgeModule();
const discordService = module.get(DiscordService);
const matrixService = module.get(MatrixService);
const stitcher = module.get(StitcherService);
const parser = module.get(CommandParserService);
// Both services exist and are distinct instances
expect(discordService).toBeDefined();
expect(matrixService).toBeDefined();
expect(discordService).not.toBe(matrixService);
// Shared singletons
expect(stitcher).toBeDefined();
expect(parser).toBeDefined();
});
it("should include only DiscordService when MATRIX_ACCESS_TOKEN is unset", async () => {
setDiscordEnv();
// No Matrix env vars
const module = await compileBridgeModule();
const providers = module.get<IChatProvider[]>(CHAT_PROVIDERS);
expect(providers).toHaveLength(1);
expect(providers[0]).toBeInstanceOf(DiscordService);
});
it("should include only MatrixService when DISCORD_BOT_TOKEN is unset", async () => {
setMatrixEnv();
// No Discord env vars
const module = await compileBridgeModule();
const providers = module.get<IChatProvider[]>(CHAT_PROVIDERS);
expect(providers).toHaveLength(1);
expect(providers[0]).toBeInstanceOf(MatrixService);
});
});
});