import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; // --- Module mocks (must be declared before imports that reference them) --- const mockSql = Object.assign(vi.fn().mockResolvedValue(undefined), { end: vi.fn().mockResolvedValue(undefined), }); vi.mock("postgres", () => ({ default: vi.fn(() => mockSql) })); vi.mock("drizzle-orm/postgres-js", () => ({ drizzle: vi.fn(() => mockDb) })); vi.mock("@atbb/db", () => ({ default: {} })); const mockCreateCategory = vi.fn(); vi.mock("../lib/steps/create-category.js", () => ({ createCategory: (...args: unknown[]) => mockCreateCategory(...args), })); const mockForumAgentInstance = { initialize: vi.fn().mockResolvedValue(undefined), isAuthenticated: vi.fn().mockReturnValue(true), getAgent: vi.fn().mockReturnValue({}), getStatus: vi.fn().mockReturnValue({ status: "authenticated", error: undefined }), shutdown: vi.fn().mockResolvedValue(undefined), }; vi.mock("@atbb/atproto", () => ({ ForumAgent: vi.fn(() => mockForumAgentInstance), })); vi.mock("../lib/config.js", () => ({ loadCliConfig: vi.fn(() => ({ databaseUrl: "postgres://test", pdsUrl: "https://pds.test", forumDid: "did:plc:testforum", forumHandle: "forum.test", forumPassword: "secret", })), })); vi.mock("../lib/preflight.js", () => ({ checkEnvironment: vi.fn(() => ({ ok: true, errors: [] })), })); const mockInput = vi.fn(); vi.mock("@inquirer/prompts", () => ({ input: (...args: unknown[]) => mockInput(...args), select: vi.fn(), })); // --- Mock DB (shared, created before mock declarations above) --- const mockDb = {} as any; // --- Import the command under test (after all mocks are registered) --- import { categoryCommand } from "../commands/category.js"; describe("category add command", () => { let exitSpy: ReturnType; beforeEach(() => { vi.clearAllMocks(); // Turn process.exit into a catchable throw so tests don't die exitSpy = vi.spyOn(process, "exit").mockImplementation((code?: number | string | null) => { throw new Error(`process.exit:${code}`); }) as any; // Default: createCategory succeeds with a new category mockCreateCategory.mockResolvedValue({ created: true, skipped: false, uri: "at://did:plc:testforum/space.atbb.forum.category/tid123", cid: "bafytest", }); }); afterEach(() => { exitSpy.mockRestore(); }); // Helper to get the run function from the add subcommand function getAddRun() { return ((categoryCommand.subCommands as any).add as any).run as (ctx: { args: Record }) => Promise; } it("creates a category when name is provided as arg", async () => { const run = getAddRun(); await run({ args: { name: "General" } }); expect(mockCreateCategory).toHaveBeenCalledWith( mockDb, expect.anything(), "did:plc:testforum", expect.objectContaining({ name: "General" }) ); }); it("prompts for name when not provided as arg", async () => { mockInput.mockResolvedValueOnce("Interactive Category"); mockInput.mockResolvedValueOnce(""); // description prompt const run = getAddRun(); await run({ args: {} }); expect(mockInput).toHaveBeenCalledWith( expect.objectContaining({ message: "Category name:" }) ); expect(mockCreateCategory).toHaveBeenCalledWith( mockDb, expect.anything(), "did:plc:testforum", expect.objectContaining({ name: "Interactive Category" }) ); }); it("skips creating when category already exists", async () => { mockCreateCategory.mockResolvedValueOnce({ created: false, skipped: true, uri: "at://did:plc:testforum/space.atbb.forum.category/old", existingName: "General", }); const run = getAddRun(); await run({ args: { name: "General" } }); // Should not exit with error expect(exitSpy).not.toHaveBeenCalled(); }); it("exits when authentication succeeds but isAuthenticated returns false", async () => { mockForumAgentInstance.isAuthenticated.mockReturnValueOnce(false); mockForumAgentInstance.getStatus.mockReturnValueOnce({ status: "failed", error: "Invalid credentials" }); const run = getAddRun(); await expect(run({ args: { name: "General" } })).rejects.toThrow("process.exit:1"); expect(exitSpy).toHaveBeenCalledWith(1); expect(mockCreateCategory).not.toHaveBeenCalled(); }); it("exits when forumAgent.initialize throws (PDS unreachable)", async () => { mockForumAgentInstance.initialize.mockRejectedValueOnce( new Error("fetch failed") ); const run = getAddRun(); await expect(run({ args: { name: "General" } })).rejects.toThrow( "process.exit:1" ); expect(exitSpy).toHaveBeenCalledWith(1); }); it("exits when createCategory fails (runtime error)", async () => { mockCreateCategory.mockRejectedValueOnce(new Error("PDS write failed")); const run = getAddRun(); await expect(run({ args: { name: "General" } })).rejects.toThrow( "process.exit:1" ); expect(exitSpy).toHaveBeenCalledWith(1); }); it("re-throws programming errors from createCategory", async () => { mockCreateCategory.mockRejectedValueOnce( new TypeError("Cannot read properties of undefined") ); const run = getAddRun(); await expect(run({ args: { name: "General" } })).rejects.toThrow(TypeError); // process.exit should NOT have been called for a programming error expect(exitSpy).not.toHaveBeenCalled(); }); });