import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; // --- Module mocks --- 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: {}, categories: "categories_table", })); vi.mock("drizzle-orm", () => ({ eq: vi.fn(), and: vi.fn(), })); const mockCreateBoard = vi.fn(); vi.mock("../lib/steps/create-board.js", () => ({ createBoard: (...args: unknown[]) => mockCreateBoard(...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(); const mockSelect = vi.fn(); vi.mock("@inquirer/prompts", () => ({ input: (...args: unknown[]) => mockInput(...args), select: (...args: unknown[]) => mockSelect(...args), })); // Mock DB with a category lookup that succeeds by default const mockCategoryRow = { id: BigInt(42), did: "did:plc:testforum", rkey: "cattid", cid: "bafycategory", name: "General", }; const mockDb = { select: vi.fn().mockReturnValue({ from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([mockCategoryRow]), }), }), }), } as any; import { boardCommand } from "../commands/board.js"; const VALID_CATEGORY_URI = `at://did:plc:testforum/space.atbb.forum.category/cattid`; describe("board add command", () => { let exitSpy: ReturnType; function getAddRun() { return ((boardCommand.subCommands as any).add as any).run as (ctx: { args: Record }) => Promise; } beforeEach(() => { vi.clearAllMocks(); exitSpy = vi.spyOn(process, "exit").mockImplementation((code?: number | string | null) => { throw new Error(`process.exit:${code}`); }) as any; // Restore DB mock after clearAllMocks mockDb.select.mockReturnValue({ from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([mockCategoryRow]), }), }), }); // Default: createBoard succeeds mockCreateBoard.mockResolvedValue({ created: true, skipped: false, uri: "at://did:plc:testforum/space.atbb.forum.board/tid456", cid: "bafyboard", }); // Default: prompts return values mockInput.mockResolvedValue(""); }); afterEach(() => { exitSpy.mockRestore(); }); it("creates a board when category-uri and name are provided as args", async () => { const run = getAddRun(); await run({ args: { "category-uri": VALID_CATEGORY_URI, name: "General Discussion" } }); expect(mockCreateBoard).toHaveBeenCalledWith( mockDb, expect.anything(), "did:plc:testforum", expect.objectContaining({ name: "General Discussion", categoryUri: VALID_CATEGORY_URI, categoryId: BigInt(42), categoryCid: "bafycategory", }) ); }); it("exits with error when AT URI has wrong collection (not space.atbb.forum.category)", async () => { const wrongUri = "at://did:plc:testforum/space.atbb.forum.board/cattid"; const run = getAddRun(); await expect( run({ args: { "category-uri": wrongUri, name: "Test" } }) ).rejects.toThrow("process.exit:1"); expect(exitSpy).toHaveBeenCalledWith(1); // Board creation should never be called expect(mockCreateBoard).not.toHaveBeenCalled(); }); it("exits with error when AT URI format is invalid (missing parts)", async () => { const run = getAddRun(); await expect( run({ args: { "category-uri": "not-an-at-uri", name: "Test" } }) ).rejects.toThrow("process.exit:1"); expect(exitSpy).toHaveBeenCalledWith(1); expect(mockCreateBoard).not.toHaveBeenCalled(); }); it("exits with error when category URI is not found in DB", async () => { mockDb.select.mockReturnValueOnce({ from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([]), // category not found }), }), }); const run = getAddRun(); await expect( run({ args: { "category-uri": VALID_CATEGORY_URI, name: "Test" } }) ).rejects.toThrow("process.exit:1"); expect(mockCreateBoard).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: { "category-uri": VALID_CATEGORY_URI, name: "Test" } }) ).rejects.toThrow("process.exit:1"); expect(exitSpy).toHaveBeenCalledWith(1); expect(mockCreateBoard).not.toHaveBeenCalled(); }); it("uses interactive select when no category-uri arg is provided", async () => { mockSelect.mockResolvedValueOnce(mockCategoryRow); const run = getAddRun(); await run({ args: { name: "Test Board" } }); expect(mockSelect).toHaveBeenCalled(); expect(mockCreateBoard).toHaveBeenCalledWith( mockDb, expect.anything(), "did:plc:testforum", expect.objectContaining({ categoryUri: `at://${mockCategoryRow.did}/space.atbb.forum.category/${mockCategoryRow.rkey}`, categoryId: mockCategoryRow.id, categoryCid: mockCategoryRow.cid, }) ); }); it("exits when no categories exist in DB and no category-uri provided", async () => { mockDb.select.mockReturnValueOnce({ from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([]), // empty categories list }), }), }); const run = getAddRun(); await expect(run({ args: { name: "Test Board" } })).rejects.toThrow("process.exit:1"); expect(exitSpy).toHaveBeenCalledWith(1); expect(mockCreateBoard).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: { "category-uri": VALID_CATEGORY_URI, name: "Test" } }) ).rejects.toThrow("process.exit:1"); expect(exitSpy).toHaveBeenCalledWith(1); }); it("exits when createBoard fails (runtime error)", async () => { mockCreateBoard.mockRejectedValueOnce(new Error("PDS write failed")); const run = getAddRun(); await expect( run({ args: { "category-uri": VALID_CATEGORY_URI, name: "Test Board" } }) ).rejects.toThrow("process.exit:1"); expect(exitSpy).toHaveBeenCalledWith(1); }); it("re-throws programming errors from createBoard", async () => { mockCreateBoard.mockRejectedValueOnce( new TypeError("Cannot read properties of undefined") ); const run = getAddRun(); await expect( run({ args: { "category-uri": VALID_CATEGORY_URI, name: "Test Board" } }) ).rejects.toThrow(TypeError); expect(exitSpy).not.toHaveBeenCalled(); }); it("skips creating when board already exists in category", async () => { mockCreateBoard.mockResolvedValueOnce({ created: false, skipped: true, uri: "at://did:plc:testforum/space.atbb.forum.board/existing", existingName: "General Discussion", }); const run = getAddRun(); await run({ args: { "category-uri": VALID_CATEGORY_URI, name: "General Discussion" } }); expect(exitSpy).not.toHaveBeenCalled(); }); });