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 320 lines 11 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 mockCreateForumRecord = vi.fn(); 20vi.mock("../lib/steps/create-forum.js", () => ({ 21 createForumRecord: (...args: unknown[]) => mockCreateForumRecord(...args), 22})); 23 24const mockSeedDefaultRoles = vi.fn(); 25vi.mock("../lib/steps/seed-roles.js", () => ({ 26 seedDefaultRoles: (...args: unknown[]) => mockSeedDefaultRoles(...args), 27})); 28 29const mockAssignOwnerRole = vi.fn(); 30vi.mock("../lib/steps/assign-owner.js", () => ({ 31 assignOwnerRole: (...args: unknown[]) => mockAssignOwnerRole(...args), 32})); 33 34const mockCreateCategory = vi.fn(); 35vi.mock("../lib/steps/create-category.js", () => ({ 36 createCategory: (...args: unknown[]) => mockCreateCategory(...args), 37})); 38 39const mockCreateBoard = vi.fn(); 40vi.mock("../lib/steps/create-board.js", () => ({ 41 createBoard: (...args: unknown[]) => mockCreateBoard(...args), 42})); 43 44const mockForumAgentInstance = { 45 initialize: vi.fn().mockResolvedValue(undefined), 46 isAuthenticated: vi.fn().mockReturnValue(true), 47 getAgent: vi.fn().mockReturnValue({}), 48 getStatus: vi.fn().mockReturnValue({ status: "authenticated", error: undefined }), 49 shutdown: vi.fn().mockResolvedValue(undefined), 50}; 51vi.mock("@atbb/atproto", () => ({ 52 ForumAgent: vi.fn(() => mockForumAgentInstance), 53 resolveIdentity: vi.fn().mockResolvedValue({ did: "did:plc:owner", handle: "owner.test" }), 54})); 55 56vi.mock("../lib/config.js", () => ({ 57 loadCliConfig: vi.fn(() => ({ 58 databaseUrl: "postgres://test", 59 pdsUrl: "https://pds.test", 60 forumDid: "did:plc:testforum", 61 forumHandle: "forum.test", 62 forumPassword: "secret", 63 })), 64})); 65 66vi.mock("../lib/preflight.js", () => ({ 67 checkEnvironment: vi.fn(() => ({ ok: true, errors: [] })), 68})); 69 70const mockInput = vi.fn(); 71const mockConfirm = vi.fn(); 72vi.mock("@inquirer/prompts", () => ({ 73 input: (...args: unknown[]) => mockInput(...args), 74 confirm: (...args: unknown[]) => mockConfirm(...args), 75})); 76 77// Mock DB: the category ID lookup in Step 4 is a select().from().where().limit() 78const mockCategoryIdRow = { id: BigInt(99) }; 79const mockDb = { 80 select: vi.fn().mockReturnValue({ 81 from: vi.fn().mockReturnValue({ 82 where: vi.fn().mockReturnValue({ 83 limit: vi.fn().mockResolvedValue([mockCategoryIdRow]), 84 }), 85 }), 86 }), 87} as any; 88 89import { initCommand } from "../commands/init.js"; 90 91function getInitRun() { 92 return (initCommand as any).run as (ctx: { args: Record<string, unknown> }) => Promise<void>; 93} 94 95// Helpers that set up the happy-path mocks for Steps 1-3. 96// All tests pass forum-name and owner as args, so only forum-description needs 97// a prompt in Steps 1-3. 98function setupSteps1to3() { 99 mockCreateForumRecord.mockResolvedValue({ created: true, skipped: false, uri: "at://f/forum/self" }); 100 const ownerRole = { name: "Owner", uri: "at://f/space.atbb.forum.role/tid1" }; 101 mockSeedDefaultRoles.mockResolvedValue({ created: 3, skipped: 0, roles: [ownerRole] }); 102 mockAssignOwnerRole.mockResolvedValue({ skipped: false }); 103 // Only one prompt consumed in steps 1-3 (forum description; name+owner come from args) 104 mockInput.mockResolvedValueOnce(""); // forum description 105} 106 107describe("init command — Step 4 (seed initial structure)", () => { 108 let exitSpy: ReturnType<typeof vi.spyOn>; 109 110 beforeEach(() => { 111 vi.clearAllMocks(); 112 exitSpy = vi.spyOn(process, "exit").mockImplementation((code?: number | string | null) => { 113 throw new Error(`process.exit:${code}`); 114 }) as any; 115 116 // Restore DB mock after clearAllMocks 117 mockDb.select.mockReturnValue({ 118 from: vi.fn().mockReturnValue({ 119 where: vi.fn().mockReturnValue({ 120 limit: vi.fn().mockResolvedValue([mockCategoryIdRow]), 121 }), 122 }), 123 }); 124 }); 125 126 afterEach(() => { 127 exitSpy.mockRestore(); 128 }); 129 130 it("skips category and board creation when user declines seeding", async () => { 131 setupSteps1to3(); 132 mockConfirm.mockResolvedValueOnce(false); // "Seed an initial category and board?" → No 133 134 const run = getInitRun(); 135 await run({ args: { "forum-name": "Test Forum", owner: "owner.test" } }); 136 137 expect(mockCreateCategory).not.toHaveBeenCalled(); 138 expect(mockCreateBoard).not.toHaveBeenCalled(); 139 expect(exitSpy).not.toHaveBeenCalled(); 140 }); 141 142 it("creates category and board when user confirms seeding", async () => { 143 setupSteps1to3(); 144 mockConfirm.mockResolvedValueOnce(true); 145 mockCreateCategory.mockResolvedValueOnce({ 146 created: true, 147 skipped: false, 148 uri: "at://did:plc:testforum/space.atbb.forum.category/cattid", 149 cid: "bafycat", 150 }); 151 mockCreateBoard.mockResolvedValueOnce({ 152 created: true, 153 skipped: false, 154 uri: "at://did:plc:testforum/space.atbb.forum.board/boardtid", 155 cid: "bafyboard", 156 }); 157 // Prompts for step 4 (appended after the forum-description prompt from setupSteps1to3) 158 mockInput 159 .mockResolvedValueOnce("General") // category name 160 .mockResolvedValueOnce("") // category description 161 .mockResolvedValueOnce("General Discussion") // board name 162 .mockResolvedValueOnce(""); // board description 163 164 const run = getInitRun(); 165 await run({ args: { "forum-name": "Test Forum", owner: "owner.test" } }); 166 167 expect(mockCreateCategory).toHaveBeenCalledWith( 168 mockDb, 169 expect.anything(), 170 "did:plc:testforum", 171 expect.objectContaining({ name: "General" }) 172 ); 173 expect(mockCreateBoard).toHaveBeenCalledWith( 174 mockDb, 175 expect.anything(), 176 "did:plc:testforum", 177 expect.objectContaining({ 178 categoryUri: "at://did:plc:testforum/space.atbb.forum.category/cattid", 179 categoryId: BigInt(99), 180 categoryCid: "bafycat", 181 }) 182 ); 183 expect(exitSpy).not.toHaveBeenCalled(); 184 }); 185 186 it("exits if categoryId cannot be found in DB after category creation", async () => { 187 setupSteps1to3(); 188 mockConfirm.mockResolvedValueOnce(true); 189 mockCreateCategory.mockResolvedValueOnce({ 190 created: true, 191 skipped: false, 192 uri: "at://did:plc:testforum/space.atbb.forum.category/cattid", 193 cid: "bafycat", 194 }); 195 // DB lookup returns empty (simulates race condition or failed insert) 196 mockDb.select.mockReturnValueOnce({ 197 from: vi.fn().mockReturnValue({ 198 where: vi.fn().mockReturnValue({ 199 limit: vi.fn().mockResolvedValue([]), // no category row found 200 }), 201 }), 202 }); 203 // Prompts for step 4 204 mockInput 205 .mockResolvedValueOnce("General") 206 .mockResolvedValueOnce(""); 207 208 const run = getInitRun(); 209 await expect(run({ args: { "forum-name": "Test Forum", owner: "owner.test" } })).rejects.toThrow( 210 "process.exit:1" 211 ); 212 // Board creation should NOT be called when categoryId is missing 213 expect(mockCreateBoard).not.toHaveBeenCalled(); 214 }); 215 216 it("exits when createCategory fails in Step 4 (runtime error)", async () => { 217 setupSteps1to3(); 218 mockConfirm.mockResolvedValueOnce(true); 219 mockCreateCategory.mockRejectedValueOnce(new Error("PDS write failed")); 220 mockInput 221 .mockResolvedValueOnce("General") 222 .mockResolvedValueOnce(""); 223 224 const run = getInitRun(); 225 await expect(run({ args: { "forum-name": "Test Forum", owner: "owner.test" } })).rejects.toThrow( 226 "process.exit:1" 227 ); 228 expect(mockCreateBoard).not.toHaveBeenCalled(); 229 }); 230 231 it("exits when createBoard fails in Step 4 (runtime error)", async () => { 232 setupSteps1to3(); 233 mockConfirm.mockResolvedValueOnce(true); 234 mockCreateCategory.mockResolvedValueOnce({ 235 created: true, 236 skipped: false, 237 uri: "at://did:plc:testforum/space.atbb.forum.category/cattid", 238 cid: "bafycat", 239 }); 240 mockCreateBoard.mockRejectedValueOnce(new Error("Board PDS write failed")); 241 mockInput 242 .mockResolvedValueOnce("General") 243 .mockResolvedValueOnce("") 244 .mockResolvedValueOnce("General Discussion") 245 .mockResolvedValueOnce(""); 246 247 const run = getInitRun(); 248 await expect(run({ args: { "forum-name": "Test Forum", owner: "owner.test" } })).rejects.toThrow( 249 "process.exit:1" 250 ); 251 expect(exitSpy).toHaveBeenCalledWith(1); 252 }); 253 254 it("re-throws programming errors from createCategory in Step 4", async () => { 255 setupSteps1to3(); 256 mockConfirm.mockResolvedValueOnce(true); 257 mockCreateCategory.mockRejectedValueOnce( 258 new TypeError("Cannot read properties of undefined") 259 ); 260 mockInput 261 .mockResolvedValueOnce("General") 262 .mockResolvedValueOnce(""); 263 264 const run = getInitRun(); 265 await expect(run({ args: { "forum-name": "Test Forum", owner: "owner.test" } })).rejects.toThrow(TypeError); 266 expect(exitSpy).not.toHaveBeenCalled(); 267 }); 268 269 it("re-throws programming errors from createBoard in Step 4", async () => { 270 setupSteps1to3(); 271 mockConfirm.mockResolvedValueOnce(true); 272 mockCreateCategory.mockResolvedValueOnce({ 273 created: true, 274 skipped: false, 275 uri: "at://did:plc:testforum/space.atbb.forum.category/cattid", 276 cid: "bafycat", 277 }); 278 mockCreateBoard.mockRejectedValueOnce( 279 new TypeError("Cannot read properties of undefined") 280 ); 281 mockInput 282 .mockResolvedValueOnce("General") 283 .mockResolvedValueOnce("") 284 .mockResolvedValueOnce("General Discussion") 285 .mockResolvedValueOnce(""); 286 287 const run = getInitRun(); 288 await expect(run({ args: { "forum-name": "Test Forum", owner: "owner.test" } })).rejects.toThrow(TypeError); 289 expect(exitSpy).not.toHaveBeenCalled(); 290 }); 291 292 it("exits with accurate message when categoryId DB re-query throws", async () => { 293 setupSteps1to3(); 294 mockConfirm.mockResolvedValueOnce(true); 295 mockCreateCategory.mockResolvedValueOnce({ 296 created: true, 297 skipped: false, 298 uri: "at://did:plc:testforum/space.atbb.forum.category/cattid", 299 cid: "bafycat", 300 }); 301 // First select call (categoryId re-query) throws 302 mockDb.select.mockReturnValueOnce({ 303 from: vi.fn().mockReturnValue({ 304 where: vi.fn().mockReturnValue({ 305 limit: vi.fn().mockRejectedValue(new Error("DB connection lost")), 306 }), 307 }), 308 }); 309 mockInput 310 .mockResolvedValueOnce("General") 311 .mockResolvedValueOnce(""); 312 313 const run = getInitRun(); 314 await expect(run({ args: { "forum-name": "Test Forum", owner: "owner.test" } })).rejects.toThrow( 315 "process.exit:1" 316 ); 317 // Board creation should NOT be attempted after re-query failure 318 expect(mockCreateBoard).not.toHaveBeenCalled(); 319 }); 320});