import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { ForumAgent } from "../forum-agent.js"; import { AtpAgent } from "@atproto/api"; import type { Logger } from "@atbb/logger"; // Mock @atproto/api vi.mock("@atproto/api", () => ({ AtpAgent: vi.fn(), })); /** Create a no-op logger for tests. */ function createMockLogger(): Logger { const noop = vi.fn(); const logger: Logger = { debug: noop, info: noop, warn: noop, error: noop, fatal: noop, child: () => logger, shutdown: () => Promise.resolve(), }; return logger; } describe("ForumAgent", () => { let mockAgent: any; let mockLogin: any; let mockLogger: Logger; let agent: ForumAgent | null = null; beforeEach(() => { mockLogin = vi.fn(); mockAgent = { login: mockLogin, session: null, }; mockLogger = createMockLogger(); (AtpAgent as any).mockImplementation(function () { return mockAgent; }); }); afterEach(async () => { if (agent) { await agent.shutdown(); agent = null; } vi.clearAllMocks(); }); describe("initialization", () => { it("starts with 'initializing' status", () => { agent = new ForumAgent( "https://pds.example.com", "forum.example.com", "password", mockLogger ); const status = agent.getStatus(); expect(status.status).toBe("initializing"); expect(status.authenticated).toBe(false); }); it("transitions to 'authenticated' on successful login", async () => { mockLogin.mockResolvedValueOnce(undefined); mockAgent.session = { did: "did:plc:test", accessJwt: "token", refreshJwt: "refresh", }; agent = new ForumAgent( "https://pds.example.com", "forum.example.com", "password", mockLogger ); await agent.initialize(); const status = agent.getStatus(); expect(status.status).toBe("authenticated"); expect(status.authenticated).toBe(true); expect(agent.isAuthenticated()).toBe(true); expect(agent.getAgent()).toBe(mockAgent); }); }); describe("shutdown", () => { it("cleans up timers on shutdown", async () => { mockLogin.mockResolvedValueOnce(undefined); mockAgent.session = { did: "did:plc:test", accessJwt: "token" }; agent = new ForumAgent( "https://pds.example.com", "forum.example.com", "password", mockLogger ); await agent.initialize(); expect(agent.isAuthenticated()).toBe(true); await agent.shutdown(); // Agent should still be authenticated but timers are cleared // (shutdown only clears timers, doesn't invalidate session) expect(agent.isAuthenticated()).toBe(true); }); }); describe("error classification", () => { it("fails permanently on auth errors (no retry)", async () => { const authError = new Error("Invalid identifier or password"); mockLogin.mockRejectedValueOnce(authError); agent = new ForumAgent( "https://pds.example.com", "forum.example.com", "wrong-password", mockLogger ); await agent.initialize(); const status = agent.getStatus(); expect(status.status).toBe("failed"); expect(status.authenticated).toBe(false); expect(status.error).toContain("invalid credentials"); expect(mockLogin).toHaveBeenCalledTimes(1); // No retry }); it("retries on network errors with exponential backoff", async () => { vi.useFakeTimers(); const networkError = new Error("ECONNREFUSED"); (networkError as any).code = "ECONNREFUSED"; mockLogin.mockRejectedValueOnce(networkError); agent = new ForumAgent( "https://pds.example.com", "forum.example.com", "password", mockLogger ); await agent.initialize(); const status = agent.getStatus(); expect(status.status).toBe("retrying"); expect(status.authenticated).toBe(false); expect(status.retryCount).toBe(1); expect(status.nextRetryAt).toBeDefined(); expect(mockLogin).toHaveBeenCalledTimes(1); // Fast-forward to next retry (10 seconds) mockLogin.mockResolvedValueOnce(undefined); mockAgent.session = { did: "did:plc:test", accessJwt: "token" }; await vi.advanceTimersByTimeAsync(10000); const statusAfterRetry = agent.getStatus(); expect(statusAfterRetry.status).toBe("authenticated"); expect(statusAfterRetry.authenticated).toBe(true); expect(mockLogin).toHaveBeenCalledTimes(2); vi.useRealTimers(); }); it("stops retrying after max attempts", async () => { vi.useFakeTimers(); const networkError = new Error("ETIMEDOUT"); (networkError as any).code = "ETIMEDOUT"; mockLogin.mockRejectedValue(networkError); agent = new ForumAgent( "https://pds.example.com", "forum.example.com", "password", mockLogger ); await agent.initialize(); // Advance through all retry attempts (5 retries) const retryDelays = [10000, 30000, 60000, 300000, 600000]; for (let i = 0; i < 5; i++) { const delay = retryDelays[i]; await vi.advanceTimersByTimeAsync(delay); } const status = agent.getStatus(); expect(status.status).toBe("failed"); expect(status.error).toContain("max retries"); expect(mockLogin).toHaveBeenCalledTimes(6); // Initial + 5 retries vi.useRealTimers(); }); }); describe("session refresh", () => { it("schedules proactive refresh after successful auth", async () => { vi.useFakeTimers(); mockLogin.mockResolvedValueOnce(undefined); mockAgent.session = { did: "did:plc:test", accessJwt: "token" }; agent = new ForumAgent( "https://pds.example.com", "forum.example.com", "password", mockLogger ); await agent.initialize(); expect(agent.isAuthenticated()).toBe(true); // Fast-forward 30 minutes to trigger refresh const mockResumeSession = vi.fn().mockResolvedValue(undefined); mockAgent.resumeSession = mockResumeSession; await vi.advanceTimersByTimeAsync(30 * 60 * 1000); expect(mockResumeSession).toHaveBeenCalled(); vi.useRealTimers(); }); it("retries if refresh fails with network error", async () => { vi.useFakeTimers(); // Initial auth succeeds mockLogin.mockResolvedValueOnce(undefined); mockAgent.session = { did: "did:plc:test", accessJwt: "token" }; agent = new ForumAgent( "https://pds.example.com", "forum.example.com", "password", mockLogger ); await agent.initialize(); // Simulate refresh failure followed by re-auth failure const networkError = new Error("ECONNREFUSED"); (networkError as any).code = "ECONNREFUSED"; const mockResumeSession = vi.fn().mockRejectedValueOnce(networkError); mockAgent.resumeSession = mockResumeSession; // Re-auth will also fail (network error) mockLogin.mockRejectedValueOnce(networkError); await vi.advanceTimersByTimeAsync(30 * 60 * 1000); // Should transition to retrying status after refresh fails and re-auth fails const status = agent.getStatus(); expect(status.status).toBe("retrying"); expect(status.authenticated).toBe(false); vi.useRealTimers(); }); }); });