Files
stack/apps/api/src/queue-notifications/queue-notifications.service.spec.ts
T

269 lines
8.6 KiB
TypeScript

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<string, unknown>;
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 });
});
});
});