269 lines
8.6 KiB
TypeScript
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 });
|
|
});
|
|
});
|
|
});
|