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 mockCreateForumRecord = vi.fn(); vi.mock("../lib/steps/create-forum.js", () => ({ createForumRecord: (...args: unknown[]) => mockCreateForumRecord(...args), })); const mockSeedDefaultRoles = vi.fn(); vi.mock("../lib/steps/seed-roles.js", () => ({ seedDefaultRoles: (...args: unknown[]) => mockSeedDefaultRoles(...args), })); const mockAssignOwnerRole = vi.fn(); vi.mock("../lib/steps/assign-owner.js", () => ({ assignOwnerRole: (...args: unknown[]) => mockAssignOwnerRole(...args), })); const mockCreateCategory = vi.fn(); vi.mock("../lib/steps/create-category.js", () => ({ createCategory: (...args: unknown[]) => mockCreateCategory(...args), })); 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), resolveIdentity: vi.fn().mockResolvedValue({ did: "did:plc:owner", handle: "owner.test" }), })); 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 mockConfirm = vi.fn(); vi.mock("@inquirer/prompts", () => ({ input: (...args: unknown[]) => mockInput(...args), confirm: (...args: unknown[]) => mockConfirm(...args), })); // Mock DB: the category ID lookup in Step 4 is a select().from().where().limit() const mockCategoryIdRow = { id: BigInt(99) }; const mockDb = { select: vi.fn().mockReturnValue({ from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([mockCategoryIdRow]), }), }), }), } as any; import { initCommand } from "../commands/init.js"; function getInitRun() { return (initCommand as any).run as (ctx: { args: Record }) => Promise; } // Helpers that set up the happy-path mocks for Steps 1-3. // All tests pass forum-name and owner as args, so only forum-description needs // a prompt in Steps 1-3. function setupSteps1to3() { mockCreateForumRecord.mockResolvedValue({ created: true, skipped: false, uri: "at://f/forum/self" }); const ownerRole = { name: "Owner", uri: "at://f/space.atbb.forum.role/tid1" }; mockSeedDefaultRoles.mockResolvedValue({ created: 3, skipped: 0, roles: [ownerRole] }); mockAssignOwnerRole.mockResolvedValue({ skipped: false }); // Only one prompt consumed in steps 1-3 (forum description; name+owner come from args) mockInput.mockResolvedValueOnce(""); // forum description } describe("init command — Step 4 (seed initial structure)", () => { let exitSpy: ReturnType; 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([mockCategoryIdRow]), }), }), }); }); afterEach(() => { exitSpy.mockRestore(); }); it("skips category and board creation when user declines seeding", async () => { setupSteps1to3(); mockConfirm.mockResolvedValueOnce(false); // "Seed an initial category and board?" → No const run = getInitRun(); await run({ args: { "forum-name": "Test Forum", owner: "owner.test" } }); expect(mockCreateCategory).not.toHaveBeenCalled(); expect(mockCreateBoard).not.toHaveBeenCalled(); expect(exitSpy).not.toHaveBeenCalled(); }); it("creates category and board when user confirms seeding", async () => { setupSteps1to3(); mockConfirm.mockResolvedValueOnce(true); mockCreateCategory.mockResolvedValueOnce({ created: true, skipped: false, uri: "at://did:plc:testforum/space.atbb.forum.category/cattid", cid: "bafycat", }); mockCreateBoard.mockResolvedValueOnce({ created: true, skipped: false, uri: "at://did:plc:testforum/space.atbb.forum.board/boardtid", cid: "bafyboard", }); // Prompts for step 4 (appended after the forum-description prompt from setupSteps1to3) mockInput .mockResolvedValueOnce("General") // category name .mockResolvedValueOnce("") // category description .mockResolvedValueOnce("General Discussion") // board name .mockResolvedValueOnce(""); // board description const run = getInitRun(); await run({ args: { "forum-name": "Test Forum", owner: "owner.test" } }); expect(mockCreateCategory).toHaveBeenCalledWith( mockDb, expect.anything(), "did:plc:testforum", expect.objectContaining({ name: "General" }) ); expect(mockCreateBoard).toHaveBeenCalledWith( mockDb, expect.anything(), "did:plc:testforum", expect.objectContaining({ categoryUri: "at://did:plc:testforum/space.atbb.forum.category/cattid", categoryId: BigInt(99), categoryCid: "bafycat", }) ); expect(exitSpy).not.toHaveBeenCalled(); }); it("exits if categoryId cannot be found in DB after category creation", async () => { setupSteps1to3(); mockConfirm.mockResolvedValueOnce(true); mockCreateCategory.mockResolvedValueOnce({ created: true, skipped: false, uri: "at://did:plc:testforum/space.atbb.forum.category/cattid", cid: "bafycat", }); // DB lookup returns empty (simulates race condition or failed insert) mockDb.select.mockReturnValueOnce({ from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([]), // no category row found }), }), }); // Prompts for step 4 mockInput .mockResolvedValueOnce("General") .mockResolvedValueOnce(""); const run = getInitRun(); await expect(run({ args: { "forum-name": "Test Forum", owner: "owner.test" } })).rejects.toThrow( "process.exit:1" ); // Board creation should NOT be called when categoryId is missing expect(mockCreateBoard).not.toHaveBeenCalled(); }); it("exits when createCategory fails in Step 4 (runtime error)", async () => { setupSteps1to3(); mockConfirm.mockResolvedValueOnce(true); mockCreateCategory.mockRejectedValueOnce(new Error("PDS write failed")); mockInput .mockResolvedValueOnce("General") .mockResolvedValueOnce(""); const run = getInitRun(); await expect(run({ args: { "forum-name": "Test Forum", owner: "owner.test" } })).rejects.toThrow( "process.exit:1" ); expect(mockCreateBoard).not.toHaveBeenCalled(); }); it("exits when createBoard fails in Step 4 (runtime error)", async () => { setupSteps1to3(); mockConfirm.mockResolvedValueOnce(true); mockCreateCategory.mockResolvedValueOnce({ created: true, skipped: false, uri: "at://did:plc:testforum/space.atbb.forum.category/cattid", cid: "bafycat", }); mockCreateBoard.mockRejectedValueOnce(new Error("Board PDS write failed")); mockInput .mockResolvedValueOnce("General") .mockResolvedValueOnce("") .mockResolvedValueOnce("General Discussion") .mockResolvedValueOnce(""); const run = getInitRun(); await expect(run({ args: { "forum-name": "Test Forum", owner: "owner.test" } })).rejects.toThrow( "process.exit:1" ); expect(exitSpy).toHaveBeenCalledWith(1); }); it("re-throws programming errors from createCategory in Step 4", async () => { setupSteps1to3(); mockConfirm.mockResolvedValueOnce(true); mockCreateCategory.mockRejectedValueOnce( new TypeError("Cannot read properties of undefined") ); mockInput .mockResolvedValueOnce("General") .mockResolvedValueOnce(""); const run = getInitRun(); await expect(run({ args: { "forum-name": "Test Forum", owner: "owner.test" } })).rejects.toThrow(TypeError); expect(exitSpy).not.toHaveBeenCalled(); }); it("re-throws programming errors from createBoard in Step 4", async () => { setupSteps1to3(); mockConfirm.mockResolvedValueOnce(true); mockCreateCategory.mockResolvedValueOnce({ created: true, skipped: false, uri: "at://did:plc:testforum/space.atbb.forum.category/cattid", cid: "bafycat", }); mockCreateBoard.mockRejectedValueOnce( new TypeError("Cannot read properties of undefined") ); mockInput .mockResolvedValueOnce("General") .mockResolvedValueOnce("") .mockResolvedValueOnce("General Discussion") .mockResolvedValueOnce(""); const run = getInitRun(); await expect(run({ args: { "forum-name": "Test Forum", owner: "owner.test" } })).rejects.toThrow(TypeError); expect(exitSpy).not.toHaveBeenCalled(); }); it("exits with accurate message when categoryId DB re-query throws", async () => { setupSteps1to3(); mockConfirm.mockResolvedValueOnce(true); mockCreateCategory.mockResolvedValueOnce({ created: true, skipped: false, uri: "at://did:plc:testforum/space.atbb.forum.category/cattid", cid: "bafycat", }); // First select call (categoryId re-query) throws mockDb.select.mockReturnValueOnce({ from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ limit: vi.fn().mockRejectedValue(new Error("DB connection lost")), }), }), }); mockInput .mockResolvedValueOnce("General") .mockResolvedValueOnce(""); const run = getInitRun(); await expect(run({ args: { "forum-name": "Test Forum", owner: "owner.test" } })).rejects.toThrow( "process.exit:1" ); // Board creation should NOT be attempted after re-query failure expect(mockCreateBoard).not.toHaveBeenCalled(); }); });