Files
stack/apps/api/src/herald/herald.service.spec.ts
Jason Woltje ad24720616
Some checks failed
ci/woodpecker/push/api Pipeline failed
feat(#382): Herald Service: broadcast to all active chat providers
- Replace direct DiscordService injection with CHAT_PROVIDERS array
- Herald broadcasts to ALL active chat providers (Discord, Matrix, future)
- Graceful error handling — one provider failure doesn't block others
- Skips disconnected providers automatically
- Tests verify multi-provider broadcasting behavior
- Fix lint: remove unnecessary conditional in matrix.service.ts

Refs #382
2026-02-15 02:25:55 -06:00

799 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 { CHAT_PROVIDERS } from "../bridge/bridge.constants";
import type { IChatProvider } from "../bridge/interfaces/chat-provider.interface";
import {
JOB_CREATED,
JOB_STARTED,
JOB_COMPLETED,
JOB_FAILED,
STEP_STARTED,
STEP_COMPLETED,
GATE_PASSED,
GATE_FAILED,
} from "../job-events/event-types";
function createMockProvider(
name: string,
connected = true
): IChatProvider & {
sendMessage: ReturnType<typeof vi.fn>;
sendThreadMessage: ReturnType<typeof vi.fn>;
createThread: ReturnType<typeof vi.fn>;
isConnected: ReturnType<typeof vi.fn>;
connect: ReturnType<typeof vi.fn>;
disconnect: ReturnType<typeof vi.fn>;
parseCommand: ReturnType<typeof vi.fn>;
} {
return {
connect: vi.fn().mockResolvedValue(undefined),
disconnect: vi.fn().mockResolvedValue(undefined),
isConnected: vi.fn().mockReturnValue(connected),
sendMessage: vi.fn().mockResolvedValue(undefined),
createThread: vi.fn().mockResolvedValue("thread-id"),
sendThreadMessage: vi.fn().mockResolvedValue(undefined),
parseCommand: vi.fn().mockReturnValue(null),
};
}
describe("HeraldService", () => {
let service: HeraldService;
const mockPrisma = {
workspace: {
findUnique: vi.fn(),
},
runnerJob: {
findUnique: vi.fn(),
},
jobEvent: {
findFirst: vi.fn(),
},
};
let mockProviderA: ReturnType<typeof createMockProvider>;
let mockProviderB: ReturnType<typeof createMockProvider>;
let chatProviders: IChatProvider[];
beforeEach(async () => {
mockProviderA = createMockProvider("providerA", true);
mockProviderB = createMockProvider("providerB", true);
chatProviders = [mockProviderA, mockProviderB];
const module: TestingModule = await Test.createTestingModule({
providers: [
HeraldService,
{
provide: PrismaService,
useValue: mockPrisma,
},
{
provide: CHAT_PROVIDERS,
useValue: chatProviders,
},
],
}).compile();
service = module.get<HeraldService>(HeraldService);
// Reset mocks
vi.clearAllMocks();
// Restore default connected state after clearAllMocks
mockProviderA.isConnected.mockReturnValue(true);
mockProviderB.isConnected.mockReturnValue(true);
});
describe("broadcastJobEvent", () => {
const baseSetup = (): {
jobId: string;
workspaceId: string;
} => {
const workspaceId = "workspace-1";
const jobId = "job-1";
mockPrisma.runnerJob.findUnique.mockResolvedValue({
id: jobId,
workspaceId,
type: "code-task",
});
mockPrisma.jobEvent.findFirst.mockResolvedValue({
payload: {
metadata: { issueNumber: 42, threadId: "thread-123" },
},
});
return { jobId, workspaceId };
};
it("should broadcast to all connected providers", async () => {
// Arrange
const { jobId } = baseSetup();
const event = {
id: "event-1",
jobId,
type: JOB_CREATED,
timestamp: new Date(),
actor: "system",
payload: { issueNumber: 42 },
};
// Act
await service.broadcastJobEvent(jobId, event);
// Assert
expect(mockProviderA.sendThreadMessage).toHaveBeenCalledWith({
threadId: "thread-123",
content: expect.stringContaining("Job created"),
});
expect(mockProviderB.sendThreadMessage).toHaveBeenCalledWith({
threadId: "thread-123",
content: expect.stringContaining("Job created"),
});
});
it("should broadcast job.started event to all providers", async () => {
// Arrange
const { jobId } = baseSetup();
const event = {
id: "event-1",
jobId,
type: JOB_STARTED,
timestamp: new Date(),
actor: "system",
payload: {},
};
// Act
await service.broadcastJobEvent(jobId, event);
// Assert
expect(mockProviderA.sendThreadMessage).toHaveBeenCalledWith({
threadId: "thread-123",
content: expect.stringContaining("Job started"),
});
expect(mockProviderB.sendThreadMessage).toHaveBeenCalledWith({
threadId: "thread-123",
content: expect.stringContaining("Job started"),
});
});
it("should broadcast job.completed event with success message", async () => {
// Arrange
const { jobId } = baseSetup();
const event = {
id: "event-1",
jobId,
type: JOB_COMPLETED,
timestamp: new Date(),
actor: "system",
payload: { duration: 120 },
};
// Act
await service.broadcastJobEvent(jobId, event);
// Assert
expect(mockProviderA.sendThreadMessage).toHaveBeenCalledWith({
threadId: "thread-123",
content: expect.stringContaining("completed"),
});
});
it("should broadcast job.failed event with PDA-friendly language", async () => {
// Arrange
const { jobId } = baseSetup();
const event = {
id: "event-1",
jobId,
type: JOB_FAILED,
timestamp: new Date(),
actor: "system",
payload: { error: "Build failed" },
};
// Act
await service.broadcastJobEvent(jobId, event);
// Assert
expect(mockProviderA.sendThreadMessage).toHaveBeenCalledWith({
threadId: "thread-123",
content: expect.stringContaining("encountered an issue"),
});
// Verify the actual message doesn't contain demanding language
const actualCall = mockProviderA.sendThreadMessage.mock.calls[0][0] as {
threadId: string;
content: string;
};
expect(actualCall.content).not.toMatch(/FAILED|ERROR|CRITICAL|URGENT/);
});
it("should skip disconnected providers", async () => {
// Arrange
const { jobId } = baseSetup();
mockProviderA.isConnected.mockReturnValue(true);
mockProviderB.isConnected.mockReturnValue(false);
const event = {
id: "event-1",
jobId,
type: JOB_CREATED,
timestamp: new Date(),
actor: "system",
payload: {},
};
// Act
await service.broadcastJobEvent(jobId, event);
// Assert
expect(mockProviderA.sendThreadMessage).toHaveBeenCalledTimes(1);
expect(mockProviderB.sendThreadMessage).not.toHaveBeenCalled();
});
it("should handle empty providers array without crashing", async () => {
// Arrange — rebuild module with empty providers
const module: TestingModule = await Test.createTestingModule({
providers: [
HeraldService,
{
provide: PrismaService,
useValue: mockPrisma,
},
{
provide: CHAT_PROVIDERS,
useValue: [],
},
],
}).compile();
const emptyService = module.get<HeraldService>(HeraldService);
const jobId = "job-1";
mockPrisma.runnerJob.findUnique.mockResolvedValue({
id: jobId,
workspaceId: "workspace-1",
type: "code-task",
});
mockPrisma.jobEvent.findFirst.mockResolvedValue({
payload: {
metadata: { threadId: "thread-123" },
},
});
const event = {
id: "event-1",
jobId,
type: JOB_CREATED,
timestamp: new Date(),
actor: "system",
payload: {},
};
// Act & Assert — should not throw
await expect(emptyService.broadcastJobEvent(jobId, event)).resolves.not.toThrow();
});
it("should continue broadcasting when one provider errors", async () => {
// Arrange
const { jobId } = baseSetup();
mockProviderA.sendThreadMessage.mockRejectedValue(new Error("Provider A rate limit"));
mockProviderB.sendThreadMessage.mockResolvedValue(undefined);
const event = {
id: "event-1",
jobId,
type: JOB_CREATED,
timestamp: new Date(),
actor: "system",
payload: {},
};
// Act — should not throw despite provider A failing
await service.broadcastJobEvent(jobId, event);
// Assert — provider B should still have been called
expect(mockProviderA.sendThreadMessage).toHaveBeenCalledTimes(1);
expect(mockProviderB.sendThreadMessage).toHaveBeenCalledTimes(1);
});
it("should not throw when all providers error", async () => {
// Arrange
const { jobId } = baseSetup();
mockProviderA.sendThreadMessage.mockRejectedValue(new Error("Provider A down"));
mockProviderB.sendThreadMessage.mockRejectedValue(new Error("Provider B down"));
const event = {
id: "event-1",
jobId,
type: JOB_CREATED,
timestamp: new Date(),
actor: "system",
payload: {},
};
// Act & Assert — should not throw; provider errors are logged, not propagated
await expect(service.broadcastJobEvent(jobId, event)).resolves.not.toThrow();
});
it("should skip broadcasting if job has no threadId", async () => {
// Arrange
const jobId = "job-1";
mockPrisma.runnerJob.findUnique.mockResolvedValue({
id: jobId,
workspaceId: "workspace-1",
type: "code-task",
});
mockPrisma.jobEvent.findFirst.mockResolvedValue({
payload: {
metadata: {}, // No threadId
},
});
const event = {
id: "event-1",
jobId,
type: JOB_CREATED,
timestamp: new Date(),
actor: "system",
payload: {},
};
// Act
await service.broadcastJobEvent(jobId, event);
// Assert
expect(mockProviderA.sendThreadMessage).not.toHaveBeenCalled();
expect(mockProviderB.sendThreadMessage).not.toHaveBeenCalled();
});
it("should skip broadcasting if job not found", async () => {
// Arrange
const jobId = "nonexistent-job";
mockPrisma.runnerJob.findUnique.mockResolvedValue(null);
const event = {
id: "event-1",
jobId,
type: JOB_CREATED,
timestamp: new Date(),
actor: "system",
payload: {},
};
// Act
await service.broadcastJobEvent(jobId, event);
// Assert
expect(mockProviderA.sendThreadMessage).not.toHaveBeenCalled();
});
// ERROR HANDLING TESTS - database errors should still propagate
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 errors when fetching job events fails", async () => {
// Arrange
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: "workspace-1",
type: "code-task",
});
const dbError = new Error("Query timeout");
mockPrisma.jobEvent.findFirst.mockRejectedValue(dbError);
// Act & Assert
await expect(service.broadcastJobEvent(jobId, event)).rejects.toThrow("Query timeout");
});
});
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("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).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();
});
});
});