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 root/atb-56-theme-caching-layer 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});