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 atb-46-modlog-endpoint 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});