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); prisma = module.get(PrismaService); discord = module.get(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(); }); }); });