import { describe, it, expect, vi, beforeEach } from "vitest"; import type { AccountEntry, AccountsFile } from "../types.js"; // Must use vi.hoisted() for mock variables referenced in vi.mock factories const mockFs = vi.hoisted(() => ({ existsSync: vi.fn(() => false), readFileSync: vi.fn(() => ""), writeFileSync: vi.fn(), renameSync: vi.fn(), mkdirSync: vi.fn(), })); vi.mock("fs", () => mockFs); vi.mock("../../paths.js", () => ({ getDataDir: vi.fn(() => "/tmp/test-persistence"), })); vi.mock("../jwt-utils.js", () => ({ extractChatGptAccountId: vi.fn((token: string) => `acct-${token}`), extractUserProfile: vi.fn(() => null), isTokenExpired: vi.fn(() => false), })); import { createFsPersistence } from "../account-persistence.js"; function makeEntry(id: string): AccountEntry { return { id, token: `tok-${id}`, refreshToken: null, email: `${id}@test.com`, accountId: `acct-${id}`, userId: `user-${id}`, planType: "free", proxyApiKey: `key-${id}`, status: "active", usage: { request_count: 0, input_tokens: 0, output_tokens: 0, empty_response_count: 0, last_used: null, rate_limit_until: null, window_request_count: 0, window_input_tokens: 0, window_output_tokens: 0, window_counters_reset_at: null, limit_window_seconds: null, }, addedAt: new Date().toISOString(), cachedQuota: null, quotaFetchedAt: null, }; } describe("account-persistence", () => { beforeEach(() => { vi.clearAllMocks(); mockFs.existsSync.mockReturnValue(false); }); describe("load", () => { it("returns empty entries when no files exist", () => { const p = createFsPersistence(); const result = p.load(); expect(result.entries).toEqual([]); expect(result.needsPersist).toBe(false); }); it("loads from accounts.json", () => { const entry = makeEntry("a"); const data: AccountsFile = { accounts: [entry] }; mockFs.existsSync.mockImplementation(((path: string) => path.includes("accounts.json")) as () => boolean, ); mockFs.readFileSync.mockReturnValue(JSON.stringify(data)); const p = createFsPersistence(); const result = p.load(); expect(result.entries).toHaveLength(1); expect(result.entries[0].id).toBe("a"); }); it("skips entries without id or token", () => { const data = { accounts: [{ id: "", token: "x" }, { id: "b" }] }; mockFs.existsSync.mockImplementation(((path: string) => path.includes("accounts.json")) as () => boolean, ); mockFs.readFileSync.mockReturnValue(JSON.stringify(data)); const p = createFsPersistence(); expect(p.load().entries).toEqual([]); }); it("backfills missing empty_response_count and auto-persists", () => { const entry = makeEntry("a"); (entry.usage as unknown as Record).empty_response_count = undefined; const data: AccountsFile = { accounts: [entry] }; mockFs.existsSync.mockImplementation(((path: string) => path.includes("accounts.json")) as () => boolean, ); mockFs.readFileSync.mockReturnValue(JSON.stringify(data)); const p = createFsPersistence(); const result = p.load(); expect(result.entries[0].usage.empty_response_count).toBe(0); expect(result.needsPersist).toBe(true); // Verify auto-persist was triggered (write + rename for atomic save) expect(mockFs.writeFileSync).toHaveBeenCalledTimes(1); expect(mockFs.renameSync).toHaveBeenCalledTimes(1); }); }); describe("save", () => { it("writes atomically via tmp file + rename", () => { mockFs.existsSync.mockReturnValue(true); const p = createFsPersistence(); const entry = makeEntry("a"); p.save([entry]); expect(mockFs.writeFileSync).toHaveBeenCalledTimes(1); const writtenPath = mockFs.writeFileSync.mock.calls[0][0] as string; expect(writtenPath).toMatch(/accounts\.json\.tmp$/); expect(mockFs.renameSync).toHaveBeenCalledTimes(1); }); it("creates directory if missing", () => { mockFs.existsSync.mockReturnValue(false); const p = createFsPersistence(); p.save([makeEntry("a")]); expect(mockFs.mkdirSync).toHaveBeenCalledWith( expect.any(String), { recursive: true }, ); }); it("serializes accounts as JSON", () => { mockFs.existsSync.mockReturnValue(true); const p = createFsPersistence(); const entries = [makeEntry("a"), makeEntry("b")]; p.save(entries); const written = JSON.parse(mockFs.writeFileSync.mock.calls[0][1] as string) as AccountsFile; expect(written.accounts).toHaveLength(2); expect(written.accounts[0].id).toBe("a"); expect(written.accounts[1].id).toBe("b"); }); }); describe("legacy migration", () => { it("migrates from auth.json when accounts.json does not exist", () => { const legacyData = { token: "legacy-token", proxyApiKey: "old-key", userInfo: { email: "old@test.com", planType: "free" }, }; mockFs.existsSync.mockImplementation(((path: string) => { if (path.includes("accounts.json")) return false; if (path.includes("auth.json")) return true; return false; }) as () => boolean); mockFs.readFileSync.mockReturnValue(JSON.stringify(legacyData)); const p = createFsPersistence(); const result = p.load(); expect(result.entries).toHaveLength(1); expect(result.entries[0].token).toBe("legacy-token"); // Should rename old file expect(mockFs.renameSync).toHaveBeenCalled(); }); }); });