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";
2import { ForumAgent } from "../forum-agent.js";
3import { AtpAgent } from "@atproto/api";
4import type { Logger } from "@atbb/logger";
5
6// Mock @atproto/api
7vi.mock("@atproto/api", () => ({
8 AtpAgent: vi.fn(),
9}));
10
11/** Create a no-op logger for tests. */
12function createMockLogger(): Logger {
13 const noop = vi.fn();
14 const logger: Logger = {
15 debug: noop,
16 info: noop,
17 warn: noop,
18 error: noop,
19 fatal: noop,
20 child: () => logger,
21 shutdown: () => Promise.resolve(),
22 };
23 return logger;
24}
25
26describe("ForumAgent", () => {
27 let mockAgent: any;
28 let mockLogin: any;
29 let mockLogger: Logger;
30 let agent: ForumAgent | null = null;
31
32 beforeEach(() => {
33 mockLogin = vi.fn();
34 mockAgent = {
35 login: mockLogin,
36 session: null,
37 };
38 mockLogger = createMockLogger();
39 (AtpAgent as any).mockImplementation(function () { return mockAgent; });
40 });
41
42 afterEach(async () => {
43 if (agent) {
44 await agent.shutdown();
45 agent = null;
46 }
47 vi.clearAllMocks();
48 });
49
50 describe("initialization", () => {
51 it("starts with 'initializing' status", () => {
52 agent = new ForumAgent(
53 "https://pds.example.com",
54 "forum.example.com",
55 "password",
56 mockLogger
57 );
58
59 const status = agent.getStatus();
60 expect(status.status).toBe("initializing");
61 expect(status.authenticated).toBe(false);
62 });
63
64 it("transitions to 'authenticated' on successful login", async () => {
65 mockLogin.mockResolvedValueOnce(undefined);
66 mockAgent.session = {
67 did: "did:plc:test",
68 accessJwt: "token",
69 refreshJwt: "refresh",
70 };
71
72 agent = new ForumAgent(
73 "https://pds.example.com",
74 "forum.example.com",
75 "password",
76 mockLogger
77 );
78 await agent.initialize();
79
80 const status = agent.getStatus();
81 expect(status.status).toBe("authenticated");
82 expect(status.authenticated).toBe(true);
83 expect(agent.isAuthenticated()).toBe(true);
84 expect(agent.getAgent()).toBe(mockAgent);
85 });
86 });
87
88 describe("shutdown", () => {
89 it("cleans up timers on shutdown", async () => {
90 mockLogin.mockResolvedValueOnce(undefined);
91 mockAgent.session = { did: "did:plc:test", accessJwt: "token" };
92
93 agent = new ForumAgent(
94 "https://pds.example.com",
95 "forum.example.com",
96 "password",
97 mockLogger
98 );
99 await agent.initialize();
100
101 expect(agent.isAuthenticated()).toBe(true);
102
103 await agent.shutdown();
104
105 // Agent should still be authenticated but timers are cleared
106 // (shutdown only clears timers, doesn't invalidate session)
107 expect(agent.isAuthenticated()).toBe(true);
108 });
109 });
110
111 describe("error classification", () => {
112 it("fails permanently on auth errors (no retry)", async () => {
113 const authError = new Error("Invalid identifier or password");
114 mockLogin.mockRejectedValueOnce(authError);
115
116 agent = new ForumAgent(
117 "https://pds.example.com",
118 "forum.example.com",
119 "wrong-password",
120 mockLogger
121 );
122 await agent.initialize();
123
124 const status = agent.getStatus();
125 expect(status.status).toBe("failed");
126 expect(status.authenticated).toBe(false);
127 expect(status.error).toContain("invalid credentials");
128 expect(mockLogin).toHaveBeenCalledTimes(1); // No retry
129 });
130
131 it("retries on network errors with exponential backoff", async () => {
132 vi.useFakeTimers();
133
134 const networkError = new Error("ECONNREFUSED");
135 (networkError as any).code = "ECONNREFUSED";
136 mockLogin.mockRejectedValueOnce(networkError);
137
138 agent = new ForumAgent(
139 "https://pds.example.com",
140 "forum.example.com",
141 "password",
142 mockLogger
143 );
144 await agent.initialize();
145
146 const status = agent.getStatus();
147 expect(status.status).toBe("retrying");
148 expect(status.authenticated).toBe(false);
149 expect(status.retryCount).toBe(1);
150 expect(status.nextRetryAt).toBeDefined();
151 expect(mockLogin).toHaveBeenCalledTimes(1);
152
153 // Fast-forward to next retry (10 seconds)
154 mockLogin.mockResolvedValueOnce(undefined);
155 mockAgent.session = { did: "did:plc:test", accessJwt: "token" };
156
157 await vi.advanceTimersByTimeAsync(10000);
158
159 const statusAfterRetry = agent.getStatus();
160 expect(statusAfterRetry.status).toBe("authenticated");
161 expect(statusAfterRetry.authenticated).toBe(true);
162 expect(mockLogin).toHaveBeenCalledTimes(2);
163
164 vi.useRealTimers();
165 });
166
167 it("stops retrying after max attempts", async () => {
168 vi.useFakeTimers();
169
170 const networkError = new Error("ETIMEDOUT");
171 (networkError as any).code = "ETIMEDOUT";
172 mockLogin.mockRejectedValue(networkError);
173
174 agent = new ForumAgent(
175 "https://pds.example.com",
176 "forum.example.com",
177 "password",
178 mockLogger
179 );
180 await agent.initialize();
181
182 // Advance through all retry attempts (5 retries)
183 const retryDelays = [10000, 30000, 60000, 300000, 600000];
184 for (let i = 0; i < 5; i++) {
185 const delay = retryDelays[i];
186 await vi.advanceTimersByTimeAsync(delay);
187 }
188
189 const status = agent.getStatus();
190 expect(status.status).toBe("failed");
191 expect(status.error).toContain("max retries");
192 expect(mockLogin).toHaveBeenCalledTimes(6); // Initial + 5 retries
193
194 vi.useRealTimers();
195 });
196 });
197
198 describe("session refresh", () => {
199 it("schedules proactive refresh after successful auth", async () => {
200 vi.useFakeTimers();
201
202 mockLogin.mockResolvedValueOnce(undefined);
203 mockAgent.session = { did: "did:plc:test", accessJwt: "token" };
204
205 agent = new ForumAgent(
206 "https://pds.example.com",
207 "forum.example.com",
208 "password",
209 mockLogger
210 );
211 await agent.initialize();
212
213 expect(agent.isAuthenticated()).toBe(true);
214
215 // Fast-forward 30 minutes to trigger refresh
216 const mockResumeSession = vi.fn().mockResolvedValue(undefined);
217 mockAgent.resumeSession = mockResumeSession;
218
219 await vi.advanceTimersByTimeAsync(30 * 60 * 1000);
220
221 expect(mockResumeSession).toHaveBeenCalled();
222
223 vi.useRealTimers();
224 });
225
226 it("retries if refresh fails with network error", async () => {
227 vi.useFakeTimers();
228
229 // Initial auth succeeds
230 mockLogin.mockResolvedValueOnce(undefined);
231 mockAgent.session = { did: "did:plc:test", accessJwt: "token" };
232
233 agent = new ForumAgent(
234 "https://pds.example.com",
235 "forum.example.com",
236 "password",
237 mockLogger
238 );
239 await agent.initialize();
240
241 // Simulate refresh failure followed by re-auth failure
242 const networkError = new Error("ECONNREFUSED");
243 (networkError as any).code = "ECONNREFUSED";
244 const mockResumeSession = vi.fn().mockRejectedValueOnce(networkError);
245 mockAgent.resumeSession = mockResumeSession;
246
247 // Re-auth will also fail (network error)
248 mockLogin.mockRejectedValueOnce(networkError);
249
250 await vi.advanceTimersByTimeAsync(30 * 60 * 1000);
251
252 // Should transition to retrying status after refresh fails and re-auth fails
253 const status = agent.getStatus();
254 expect(status.status).toBe("retrying");
255 expect(status.authenticated).toBe(false);
256
257 vi.useRealTimers();
258 });
259 });
260});