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