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