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 main 260 lines 7.5 kB view raw
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});