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 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});