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 263 lines 8.4 kB view raw
1import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; 2 3// --- Module mocks --- 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", () => ({ 11 default: {}, 12 categories: "categories_table", 13})); 14vi.mock("drizzle-orm", () => ({ 15 eq: vi.fn(), 16 and: vi.fn(), 17})); 18 19const mockCreateBoard = vi.fn(); 20vi.mock("../lib/steps/create-board.js", () => ({ 21 createBoard: (...args: unknown[]) => mockCreateBoard(...args), 22})); 23 24const mockForumAgentInstance = { 25 initialize: vi.fn().mockResolvedValue(undefined), 26 isAuthenticated: vi.fn().mockReturnValue(true), 27 getAgent: vi.fn().mockReturnValue({}), 28 getStatus: vi.fn().mockReturnValue({ status: "authenticated", error: undefined }), 29 shutdown: vi.fn().mockResolvedValue(undefined), 30}; 31vi.mock("@atbb/atproto", () => ({ 32 ForumAgent: vi.fn(() => mockForumAgentInstance), 33})); 34 35vi.mock("../lib/config.js", () => ({ 36 loadCliConfig: vi.fn(() => ({ 37 databaseUrl: "postgres://test", 38 pdsUrl: "https://pds.test", 39 forumDid: "did:plc:testforum", 40 forumHandle: "forum.test", 41 forumPassword: "secret", 42 })), 43})); 44 45vi.mock("../lib/preflight.js", () => ({ 46 checkEnvironment: vi.fn(() => ({ ok: true, errors: [] })), 47})); 48 49const mockInput = vi.fn(); 50const mockSelect = vi.fn(); 51vi.mock("@inquirer/prompts", () => ({ 52 input: (...args: unknown[]) => mockInput(...args), 53 select: (...args: unknown[]) => mockSelect(...args), 54})); 55 56// Mock DB with a category lookup that succeeds by default 57const mockCategoryRow = { 58 id: BigInt(42), 59 did: "did:plc:testforum", 60 rkey: "cattid", 61 cid: "bafycategory", 62 name: "General", 63}; 64 65const mockDb = { 66 select: vi.fn().mockReturnValue({ 67 from: vi.fn().mockReturnValue({ 68 where: vi.fn().mockReturnValue({ 69 limit: vi.fn().mockResolvedValue([mockCategoryRow]), 70 }), 71 }), 72 }), 73} as any; 74 75import { boardCommand } from "../commands/board.js"; 76 77const VALID_CATEGORY_URI = `at://did:plc:testforum/space.atbb.forum.category/cattid`; 78 79describe("board add command", () => { 80 let exitSpy: ReturnType<typeof vi.spyOn>; 81 82 function getAddRun() { 83 return ((boardCommand.subCommands as any).add as any).run as (ctx: { args: Record<string, unknown> }) => Promise<void>; 84 } 85 86 beforeEach(() => { 87 vi.clearAllMocks(); 88 exitSpy = vi.spyOn(process, "exit").mockImplementation((code?: number | string | null) => { 89 throw new Error(`process.exit:${code}`); 90 }) as any; 91 92 // Restore DB mock after clearAllMocks 93 mockDb.select.mockReturnValue({ 94 from: vi.fn().mockReturnValue({ 95 where: vi.fn().mockReturnValue({ 96 limit: vi.fn().mockResolvedValue([mockCategoryRow]), 97 }), 98 }), 99 }); 100 101 // Default: createBoard succeeds 102 mockCreateBoard.mockResolvedValue({ 103 created: true, 104 skipped: false, 105 uri: "at://did:plc:testforum/space.atbb.forum.board/tid456", 106 cid: "bafyboard", 107 }); 108 109 // Default: prompts return values 110 mockInput.mockResolvedValue(""); 111 }); 112 113 afterEach(() => { 114 exitSpy.mockRestore(); 115 }); 116 117 it("creates a board when category-uri and name are provided as args", async () => { 118 const run = getAddRun(); 119 await run({ args: { "category-uri": VALID_CATEGORY_URI, name: "General Discussion" } }); 120 121 expect(mockCreateBoard).toHaveBeenCalledWith( 122 mockDb, 123 expect.anything(), 124 "did:plc:testforum", 125 expect.objectContaining({ 126 name: "General Discussion", 127 categoryUri: VALID_CATEGORY_URI, 128 categoryId: BigInt(42), 129 categoryCid: "bafycategory", 130 }) 131 ); 132 }); 133 134 it("exits with error when AT URI has wrong collection (not space.atbb.forum.category)", async () => { 135 const wrongUri = "at://did:plc:testforum/space.atbb.forum.board/cattid"; 136 const run = getAddRun(); 137 await expect( 138 run({ args: { "category-uri": wrongUri, name: "Test" } }) 139 ).rejects.toThrow("process.exit:1"); 140 expect(exitSpy).toHaveBeenCalledWith(1); 141 // Board creation should never be called 142 expect(mockCreateBoard).not.toHaveBeenCalled(); 143 }); 144 145 it("exits with error when AT URI format is invalid (missing parts)", async () => { 146 const run = getAddRun(); 147 await expect( 148 run({ args: { "category-uri": "not-an-at-uri", name: "Test" } }) 149 ).rejects.toThrow("process.exit:1"); 150 expect(exitSpy).toHaveBeenCalledWith(1); 151 expect(mockCreateBoard).not.toHaveBeenCalled(); 152 }); 153 154 it("exits with error when category URI is not found in DB", async () => { 155 mockDb.select.mockReturnValueOnce({ 156 from: vi.fn().mockReturnValue({ 157 where: vi.fn().mockReturnValue({ 158 limit: vi.fn().mockResolvedValue([]), // category not found 159 }), 160 }), 161 }); 162 163 const run = getAddRun(); 164 await expect( 165 run({ args: { "category-uri": VALID_CATEGORY_URI, name: "Test" } }) 166 ).rejects.toThrow("process.exit:1"); 167 expect(mockCreateBoard).not.toHaveBeenCalled(); 168 }); 169 170 it("exits when authentication succeeds but isAuthenticated returns false", async () => { 171 mockForumAgentInstance.isAuthenticated.mockReturnValueOnce(false); 172 mockForumAgentInstance.getStatus.mockReturnValueOnce({ status: "failed", error: "Invalid credentials" }); 173 174 const run = getAddRun(); 175 await expect( 176 run({ args: { "category-uri": VALID_CATEGORY_URI, name: "Test" } }) 177 ).rejects.toThrow("process.exit:1"); 178 expect(exitSpy).toHaveBeenCalledWith(1); 179 expect(mockCreateBoard).not.toHaveBeenCalled(); 180 }); 181 182 it("uses interactive select when no category-uri arg is provided", async () => { 183 mockSelect.mockResolvedValueOnce(mockCategoryRow); 184 185 const run = getAddRun(); 186 await run({ args: { name: "Test Board" } }); 187 188 expect(mockSelect).toHaveBeenCalled(); 189 expect(mockCreateBoard).toHaveBeenCalledWith( 190 mockDb, 191 expect.anything(), 192 "did:plc:testforum", 193 expect.objectContaining({ 194 categoryUri: `at://${mockCategoryRow.did}/space.atbb.forum.category/${mockCategoryRow.rkey}`, 195 categoryId: mockCategoryRow.id, 196 categoryCid: mockCategoryRow.cid, 197 }) 198 ); 199 }); 200 201 it("exits when no categories exist in DB and no category-uri provided", async () => { 202 mockDb.select.mockReturnValueOnce({ 203 from: vi.fn().mockReturnValue({ 204 where: vi.fn().mockReturnValue({ 205 limit: vi.fn().mockResolvedValue([]), // empty categories list 206 }), 207 }), 208 }); 209 210 const run = getAddRun(); 211 await expect(run({ args: { name: "Test Board" } })).rejects.toThrow("process.exit:1"); 212 expect(exitSpy).toHaveBeenCalledWith(1); 213 expect(mockCreateBoard).not.toHaveBeenCalled(); 214 }); 215 216 it("exits when forumAgent.initialize throws (PDS unreachable)", async () => { 217 mockForumAgentInstance.initialize.mockRejectedValueOnce( 218 new Error("fetch failed") 219 ); 220 221 const run = getAddRun(); 222 await expect( 223 run({ args: { "category-uri": VALID_CATEGORY_URI, name: "Test" } }) 224 ).rejects.toThrow("process.exit:1"); 225 expect(exitSpy).toHaveBeenCalledWith(1); 226 }); 227 228 it("exits when createBoard fails (runtime error)", async () => { 229 mockCreateBoard.mockRejectedValueOnce(new Error("PDS write failed")); 230 231 const run = getAddRun(); 232 await expect( 233 run({ args: { "category-uri": VALID_CATEGORY_URI, name: "Test Board" } }) 234 ).rejects.toThrow("process.exit:1"); 235 expect(exitSpy).toHaveBeenCalledWith(1); 236 }); 237 238 it("re-throws programming errors from createBoard", async () => { 239 mockCreateBoard.mockRejectedValueOnce( 240 new TypeError("Cannot read properties of undefined") 241 ); 242 243 const run = getAddRun(); 244 await expect( 245 run({ args: { "category-uri": VALID_CATEGORY_URI, name: "Test Board" } }) 246 ).rejects.toThrow(TypeError); 247 expect(exitSpy).not.toHaveBeenCalled(); 248 }); 249 250 it("skips creating when board already exists in category", async () => { 251 mockCreateBoard.mockResolvedValueOnce({ 252 created: false, 253 skipped: true, 254 uri: "at://did:plc:testforum/space.atbb.forum.board/existing", 255 existingName: "General Discussion", 256 }); 257 258 const run = getAddRun(); 259 await run({ args: { "category-uri": VALID_CATEGORY_URI, name: "General Discussion" } }); 260 261 expect(exitSpy).not.toHaveBeenCalled(); 262 }); 263});