Implements FED-010: Agent Spawn via Federation feature that enables spawning and managing Claude agents on remote federated Mosaic Stack instances via COMMAND message type. Features: - Federation agent command types (spawn, status, kill) - FederationAgentService for handling agent operations - Integration with orchestrator's agent spawner/lifecycle services - API endpoints for spawning, querying status, and killing agents - Full command routing through federation COMMAND infrastructure - Comprehensive test coverage (12/12 tests passing) Architecture: - Hub → Spoke: Spawn agents on remote instances - Command flow: FederationController → FederationAgentService → CommandService → Remote Orchestrator - Response handling: Remote orchestrator returns agent status/results - Security: Connection validation, signature verification Files created: - apps/api/src/federation/types/federation-agent.types.ts - apps/api/src/federation/federation-agent.service.ts - apps/api/src/federation/federation-agent.service.spec.ts Files modified: - apps/api/src/federation/command.service.ts (agent command routing) - apps/api/src/federation/federation.controller.ts (agent endpoints) - apps/api/src/federation/federation.module.ts (service registration) - apps/orchestrator/src/api/agents/agents.controller.ts (status endpoint) - apps/orchestrator/src/api/agents/agents.module.ts (lifecycle integration) Testing: - 12/12 tests passing for FederationAgentService - All command service tests passing - TypeScript compilation successful - Linting passed Refs #93 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
829 lines
21 KiB
TypeScript
829 lines
21 KiB
TypeScript
import { Test, TestingModule } from "@nestjs/testing";
|
|
import { vi, describe, it, expect, beforeEach } from "vitest";
|
|
import { HeraldService } from "./herald.service";
|
|
import { PrismaService } from "../prisma/prisma.service";
|
|
import { DiscordService } from "../bridge/discord/discord.service";
|
|
import {
|
|
JOB_CREATED,
|
|
JOB_STARTED,
|
|
JOB_COMPLETED,
|
|
JOB_FAILED,
|
|
STEP_STARTED,
|
|
STEP_COMPLETED,
|
|
GATE_PASSED,
|
|
GATE_FAILED,
|
|
} from "../job-events/event-types";
|
|
|
|
describe("HeraldService", () => {
|
|
let service: HeraldService;
|
|
let prisma: PrismaService;
|
|
let discord: DiscordService;
|
|
|
|
const mockPrisma = {
|
|
workspace: {
|
|
findUnique: vi.fn(),
|
|
},
|
|
runnerJob: {
|
|
findUnique: vi.fn(),
|
|
},
|
|
jobEvent: {
|
|
findFirst: vi.fn(),
|
|
},
|
|
};
|
|
|
|
const mockDiscord = {
|
|
isConnected: vi.fn(),
|
|
sendMessage: vi.fn(),
|
|
sendThreadMessage: vi.fn(),
|
|
createThread: vi.fn(),
|
|
};
|
|
|
|
beforeEach(async () => {
|
|
const module: TestingModule = await Test.createTestingModule({
|
|
providers: [
|
|
HeraldService,
|
|
{
|
|
provide: PrismaService,
|
|
useValue: mockPrisma,
|
|
},
|
|
{
|
|
provide: DiscordService,
|
|
useValue: mockDiscord,
|
|
},
|
|
],
|
|
}).compile();
|
|
|
|
service = module.get<HeraldService>(HeraldService);
|
|
prisma = module.get<PrismaService>(PrismaService);
|
|
discord = module.get<DiscordService>(DiscordService);
|
|
|
|
// Reset mocks
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
describe("broadcastJobEvent", () => {
|
|
it("should broadcast job.created event to configured channel", async () => {
|
|
// Arrange
|
|
const workspaceId = "workspace-1";
|
|
const jobId = "job-1";
|
|
const event = {
|
|
id: "event-1",
|
|
jobId,
|
|
type: JOB_CREATED,
|
|
timestamp: new Date(),
|
|
actor: "system",
|
|
payload: { issueNumber: 42 },
|
|
};
|
|
|
|
mockPrisma.workspace.findUnique.mockResolvedValue({
|
|
id: workspaceId,
|
|
settings: {
|
|
herald: {
|
|
channelMappings: {
|
|
"code-task": "channel-123",
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
mockPrisma.runnerJob.findUnique.mockResolvedValue({
|
|
id: jobId,
|
|
workspaceId,
|
|
type: "code-task",
|
|
});
|
|
|
|
mockPrisma.jobEvent.findFirst.mockResolvedValue({
|
|
payload: {
|
|
metadata: { issueNumber: 42, threadId: "thread-123" },
|
|
},
|
|
});
|
|
|
|
mockDiscord.isConnected.mockReturnValue(true);
|
|
mockDiscord.sendThreadMessage.mockResolvedValue(undefined);
|
|
|
|
// Act
|
|
await service.broadcastJobEvent(jobId, event);
|
|
|
|
// Assert
|
|
expect(mockDiscord.sendThreadMessage).toHaveBeenCalledWith({
|
|
threadId: "thread-123",
|
|
content: expect.stringContaining("Job created"),
|
|
});
|
|
});
|
|
|
|
it("should broadcast job.started event", async () => {
|
|
// Arrange
|
|
const workspaceId = "workspace-1";
|
|
const jobId = "job-1";
|
|
const event = {
|
|
id: "event-1",
|
|
jobId,
|
|
type: JOB_STARTED,
|
|
timestamp: new Date(),
|
|
actor: "system",
|
|
payload: {},
|
|
};
|
|
|
|
mockPrisma.workspace.findUnique.mockResolvedValue({
|
|
id: workspaceId,
|
|
settings: { herald: { channelMappings: {} } },
|
|
});
|
|
|
|
mockPrisma.runnerJob.findUnique.mockResolvedValue({
|
|
id: jobId,
|
|
workspaceId,
|
|
type: "code-task",
|
|
});
|
|
|
|
mockPrisma.jobEvent.findFirst.mockResolvedValue({
|
|
payload: {
|
|
metadata: { threadId: "thread-123" },
|
|
},
|
|
});
|
|
|
|
mockDiscord.isConnected.mockReturnValue(true);
|
|
mockDiscord.sendThreadMessage.mockResolvedValue(undefined);
|
|
|
|
// Act
|
|
await service.broadcastJobEvent(jobId, event);
|
|
|
|
// Assert
|
|
expect(mockDiscord.sendThreadMessage).toHaveBeenCalledWith({
|
|
threadId: "thread-123",
|
|
content: expect.stringContaining("Job started"),
|
|
});
|
|
});
|
|
|
|
it("should broadcast job.completed event with success message", async () => {
|
|
// Arrange
|
|
const workspaceId = "workspace-1";
|
|
const jobId = "job-1";
|
|
const event = {
|
|
id: "event-1",
|
|
jobId,
|
|
type: JOB_COMPLETED,
|
|
timestamp: new Date(),
|
|
actor: "system",
|
|
payload: { duration: 120 },
|
|
};
|
|
|
|
mockPrisma.workspace.findUnique.mockResolvedValue({
|
|
id: workspaceId,
|
|
settings: { herald: { channelMappings: {} } },
|
|
});
|
|
|
|
mockPrisma.runnerJob.findUnique.mockResolvedValue({
|
|
id: jobId,
|
|
workspaceId,
|
|
type: "code-task",
|
|
});
|
|
|
|
mockPrisma.jobEvent.findFirst.mockResolvedValue({
|
|
payload: {
|
|
metadata: { threadId: "thread-123" },
|
|
},
|
|
});
|
|
|
|
mockDiscord.isConnected.mockReturnValue(true);
|
|
mockDiscord.sendThreadMessage.mockResolvedValue(undefined);
|
|
|
|
// Act
|
|
await service.broadcastJobEvent(jobId, event);
|
|
|
|
// Assert
|
|
expect(mockDiscord.sendThreadMessage).toHaveBeenCalledWith({
|
|
threadId: "thread-123",
|
|
content: expect.stringContaining("completed"),
|
|
});
|
|
});
|
|
|
|
it("should broadcast job.failed event with PDA-friendly language", async () => {
|
|
// Arrange
|
|
const workspaceId = "workspace-1";
|
|
const jobId = "job-1";
|
|
const event = {
|
|
id: "event-1",
|
|
jobId,
|
|
type: JOB_FAILED,
|
|
timestamp: new Date(),
|
|
actor: "system",
|
|
payload: { error: "Build failed" },
|
|
};
|
|
|
|
mockPrisma.workspace.findUnique.mockResolvedValue({
|
|
id: workspaceId,
|
|
settings: { herald: { channelMappings: {} } },
|
|
});
|
|
|
|
mockPrisma.runnerJob.findUnique.mockResolvedValue({
|
|
id: jobId,
|
|
workspaceId,
|
|
type: "code-task",
|
|
});
|
|
|
|
mockPrisma.jobEvent.findFirst.mockResolvedValue({
|
|
payload: {
|
|
metadata: { threadId: "thread-123" },
|
|
},
|
|
});
|
|
|
|
mockDiscord.isConnected.mockReturnValue(true);
|
|
mockDiscord.sendThreadMessage.mockResolvedValue(undefined);
|
|
|
|
// Act
|
|
await service.broadcastJobEvent(jobId, event);
|
|
|
|
// Assert
|
|
expect(mockDiscord.sendThreadMessage).toHaveBeenCalledWith({
|
|
threadId: "thread-123",
|
|
content: expect.stringContaining("encountered an issue"),
|
|
});
|
|
// Verify the actual message doesn't contain demanding language
|
|
const actualCall = mockDiscord.sendThreadMessage.mock.calls[0][0];
|
|
expect(actualCall.content).not.toMatch(/FAILED|ERROR|CRITICAL|URGENT/);
|
|
});
|
|
|
|
it("should skip broadcasting if Discord is not connected", async () => {
|
|
// Arrange
|
|
const workspaceId = "workspace-1";
|
|
const jobId = "job-1";
|
|
const event = {
|
|
id: "event-1",
|
|
jobId,
|
|
type: JOB_CREATED,
|
|
timestamp: new Date(),
|
|
actor: "system",
|
|
payload: {},
|
|
};
|
|
|
|
mockPrisma.workspace.findUnique.mockResolvedValue({
|
|
id: workspaceId,
|
|
settings: { herald: { channelMappings: {} } },
|
|
});
|
|
|
|
mockPrisma.runnerJob.findUnique.mockResolvedValue({
|
|
id: jobId,
|
|
workspaceId,
|
|
type: "code-task",
|
|
});
|
|
|
|
mockPrisma.jobEvent.findFirst.mockResolvedValue({
|
|
payload: {
|
|
metadata: { threadId: "thread-123" },
|
|
},
|
|
});
|
|
|
|
mockDiscord.isConnected.mockReturnValue(false);
|
|
|
|
// Act
|
|
await service.broadcastJobEvent(jobId, event);
|
|
|
|
// Assert
|
|
expect(mockDiscord.sendThreadMessage).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("should skip broadcasting if job has no threadId", async () => {
|
|
// Arrange
|
|
const workspaceId = "workspace-1";
|
|
const jobId = "job-1";
|
|
const event = {
|
|
id: "event-1",
|
|
jobId,
|
|
type: JOB_CREATED,
|
|
timestamp: new Date(),
|
|
actor: "system",
|
|
payload: {},
|
|
};
|
|
|
|
mockPrisma.workspace.findUnique.mockResolvedValue({
|
|
id: workspaceId,
|
|
settings: { herald: { channelMappings: {} } },
|
|
});
|
|
|
|
mockPrisma.runnerJob.findUnique.mockResolvedValue({
|
|
id: jobId,
|
|
workspaceId,
|
|
type: "code-task",
|
|
});
|
|
|
|
mockPrisma.jobEvent.findFirst.mockResolvedValue({
|
|
payload: {
|
|
metadata: {}, // No threadId
|
|
},
|
|
});
|
|
|
|
mockDiscord.isConnected.mockReturnValue(true);
|
|
|
|
// Act
|
|
await service.broadcastJobEvent(jobId, event);
|
|
|
|
// Assert
|
|
expect(mockDiscord.sendThreadMessage).not.toHaveBeenCalled();
|
|
});
|
|
|
|
// ERROR HANDLING TESTS - Issue #185
|
|
|
|
it("should propagate database errors when job lookup fails", async () => {
|
|
// Arrange
|
|
const jobId = "job-1";
|
|
const event = {
|
|
id: "event-1",
|
|
jobId,
|
|
type: JOB_CREATED,
|
|
timestamp: new Date(),
|
|
actor: "system",
|
|
payload: {},
|
|
};
|
|
|
|
const dbError = new Error("Database connection lost");
|
|
mockPrisma.runnerJob.findUnique.mockRejectedValue(dbError);
|
|
|
|
// Act & Assert
|
|
await expect(service.broadcastJobEvent(jobId, event)).rejects.toThrow(
|
|
"Database connection lost"
|
|
);
|
|
});
|
|
|
|
it("should propagate Discord send failures with context", async () => {
|
|
// Arrange
|
|
const workspaceId = "workspace-1";
|
|
const jobId = "job-1";
|
|
const event = {
|
|
id: "event-1",
|
|
jobId,
|
|
type: JOB_CREATED,
|
|
timestamp: new Date(),
|
|
actor: "system",
|
|
payload: {},
|
|
};
|
|
|
|
mockPrisma.runnerJob.findUnique.mockResolvedValue({
|
|
id: jobId,
|
|
workspaceId,
|
|
type: "code-task",
|
|
});
|
|
|
|
mockPrisma.jobEvent.findFirst.mockResolvedValue({
|
|
payload: {
|
|
metadata: { threadId: "thread-123" },
|
|
},
|
|
});
|
|
|
|
mockDiscord.isConnected.mockReturnValue(true);
|
|
|
|
const discordError = new Error("Rate limit exceeded");
|
|
mockDiscord.sendThreadMessage.mockRejectedValue(discordError);
|
|
|
|
// Act & Assert
|
|
await expect(service.broadcastJobEvent(jobId, event)).rejects.toThrow("Rate limit exceeded");
|
|
});
|
|
|
|
it("should propagate errors when fetching job events fails", async () => {
|
|
// Arrange
|
|
const workspaceId = "workspace-1";
|
|
const jobId = "job-1";
|
|
const event = {
|
|
id: "event-1",
|
|
jobId,
|
|
type: JOB_STARTED,
|
|
timestamp: new Date(),
|
|
actor: "system",
|
|
payload: {},
|
|
};
|
|
|
|
mockPrisma.runnerJob.findUnique.mockResolvedValue({
|
|
id: jobId,
|
|
workspaceId,
|
|
type: "code-task",
|
|
});
|
|
|
|
const dbError = new Error("Query timeout");
|
|
mockPrisma.jobEvent.findFirst.mockRejectedValue(dbError);
|
|
|
|
mockDiscord.isConnected.mockReturnValue(true);
|
|
|
|
// Act & Assert
|
|
await expect(service.broadcastJobEvent(jobId, event)).rejects.toThrow("Query timeout");
|
|
});
|
|
|
|
it("should include job context in error messages", async () => {
|
|
// Arrange
|
|
const workspaceId = "workspace-1";
|
|
const jobId = "test-job-123";
|
|
const event = {
|
|
id: "event-1",
|
|
jobId,
|
|
type: JOB_COMPLETED,
|
|
timestamp: new Date(),
|
|
actor: "system",
|
|
payload: {},
|
|
};
|
|
|
|
mockPrisma.runnerJob.findUnique.mockResolvedValue({
|
|
id: jobId,
|
|
workspaceId,
|
|
type: "code-task",
|
|
});
|
|
|
|
mockPrisma.jobEvent.findFirst.mockResolvedValue({
|
|
payload: {
|
|
metadata: { threadId: "thread-123" },
|
|
},
|
|
});
|
|
|
|
mockDiscord.isConnected.mockReturnValue(true);
|
|
|
|
const discordError = new Error("Network failure");
|
|
mockDiscord.sendThreadMessage.mockRejectedValue(discordError);
|
|
|
|
// Act & Assert
|
|
try {
|
|
await service.broadcastJobEvent(jobId, event);
|
|
// Should not reach here
|
|
expect(true).toBe(false);
|
|
} catch (error) {
|
|
// Verify error was thrown
|
|
expect(error).toBeDefined();
|
|
// Verify original error is preserved
|
|
expect((error as Error).message).toContain("Network failure");
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("formatJobEventMessage", () => {
|
|
it("should format job.created message with 10-second scannability", () => {
|
|
// Arrange
|
|
const event = {
|
|
id: "event-1",
|
|
jobId: "job-1",
|
|
type: JOB_CREATED,
|
|
timestamp: new Date("2026-01-01T12:00:00Z"),
|
|
actor: "system",
|
|
payload: { issueNumber: 42 },
|
|
};
|
|
|
|
const job = {
|
|
id: "job-1",
|
|
type: "code-task",
|
|
};
|
|
|
|
const metadata = { issueNumber: 42 };
|
|
|
|
// Act
|
|
const message = service.formatJobEventMessage(event, job, metadata);
|
|
|
|
// Assert
|
|
expect(message).toContain("🟢");
|
|
expect(message).toContain("Job created");
|
|
expect(message).toContain("#42");
|
|
expect(message.length).toBeLessThan(200); // Keep it scannable
|
|
});
|
|
|
|
it("should format job.created without issue number", () => {
|
|
// Arrange
|
|
const event = {
|
|
id: "event-1",
|
|
jobId: "job-1",
|
|
type: JOB_CREATED,
|
|
timestamp: new Date("2026-01-01T12:00:00Z"),
|
|
actor: "system",
|
|
payload: {},
|
|
};
|
|
|
|
const job = {
|
|
id: "job-1",
|
|
type: "code-task",
|
|
};
|
|
|
|
// Act
|
|
const message = service.formatJobEventMessage(event, job, undefined);
|
|
|
|
// Assert
|
|
expect(message).toContain("Job created");
|
|
expect(message).toContain("task");
|
|
expect(message).not.toContain("#");
|
|
});
|
|
|
|
it("should format job.completed message with visual indicator", () => {
|
|
// Arrange
|
|
const event = {
|
|
id: "event-1",
|
|
jobId: "job-1",
|
|
type: JOB_COMPLETED,
|
|
timestamp: new Date("2026-01-01T12:00:00Z"),
|
|
actor: "system",
|
|
payload: { duration: 120 },
|
|
};
|
|
|
|
const job = {
|
|
id: "job-1",
|
|
type: "code-task",
|
|
};
|
|
|
|
const metadata = { issueNumber: 42 };
|
|
|
|
// Act
|
|
const message = service.formatJobEventMessage(event, job, metadata);
|
|
|
|
// Assert
|
|
expect(message).toMatch(/✅|🟢/);
|
|
expect(message).toContain("completed");
|
|
expect(message).not.toMatch(/COMPLETED|SUCCESS/);
|
|
});
|
|
|
|
it("should format step.completed message", () => {
|
|
// Arrange
|
|
const event = {
|
|
id: "event-1",
|
|
jobId: "job-1",
|
|
stepId: "step-1",
|
|
type: STEP_COMPLETED,
|
|
timestamp: new Date("2026-01-01T12:00:00Z"),
|
|
actor: "system",
|
|
payload: { stepName: "Run tests" },
|
|
};
|
|
|
|
const job = {
|
|
id: "job-1",
|
|
type: "code-task",
|
|
};
|
|
|
|
const metadata = { issueNumber: 42 };
|
|
|
|
// Act
|
|
const message = service.formatJobEventMessage(event, job, metadata);
|
|
|
|
// Assert
|
|
expect(message).toContain("Step completed");
|
|
expect(message).toContain("Run tests");
|
|
});
|
|
|
|
it("should format step.started message", () => {
|
|
// Arrange
|
|
const event = {
|
|
id: "event-1",
|
|
jobId: "job-1",
|
|
stepId: "step-1",
|
|
type: STEP_STARTED,
|
|
timestamp: new Date("2026-01-01T12:00:00Z"),
|
|
actor: "system",
|
|
payload: { stepName: "Build project" },
|
|
};
|
|
|
|
const job = {
|
|
id: "job-1",
|
|
type: "code-task",
|
|
};
|
|
|
|
// Act
|
|
const message = service.formatJobEventMessage(event, job, {});
|
|
|
|
// Assert
|
|
expect(message).toContain("Step started");
|
|
expect(message).toContain("Build project");
|
|
});
|
|
|
|
it("should format step.started without step name", () => {
|
|
// Arrange
|
|
const event = {
|
|
id: "event-1",
|
|
jobId: "job-1",
|
|
stepId: "step-1",
|
|
type: STEP_STARTED,
|
|
timestamp: new Date("2026-01-01T12:00:00Z"),
|
|
actor: "system",
|
|
payload: {},
|
|
};
|
|
|
|
const job = {
|
|
id: "job-1",
|
|
type: "code-task",
|
|
};
|
|
|
|
// Act
|
|
const message = service.formatJobEventMessage(event, job, {});
|
|
|
|
// Assert
|
|
expect(message).toContain("Step started");
|
|
expect(message).toContain("unknown");
|
|
});
|
|
|
|
it("should format gate.passed message", () => {
|
|
// Arrange
|
|
const event = {
|
|
id: "event-1",
|
|
jobId: "job-1",
|
|
type: GATE_PASSED,
|
|
timestamp: new Date("2026-01-01T12:00:00Z"),
|
|
actor: "system",
|
|
payload: { gateName: "build" },
|
|
};
|
|
|
|
const job = {
|
|
id: "job-1",
|
|
type: "code-task",
|
|
};
|
|
|
|
const metadata = { issueNumber: 42 };
|
|
|
|
// Act
|
|
const message = service.formatJobEventMessage(event, job, metadata);
|
|
|
|
// Assert
|
|
expect(message).toContain("Gate passed");
|
|
expect(message).toContain("build");
|
|
});
|
|
|
|
it("should format gate.failed message with PDA-friendly language", () => {
|
|
// Arrange
|
|
const event = {
|
|
id: "event-1",
|
|
jobId: "job-1",
|
|
type: GATE_FAILED,
|
|
timestamp: new Date("2026-01-01T12:00:00Z"),
|
|
actor: "system",
|
|
payload: { gateName: "test", error: "2 tests failed" },
|
|
};
|
|
|
|
const job = {
|
|
id: "job-1",
|
|
type: "code-task",
|
|
};
|
|
|
|
const metadata = { issueNumber: 42 };
|
|
|
|
// Act
|
|
const message = service.formatJobEventMessage(event, job, metadata);
|
|
|
|
// Assert
|
|
expect(message).toContain("Gate needs attention");
|
|
expect(message).toContain("test");
|
|
expect(message).not.toMatch(/FAILED|ERROR|CRITICAL/);
|
|
});
|
|
|
|
it("should format gate.failed without error details", () => {
|
|
// Arrange
|
|
const event = {
|
|
id: "event-1",
|
|
jobId: "job-1",
|
|
type: GATE_FAILED,
|
|
timestamp: new Date("2026-01-01T12:00:00Z"),
|
|
actor: "system",
|
|
payload: { gateName: "lint" },
|
|
};
|
|
|
|
const job = {
|
|
id: "job-1",
|
|
type: "code-task",
|
|
};
|
|
|
|
// Act
|
|
const message = service.formatJobEventMessage(event, job, {});
|
|
|
|
// Assert
|
|
expect(message).toContain("Gate needs attention");
|
|
expect(message).toContain("lint");
|
|
expect(message).not.toContain("\n");
|
|
});
|
|
|
|
it("should format step.failed with error message", () => {
|
|
// Arrange
|
|
const event = {
|
|
id: "event-1",
|
|
jobId: "job-1",
|
|
stepId: "step-1",
|
|
type: "step.failed",
|
|
timestamp: new Date("2026-01-01T12:00:00Z"),
|
|
actor: "system",
|
|
payload: { stepName: "Deploy", error: "Connection timeout" },
|
|
};
|
|
|
|
const job = {
|
|
id: "job-1",
|
|
type: "code-task",
|
|
};
|
|
|
|
// Act
|
|
const message = service.formatJobEventMessage(event, job, {});
|
|
|
|
// Assert
|
|
expect(message).toContain("Step needs attention");
|
|
expect(message).toContain("Deploy");
|
|
expect(message).toContain("Connection timeout");
|
|
});
|
|
|
|
it("should format job.cancelled message", () => {
|
|
// Arrange
|
|
const event = {
|
|
id: "event-1",
|
|
jobId: "job-1",
|
|
type: "job.cancelled",
|
|
timestamp: new Date("2026-01-01T12:00:00Z"),
|
|
actor: "user",
|
|
payload: {},
|
|
};
|
|
|
|
const job = {
|
|
id: "job-1",
|
|
type: "code-task",
|
|
};
|
|
|
|
const metadata = { issueNumber: 123 };
|
|
|
|
// Act
|
|
const message = service.formatJobEventMessage(event, job, metadata);
|
|
|
|
// Assert
|
|
expect(message).toContain("Job paused");
|
|
expect(message).toContain("#123");
|
|
});
|
|
|
|
it("should format unknown event types", () => {
|
|
// Arrange
|
|
const event = {
|
|
id: "event-1",
|
|
jobId: "job-1",
|
|
type: "unknown.event.type",
|
|
timestamp: new Date("2026-01-01T12:00:00Z"),
|
|
actor: "system",
|
|
payload: {},
|
|
};
|
|
|
|
const job = {
|
|
id: "job-1",
|
|
type: "code-task",
|
|
};
|
|
|
|
// Act
|
|
const message = service.formatJobEventMessage(event, job, {});
|
|
|
|
// Assert
|
|
expect(message).toContain("Event: unknown.event.type");
|
|
});
|
|
});
|
|
|
|
describe("getChannelForJobType", () => {
|
|
it("should return channel from workspace settings", async () => {
|
|
// Arrange
|
|
const workspaceId = "workspace-1";
|
|
const jobType = "code-task";
|
|
|
|
mockPrisma.workspace.findUnique.mockResolvedValue({
|
|
id: workspaceId,
|
|
settings: {
|
|
herald: {
|
|
channelMappings: {
|
|
"code-task": "channel-123",
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
// Act
|
|
const channelId = await service.getChannelForJobType(workspaceId, jobType);
|
|
|
|
// Assert
|
|
expect(channelId).toBe("channel-123");
|
|
});
|
|
|
|
it("should return default channel if job type not mapped", async () => {
|
|
// Arrange
|
|
const workspaceId = "workspace-1";
|
|
const jobType = "code-task";
|
|
|
|
mockPrisma.workspace.findUnique.mockResolvedValue({
|
|
id: workspaceId,
|
|
settings: {
|
|
herald: {
|
|
channelMappings: {},
|
|
defaultChannel: "default-channel",
|
|
},
|
|
},
|
|
});
|
|
|
|
// Act
|
|
const channelId = await service.getChannelForJobType(workspaceId, jobType);
|
|
|
|
// Assert
|
|
expect(channelId).toBe("default-channel");
|
|
});
|
|
|
|
it("should return null if no channel configured", async () => {
|
|
// Arrange
|
|
const workspaceId = "workspace-1";
|
|
const jobType = "code-task";
|
|
|
|
mockPrisma.workspace.findUnique.mockResolvedValue({
|
|
id: workspaceId,
|
|
settings: {},
|
|
});
|
|
|
|
// Act
|
|
const channelId = await service.getChannelForJobType(workspaceId, jobType);
|
|
|
|
// Assert
|
|
expect(channelId).toBeNull();
|
|
});
|
|
});
|
|
});
|