WIP! A BB-style forum, on the ATmosphere! We're still working... we'll be back soon when we have something to show off!
node typescript hono htmx atproto
at main 168 lines 5.6 kB view raw
1import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; 2 3// --- Module mocks (must be declared before imports that reference them) --- 4 5const mockSql = Object.assign(vi.fn().mockResolvedValue(undefined), { 6 end: vi.fn().mockResolvedValue(undefined), 7}); 8vi.mock("postgres", () => ({ default: vi.fn(() => mockSql) })); 9vi.mock("drizzle-orm/postgres-js", () => ({ drizzle: vi.fn(() => mockDb) })); 10vi.mock("@atbb/db", () => ({ default: {} })); 11 12const mockCreateCategory = vi.fn(); 13vi.mock("../lib/steps/create-category.js", () => ({ 14 createCategory: (...args: unknown[]) => mockCreateCategory(...args), 15})); 16 17const mockForumAgentInstance = { 18 initialize: vi.fn().mockResolvedValue(undefined), 19 isAuthenticated: vi.fn().mockReturnValue(true), 20 getAgent: vi.fn().mockReturnValue({}), 21 getStatus: vi.fn().mockReturnValue({ status: "authenticated", error: undefined }), 22 shutdown: vi.fn().mockResolvedValue(undefined), 23}; 24vi.mock("@atbb/atproto", () => ({ 25 ForumAgent: vi.fn(() => mockForumAgentInstance), 26})); 27 28vi.mock("../lib/config.js", () => ({ 29 loadCliConfig: vi.fn(() => ({ 30 databaseUrl: "postgres://test", 31 pdsUrl: "https://pds.test", 32 forumDid: "did:plc:testforum", 33 forumHandle: "forum.test", 34 forumPassword: "secret", 35 })), 36})); 37 38vi.mock("../lib/preflight.js", () => ({ 39 checkEnvironment: vi.fn(() => ({ ok: true, errors: [] })), 40})); 41 42const mockInput = vi.fn(); 43vi.mock("@inquirer/prompts", () => ({ 44 input: (...args: unknown[]) => mockInput(...args), 45 select: vi.fn(), 46})); 47 48// --- Mock DB (shared, created before mock declarations above) --- 49const mockDb = {} as any; 50 51// --- Import the command under test (after all mocks are registered) --- 52import { categoryCommand } from "../commands/category.js"; 53 54describe("category add command", () => { 55 let exitSpy: ReturnType<typeof vi.spyOn>; 56 57 beforeEach(() => { 58 vi.clearAllMocks(); 59 // Turn process.exit into a catchable throw so tests don't die 60 exitSpy = vi.spyOn(process, "exit").mockImplementation((code?: number | string | null) => { 61 throw new Error(`process.exit:${code}`); 62 }) as any; 63 // Default: createCategory succeeds with a new category 64 mockCreateCategory.mockResolvedValue({ 65 created: true, 66 skipped: false, 67 uri: "at://did:plc:testforum/space.atbb.forum.category/tid123", 68 cid: "bafytest", 69 }); 70 }); 71 72 afterEach(() => { 73 exitSpy.mockRestore(); 74 }); 75 76 // Helper to get the run function from the add subcommand 77 function getAddRun() { 78 return ((categoryCommand.subCommands as any).add as any).run as (ctx: { args: Record<string, unknown> }) => Promise<void>; 79 } 80 81 it("creates a category when name is provided as arg", async () => { 82 const run = getAddRun(); 83 await run({ args: { name: "General" } }); 84 85 expect(mockCreateCategory).toHaveBeenCalledWith( 86 mockDb, 87 expect.anything(), 88 "did:plc:testforum", 89 expect.objectContaining({ name: "General" }) 90 ); 91 }); 92 93 it("prompts for name when not provided as arg", async () => { 94 mockInput.mockResolvedValueOnce("Interactive Category"); 95 mockInput.mockResolvedValueOnce(""); // description prompt 96 97 const run = getAddRun(); 98 await run({ args: {} }); 99 100 expect(mockInput).toHaveBeenCalledWith( 101 expect.objectContaining({ message: "Category name:" }) 102 ); 103 expect(mockCreateCategory).toHaveBeenCalledWith( 104 mockDb, 105 expect.anything(), 106 "did:plc:testforum", 107 expect.objectContaining({ name: "Interactive Category" }) 108 ); 109 }); 110 111 it("skips creating when category already exists", async () => { 112 mockCreateCategory.mockResolvedValueOnce({ 113 created: false, 114 skipped: true, 115 uri: "at://did:plc:testforum/space.atbb.forum.category/old", 116 existingName: "General", 117 }); 118 119 const run = getAddRun(); 120 await run({ args: { name: "General" } }); 121 122 // Should not exit with error 123 expect(exitSpy).not.toHaveBeenCalled(); 124 }); 125 126 it("exits when authentication succeeds but isAuthenticated returns false", async () => { 127 mockForumAgentInstance.isAuthenticated.mockReturnValueOnce(false); 128 mockForumAgentInstance.getStatus.mockReturnValueOnce({ status: "failed", error: "Invalid credentials" }); 129 130 const run = getAddRun(); 131 await expect(run({ args: { name: "General" } })).rejects.toThrow("process.exit:1"); 132 expect(exitSpy).toHaveBeenCalledWith(1); 133 expect(mockCreateCategory).not.toHaveBeenCalled(); 134 }); 135 136 it("exits when forumAgent.initialize throws (PDS unreachable)", async () => { 137 mockForumAgentInstance.initialize.mockRejectedValueOnce( 138 new Error("fetch failed") 139 ); 140 141 const run = getAddRun(); 142 await expect(run({ args: { name: "General" } })).rejects.toThrow( 143 "process.exit:1" 144 ); 145 expect(exitSpy).toHaveBeenCalledWith(1); 146 }); 147 148 it("exits when createCategory fails (runtime error)", async () => { 149 mockCreateCategory.mockRejectedValueOnce(new Error("PDS write failed")); 150 151 const run = getAddRun(); 152 await expect(run({ args: { name: "General" } })).rejects.toThrow( 153 "process.exit:1" 154 ); 155 expect(exitSpy).toHaveBeenCalledWith(1); 156 }); 157 158 it("re-throws programming errors from createCategory", async () => { 159 mockCreateCategory.mockRejectedValueOnce( 160 new TypeError("Cannot read properties of undefined") 161 ); 162 163 const run = getAddRun(); 164 await expect(run({ args: { name: "General" } })).rejects.toThrow(TypeError); 165 // process.exit should NOT have been called for a programming error 166 expect(exitSpy).not.toHaveBeenCalled(); 167 }); 168});