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 (must be declared before imports that reference them) ---
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", () => ({ default: {} }));
11
12const mockCreateCategory = vi.fn();
13vi.mock("../lib/steps/create-category.js", () => ({
14 createCategory: (...args: unknown[]) => mockCreateCategory(...args),
15}));
16
17const mockForumAgentInstance = {
18 initialize: vi.fn().mockResolvedValue(undefined),
19 isAuthenticated: vi.fn().mockReturnValue(true),
20 getAgent: vi.fn().mockReturnValue({}),
21 getStatus: vi.fn().mockReturnValue({ status: "authenticated", error: undefined }),
22 shutdown: vi.fn().mockResolvedValue(undefined),
23};
24vi.mock("@atbb/atproto", () => ({
25 ForumAgent: vi.fn(() => mockForumAgentInstance),
26}));
27
28vi.mock("../lib/config.js", () => ({
29 loadCliConfig: vi.fn(() => ({
30 databaseUrl: "postgres://test",
31 pdsUrl: "https://pds.test",
32 forumDid: "did:plc:testforum",
33 forumHandle: "forum.test",
34 forumPassword: "secret",
35 })),
36}));
37
38vi.mock("../lib/preflight.js", () => ({
39 checkEnvironment: vi.fn(() => ({ ok: true, errors: [] })),
40}));
41
42const mockInput = vi.fn();
43vi.mock("@inquirer/prompts", () => ({
44 input: (...args: unknown[]) => mockInput(...args),
45 select: vi.fn(),
46}));
47
48// --- Mock DB (shared, created before mock declarations above) ---
49const mockDb = {} as any;
50
51// --- Import the command under test (after all mocks are registered) ---
52import { categoryCommand } from "../commands/category.js";
53
54describe("category add command", () => {
55 let exitSpy: ReturnType<typeof vi.spyOn>;
56
57 beforeEach(() => {
58 vi.clearAllMocks();
59 // Turn process.exit into a catchable throw so tests don't die
60 exitSpy = vi.spyOn(process, "exit").mockImplementation((code?: number | string | null) => {
61 throw new Error(`process.exit:${code}`);
62 }) as any;
63 // Default: createCategory succeeds with a new category
64 mockCreateCategory.mockResolvedValue({
65 created: true,
66 skipped: false,
67 uri: "at://did:plc:testforum/space.atbb.forum.category/tid123",
68 cid: "bafytest",
69 });
70 });
71
72 afterEach(() => {
73 exitSpy.mockRestore();
74 });
75
76 // Helper to get the run function from the add subcommand
77 function getAddRun() {
78 return ((categoryCommand.subCommands as any).add as any).run as (ctx: { args: Record<string, unknown> }) => Promise<void>;
79 }
80
81 it("creates a category when name is provided as arg", async () => {
82 const run = getAddRun();
83 await run({ args: { name: "General" } });
84
85 expect(mockCreateCategory).toHaveBeenCalledWith(
86 mockDb,
87 expect.anything(),
88 "did:plc:testforum",
89 expect.objectContaining({ name: "General" })
90 );
91 });
92
93 it("prompts for name when not provided as arg", async () => {
94 mockInput.mockResolvedValueOnce("Interactive Category");
95 mockInput.mockResolvedValueOnce(""); // description prompt
96
97 const run = getAddRun();
98 await run({ args: {} });
99
100 expect(mockInput).toHaveBeenCalledWith(
101 expect.objectContaining({ message: "Category name:" })
102 );
103 expect(mockCreateCategory).toHaveBeenCalledWith(
104 mockDb,
105 expect.anything(),
106 "did:plc:testforum",
107 expect.objectContaining({ name: "Interactive Category" })
108 );
109 });
110
111 it("skips creating when category already exists", async () => {
112 mockCreateCategory.mockResolvedValueOnce({
113 created: false,
114 skipped: true,
115 uri: "at://did:plc:testforum/space.atbb.forum.category/old",
116 existingName: "General",
117 });
118
119 const run = getAddRun();
120 await run({ args: { name: "General" } });
121
122 // Should not exit with error
123 expect(exitSpy).not.toHaveBeenCalled();
124 });
125
126 it("exits when authentication succeeds but isAuthenticated returns false", async () => {
127 mockForumAgentInstance.isAuthenticated.mockReturnValueOnce(false);
128 mockForumAgentInstance.getStatus.mockReturnValueOnce({ status: "failed", error: "Invalid credentials" });
129
130 const run = getAddRun();
131 await expect(run({ args: { name: "General" } })).rejects.toThrow("process.exit:1");
132 expect(exitSpy).toHaveBeenCalledWith(1);
133 expect(mockCreateCategory).not.toHaveBeenCalled();
134 });
135
136 it("exits when forumAgent.initialize throws (PDS unreachable)", async () => {
137 mockForumAgentInstance.initialize.mockRejectedValueOnce(
138 new Error("fetch failed")
139 );
140
141 const run = getAddRun();
142 await expect(run({ args: { name: "General" } })).rejects.toThrow(
143 "process.exit:1"
144 );
145 expect(exitSpy).toHaveBeenCalledWith(1);
146 });
147
148 it("exits when createCategory fails (runtime error)", async () => {
149 mockCreateCategory.mockRejectedValueOnce(new Error("PDS write failed"));
150
151 const run = getAddRun();
152 await expect(run({ args: { name: "General" } })).rejects.toThrow(
153 "process.exit:1"
154 );
155 expect(exitSpy).toHaveBeenCalledWith(1);
156 });
157
158 it("re-throws programming errors from createCategory", async () => {
159 mockCreateCategory.mockRejectedValueOnce(
160 new TypeError("Cannot read properties of undefined")
161 );
162
163 const run = getAddRun();
164 await expect(run({ args: { name: "General" } })).rejects.toThrow(TypeError);
165 // process.exit should NOT have been called for a programming error
166 expect(exitSpy).not.toHaveBeenCalled();
167 });
168});