import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ConfigService } from "@nestjs/config"; import { Logger, NotFoundException } from "@nestjs/common"; import { mkdtemp, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { execFile } from "node:child_process"; import { QueueNotificationsService } from "./queue-notifications.service"; vi.mock("node:child_process", () => ({ execFile: vi.fn(), })); describe("QueueNotificationsService", () => { let service: QueueNotificationsService; let inboxDir: string; let agentStateDir: string; let configService: ConfigService; beforeEach(async () => { vi.clearAllMocks(); inboxDir = await mkdtemp(join(tmpdir(), "queue-notifications-")); agentStateDir = await mkdtemp(join(tmpdir(), "agent-state-")); configService = { get: vi.fn((key: string) => { if (key === "MOSAIC_QUEUE_INBOX_DIR") { return inboxDir; } if (key === "MOSAIC_AGENT_STATE_DIR") { return agentStateDir; } if (key === "MOSAIC_QUEUE_CLI") { return "/tmp/mosaic-queue-cli.js"; } return undefined; }), } as unknown as ConfigService; service = new QueueNotificationsService(configService); }); afterEach(async () => { vi.restoreAllMocks(); await rm(inboxDir, { recursive: true, force: true }); await rm(agentStateDir, { recursive: true, force: true }); }); describe("onModuleInit", () => { it("logs a warning when the inbox directory does not exist", async () => { await rm(inboxDir, { recursive: true, force: true }); const warnSpy = vi.spyOn(Logger.prototype, "warn").mockImplementation(() => undefined); await service.onModuleInit(); expect(warnSpy).toHaveBeenCalledWith( expect.stringContaining("Queue notifications inbox directory does not exist") ); }); }); describe("listNotifications", () => { it("returns parsed notifications from agent inbox directories", async () => { await mkdir(join(inboxDir, "mosaic"), { recursive: true }); await mkdir(join(inboxDir, "mosaic", "_acked"), { recursive: true }); await mkdir(join(inboxDir, "sage"), { recursive: true }); await writeFile( join(inboxDir, "mosaic", "notif-1.json"), JSON.stringify({ type: "task.ready", taskId: "MS24-API-001" }) ); await writeFile( join(inboxDir, "mosaic", "_acked", "notif-ignored.json"), JSON.stringify({ ignored: true }) ); await writeFile(join(inboxDir, "sage", "notif-2.json"), JSON.stringify({ type: "done" })); const notifications = await service.listNotifications(); expect(notifications).toHaveLength(2); expect(notifications).toEqual( expect.arrayContaining([ expect.objectContaining({ id: "notif-1", agent: "mosaic", filename: "notif-1.json", payload: { type: "task.ready", taskId: "MS24-API-001" }, }), expect.objectContaining({ id: "notif-2", agent: "sage", filename: "notif-2.json", payload: { type: "done" }, }), ]) ); }); it("returns an empty array when the inbox directory is missing", async () => { await rm(inboxDir, { recursive: true, force: true }); await expect(service.listNotifications()).resolves.toEqual([]); }); }); describe("ackNotification", () => { it("executes the queue CLI with node and ack args", async () => { await mkdir(join(inboxDir, "mosaic"), { recursive: true }); await writeFile(join(inboxDir, "mosaic", "notif-3.json"), JSON.stringify({ ok: true })); vi.mocked(execFile).mockImplementation( ( _command: string, _args: readonly string[], callback: (error: Error | null, stdout: string, stderr: string) => void ) => callback(null, "acked", "") ); await expect(service.ackNotification("notif-3")).resolves.toEqual({ success: true, id: "notif-3", }); expect(execFile).toHaveBeenCalledWith( "node", ["/tmp/mosaic-queue-cli.js", "ack", "notif-3"], expect.any(Function) ); }); it("throws NotFoundException when the notification does not exist", async () => { await expect(service.ackNotification("missing")).rejects.toThrow(NotFoundException); expect(execFile).not.toHaveBeenCalled(); }); }); describe("listTasks", () => { it("parses tab-separated CLI output", async () => { vi.mocked(execFile).mockImplementation( ( _command: string, _args: readonly string[], callback: (error: Error | null, stdout: string, stderr: string) => void ) => callback( null, [ "task-1\tmosaic-stack/MS24-API-001\t[pending]\tBuild queue notifications module", "task-2\tmosaic-stack/MS24-API-002\t[done]\tWrite tests", ].join("\n"), "" ) ); await expect(service.listTasks()).resolves.toEqual([ { id: "task-1", project: "mosaic-stack", taskId: "MS24-API-001", status: "pending", description: "Build queue notifications module", }, { id: "task-2", project: "mosaic-stack", taskId: "MS24-API-002", status: "done", description: "Write tests", }, ]); expect(execFile).toHaveBeenCalledWith( "node", ["/tmp/mosaic-queue-cli.js", "list", "mosaic-stack"], expect.any(Function) ); }); }); describe("notifyAgentCiResult", () => { it("writes one notification per active agent-state record matching the branch", async () => { await mkdir(join(agentStateDir, "active"), { recursive: true }); await writeFile( join(agentStateDir, "active", "sage-landing-page.json"), JSON.stringify({ taskId: "sage-landing-page", status: "spawned", startedAt: "2026-03-08T22:47:20Z", lastUpdated: "2026-03-08T23:15:11.726Z", agent: "sage", branch: "feature/landing-page", description: "Create apps/landing marketing site", }) ); await writeFile( join(agentStateDir, "active", "pixels-other.json"), JSON.stringify({ taskId: "pixels-other", status: "spawned", startedAt: "2026-03-08T22:47:20Z", lastUpdated: "2026-03-08T23:15:11.726Z", agent: "pixels", branch: "feature/something-else", description: "Unrelated", }) ); await expect( service.notifyAgentCiResult({ branch: "feature/landing-page", status: "success", buildUrl: "https://ci.example/build/123", repo: "mosaic/stack", }) ).resolves.toEqual({ notified: 1 }); const inboxFiles = await readdir(join(inboxDir, "sage")); expect(inboxFiles).toHaveLength(1); const notification = JSON.parse( await readFile(join(inboxDir, "sage", inboxFiles[0]!), "utf8") ) as Record; expect(notification).toMatchObject({ taskId: "sage-landing-page", event: "completed", targetAgent: "sage", fromAgent: "mosaic-api", retries: 0, maxRetries: 3, ttlSeconds: 600, payload: { branch: "feature/landing-page", buildUrl: "https://ci.example/build/123", repo: "mosaic/stack", ciStatus: "success", }, }); expect(typeof notification.id).toBe("string"); expect(typeof notification.createdAt).toBe("string"); }); it("returns zero when no active agent-state branch matches the webhook payload", async () => { await mkdir(join(agentStateDir, "active"), { recursive: true }); await writeFile( join(agentStateDir, "active", "mosaic-task.json"), JSON.stringify({ taskId: "mosaic-task", status: "spawned", startedAt: "2026-03-08T22:47:20Z", lastUpdated: "2026-03-08T23:15:11.726Z", agent: "mosaic", branch: "feature/not-this-one", description: "Unrelated", }) ); await expect( service.notifyAgentCiResult({ branch: "feature/landing-page", status: "failure", buildUrl: "https://ci.example/build/456", repo: "mosaic/stack", }) ).resolves.toEqual({ notified: 0 }); }); }); });