icebear icebear0828 commited on
Commit
5abff38
·
unverified ·
1 Parent(s): 98d1cac

fix: fall back to all accounts when model plan doesn't match (#91)

Browse files

The backend's /codex/models API returns different model lists per plan
(e.g. free doesn't list gpt-5.4), but free accounts can actually use
those models. Previously, acquire() returned null when no account
matched the model's plan requirements, blocking valid requests.

Now falls back to all available accounts instead of hard-blocking,
since the backend's plan-based model list is incomplete.

Co-authored-by: icebear0828 <icebear0828@users.noreply.github.com>

src/auth/account-pool.ts CHANGED
@@ -96,7 +96,10 @@ export class AccountPool {
96
 
97
  if (available.length === 0) return null;
98
 
99
- // Model-aware selection: prefer accounts whose planType matches the model's known plans
 
 
 
100
  let candidates = available;
101
  if (options?.model) {
102
  const preferredPlans = getModelPlanTypes(options.model);
@@ -105,10 +108,8 @@ export class AccountPool {
105
  const matched = available.filter((a) => a.planType && planSet.has(a.planType));
106
  if (matched.length > 0) {
107
  candidates = matched;
108
- } else {
109
- // No account matches the model's plan requirements — don't fallback to incompatible accounts
110
- return null;
111
  }
 
112
  }
113
  }
114
 
 
96
 
97
  if (available.length === 0) return null;
98
 
99
+ // Model-aware selection: prefer accounts whose planType matches the model's known plans.
100
+ // If no account matches, fall back to all available accounts — the backend API's
101
+ // model list per plan is incomplete (e.g. free can use gpt-5.4 but /codex/models
102
+ // doesn't return it), so hard-blocking would reject valid requests.
103
  let candidates = available;
104
  if (options?.model) {
105
  const preferredPlans = getModelPlanTypes(options.model);
 
108
  const matched = available.filter((a) => a.planType && planSet.has(a.planType));
109
  if (matched.length > 0) {
110
  candidates = matched;
 
 
 
111
  }
112
+ // else: no matching plan accounts — fall back to all available
113
  }
114
  }
115
 
tests/unit/auth/account-pool.test.ts ADDED
@@ -0,0 +1,504 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Tests for AccountPool core scheduling logic.
3
+ * Migrated from src/auth/__tests__/ with @src/ path aliases.
4
+ */
5
+
6
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
7
+
8
+ // Mock fs before importing AccountPool
9
+ vi.mock("fs", () => ({
10
+ readFileSync: vi.fn(() => { throw new Error("ENOENT"); }),
11
+ writeFileSync: vi.fn(),
12
+ renameSync: vi.fn(),
13
+ existsSync: vi.fn(() => false),
14
+ mkdirSync: vi.fn(),
15
+ }));
16
+
17
+ // Mock paths
18
+ vi.mock("@src/paths.js", () => ({
19
+ getDataDir: vi.fn(() => "/tmp/test-data"),
20
+ getConfigDir: vi.fn(() => "/tmp/test-config"),
21
+ }));
22
+
23
+ // Mock config
24
+ vi.mock("@src/config.js", () => ({
25
+ getConfig: vi.fn(() => ({
26
+ auth: {
27
+ jwt_token: null,
28
+ rotation_strategy: "least_used",
29
+ rate_limit_backoff_seconds: 60,
30
+ },
31
+ server: {
32
+ proxy_api_key: null,
33
+ },
34
+ })),
35
+ }));
36
+
37
+ // Mock JWT utilities — all tokens are "valid"
38
+ vi.mock("@src/auth/jwt-utils.js", () => ({
39
+ decodeJwtPayload: vi.fn(() => ({ exp: Math.floor(Date.now() / 1000) + 3600 })),
40
+ extractChatGptAccountId: vi.fn((token: string) => `acct-${token.slice(0, 8)}`),
41
+ extractUserProfile: vi.fn((token: string) => ({
42
+ email: `${token.slice(0, 4)}@test.com`,
43
+ chatgpt_plan_type: "free",
44
+ })),
45
+ isTokenExpired: vi.fn(() => false),
46
+ }));
47
+
48
+ // Mock jitter to return the exact value (no randomness in tests)
49
+ vi.mock("@src/utils/jitter.js", () => ({
50
+ jitter: vi.fn((val: number) => val),
51
+ }));
52
+
53
+ // Mock model-store for model-aware selection tests
54
+ vi.mock("@src/models/model-store.js", () => ({
55
+ getModelPlanTypes: vi.fn(() => []),
56
+ }));
57
+
58
+ import { AccountPool } from "@src/auth/account-pool.js";
59
+ import { getConfig } from "@src/config.js";
60
+ import { isTokenExpired } from "@src/auth/jwt-utils.js";
61
+ import { getModelPlanTypes } from "@src/models/model-store.js";
62
+
63
+ describe("AccountPool", () => {
64
+ let pool: AccountPool;
65
+
66
+ beforeEach(() => {
67
+ vi.mocked(isTokenExpired).mockReturnValue(false);
68
+ vi.mocked(getConfig).mockReturnValue({
69
+ auth: {
70
+ jwt_token: null,
71
+ rotation_strategy: "least_used",
72
+ rate_limit_backoff_seconds: 60,
73
+ },
74
+ server: { proxy_api_key: null },
75
+ } as ReturnType<typeof getConfig>);
76
+ pool = new AccountPool();
77
+ });
78
+
79
+ afterEach(() => {
80
+ pool.destroy();
81
+ });
82
+
83
+ describe("addAccount + acquire", () => {
84
+ it("adds an account and acquires it", () => {
85
+ pool.addAccount("token-aaa");
86
+ const acquired = pool.acquire();
87
+ expect(acquired).not.toBeNull();
88
+ expect(acquired!.token).toBe("token-aaa");
89
+ });
90
+
91
+ it("deduplicates by accountId", () => {
92
+ const id1 = pool.addAccount("token-aaa");
93
+ const id2 = pool.addAccount("token-aaa");
94
+ expect(id1).toBe(id2);
95
+ });
96
+
97
+ it("returns null when no accounts exist", () => {
98
+ expect(pool.acquire()).toBeNull();
99
+ });
100
+ });
101
+
102
+ describe("least_used rotation", () => {
103
+ it("selects the account with lowest request_count", () => {
104
+ pool.addAccount("token-aaa");
105
+ pool.addAccount("token-bbb");
106
+
107
+ const first = pool.acquire()!;
108
+ pool.release(first.entryId, { input_tokens: 10, output_tokens: 5 });
109
+
110
+ const second = pool.acquire()!;
111
+ expect(second.entryId).not.toBe(first.entryId);
112
+ });
113
+ });
114
+
115
+ describe("round_robin rotation", () => {
116
+ it("cycles through accounts in order", () => {
117
+ vi.mocked(getConfig).mockReturnValue({
118
+ auth: {
119
+ jwt_token: null,
120
+ rotation_strategy: "round_robin",
121
+ rate_limit_backoff_seconds: 60,
122
+ },
123
+ server: { proxy_api_key: null },
124
+ } as ReturnType<typeof getConfig>);
125
+
126
+ const rrPool = new AccountPool();
127
+ rrPool.addAccount("token-aaa");
128
+ rrPool.addAccount("token-bbb");
129
+
130
+ const a1 = rrPool.acquire()!;
131
+ rrPool.release(a1.entryId);
132
+
133
+ const a2 = rrPool.acquire()!;
134
+ rrPool.release(a2.entryId);
135
+
136
+ const a3 = rrPool.acquire()!;
137
+ rrPool.release(a3.entryId);
138
+
139
+ expect(a3.entryId).toBe(a1.entryId);
140
+ expect(a1.entryId).not.toBe(a2.entryId);
141
+
142
+ rrPool.destroy();
143
+ });
144
+ });
145
+
146
+ describe("release", () => {
147
+ it("increments request_count and token usage", () => {
148
+ pool.addAccount("token-aaa");
149
+
150
+ const acquired = pool.acquire()!;
151
+ pool.release(acquired.entryId, { input_tokens: 100, output_tokens: 50 });
152
+
153
+ const accounts = pool.getAccounts();
154
+ expect(accounts[0].usage.request_count).toBe(1);
155
+ expect(accounts[0].usage.input_tokens).toBe(100);
156
+ expect(accounts[0].usage.output_tokens).toBe(50);
157
+ expect(accounts[0].usage.last_used).not.toBeNull();
158
+ });
159
+
160
+ it("unlocks account after release", () => {
161
+ pool.addAccount("token-aaa");
162
+
163
+ const a1 = pool.acquire()!;
164
+ expect(pool.acquire()).toBeNull();
165
+
166
+ pool.release(a1.entryId);
167
+ expect(pool.acquire()).not.toBeNull();
168
+ });
169
+ });
170
+
171
+ describe("markRateLimited", () => {
172
+ it("marks account as rate_limited and skips it in acquire", () => {
173
+ pool.addAccount("token-aaa");
174
+ pool.addAccount("token-bbb");
175
+
176
+ const first = pool.acquire()!;
177
+ pool.markRateLimited(first.entryId);
178
+
179
+ const summary = pool.getPoolSummary();
180
+ expect(summary.rate_limited).toBe(1);
181
+ expect(summary.active).toBe(1);
182
+
183
+ const second = pool.acquire()!;
184
+ expect(second.entryId).not.toBe(first.entryId);
185
+ });
186
+
187
+ it("countRequest option increments usage on 429", () => {
188
+ pool.addAccount("token-aaa");
189
+
190
+ const acquired = pool.acquire()!;
191
+ pool.markRateLimited(acquired.entryId, { countRequest: true });
192
+
193
+ const accounts = pool.getAccounts();
194
+ expect(accounts[0].usage.request_count).toBe(1);
195
+ });
196
+
197
+ it("auto-recovers after rate_limit_until passes", () => {
198
+ pool.addAccount("token-aaa");
199
+
200
+ const acquired = pool.acquire()!;
201
+ pool.markRateLimited(acquired.entryId, { retryAfterSec: -1 });
202
+
203
+ const summary = pool.getPoolSummary();
204
+ expect(summary.active).toBe(1);
205
+ expect(summary.rate_limited).toBe(0);
206
+ });
207
+ });
208
+
209
+ describe("stale lock auto-release", () => {
210
+ it("releases locks older than 5 minutes", () => {
211
+ pool.addAccount("token-aaa");
212
+
213
+ const acquired = pool.acquire()!;
214
+
215
+ const locks = (pool as unknown as { acquireLocks: Map<string, number> }).acquireLocks;
216
+ locks.set(acquired.entryId, Date.now() - 6 * 60 * 1000);
217
+
218
+ const reacquired = pool.acquire()!;
219
+ expect(reacquired).not.toBeNull();
220
+ expect(reacquired.entryId).toBe(acquired.entryId);
221
+ });
222
+ });
223
+
224
+ describe("expired tokens", () => {
225
+ it("skips expired accounts in acquire", () => {
226
+ vi.mocked(isTokenExpired).mockReturnValue(true);
227
+ pool.addAccount("token-expired");
228
+
229
+ expect(pool.acquire()).toBeNull();
230
+ expect(pool.getPoolSummary().expired).toBe(1);
231
+ });
232
+ });
233
+
234
+ describe("removeAccount", () => {
235
+ it("removes an account and clears its lock", () => {
236
+ pool.addAccount("token-aaa");
237
+
238
+ const acquired = pool.acquire()!;
239
+ pool.removeAccount(acquired.entryId);
240
+
241
+ expect(pool.getPoolSummary().total).toBe(0);
242
+ expect(pool.acquire()).toBeNull();
243
+ });
244
+ });
245
+
246
+ describe("resetUsage", () => {
247
+ it("resets counters to zero", () => {
248
+ pool.addAccount("token-aaa");
249
+
250
+ const acquired = pool.acquire()!;
251
+ pool.release(acquired.entryId, { input_tokens: 100, output_tokens: 50 });
252
+ pool.resetUsage(acquired.entryId);
253
+
254
+ const accounts = pool.getAccounts();
255
+ expect(accounts[0].usage.request_count).toBe(0);
256
+ expect(accounts[0].usage.input_tokens).toBe(0);
257
+ expect(accounts[0].usage.output_tokens).toBe(0);
258
+ });
259
+ });
260
+
261
+ describe("validateProxyApiKey", () => {
262
+ it("validates per-account proxy API key", () => {
263
+ pool.addAccount("token-aaa");
264
+
265
+ const accounts = pool.getAccounts();
266
+ const entry = pool.getEntry(accounts[0].id)!;
267
+ expect(pool.validateProxyApiKey(entry.proxyApiKey)).toBe(true);
268
+ expect(pool.validateProxyApiKey("wrong-key")).toBe(false);
269
+ });
270
+
271
+ it("validates config-level proxy API key", () => {
272
+ vi.mocked(getConfig).mockReturnValue({
273
+ auth: {
274
+ jwt_token: null,
275
+ rotation_strategy: "least_used",
276
+ rate_limit_backoff_seconds: 60,
277
+ },
278
+ server: { proxy_api_key: "global-key-123" },
279
+ } as ReturnType<typeof getConfig>);
280
+
281
+ expect(pool.validateProxyApiKey("global-key-123")).toBe(true);
282
+ });
283
+ });
284
+
285
+ // ── Tier 1: Branch coverage additions ────────────────────────────
286
+
287
+ describe("release without usage", () => {
288
+ it("increments window_request_count but not tokens when no usage provided", () => {
289
+ pool.addAccount("token-aaa");
290
+
291
+ const acquired = pool.acquire()!;
292
+ pool.release(acquired.entryId); // no usage param
293
+
294
+ const accounts = pool.getAccounts();
295
+ expect(accounts[0].usage.request_count).toBe(1);
296
+ expect(accounts[0].usage.last_used).not.toBeNull();
297
+ expect(accounts[0].usage.window_request_count).toBe(1);
298
+ // Token counts should stay at zero
299
+ expect(accounts[0].usage.input_tokens).toBe(0);
300
+ expect(accounts[0].usage.output_tokens).toBe(0);
301
+ expect(accounts[0].usage.window_input_tokens).toBe(0);
302
+ expect(accounts[0].usage.window_output_tokens).toBe(0);
303
+ });
304
+ });
305
+
306
+ describe("markRateLimited without countRequest", () => {
307
+ it("does not increment request_count when countRequest not set", () => {
308
+ pool.addAccount("token-aaa");
309
+
310
+ const acquired = pool.acquire()!;
311
+ pool.markRateLimited(acquired.entryId); // no options
312
+
313
+ const accounts = pool.getAccounts();
314
+ expect(accounts[0].usage.request_count).toBe(0);
315
+ expect(accounts[0].usage.window_request_count).toBe(0);
316
+ });
317
+ });
318
+
319
+ describe("model-aware selection", () => {
320
+ it("falls back to all accounts when model has plan requirements but no account matches", () => {
321
+ pool.addAccount("token-aaa"); // planType defaults to "free"
322
+ pool.addAccount("token-bbb");
323
+
324
+ // Mock getModelPlanTypes to require "pro" plan
325
+ vi.mocked(getModelPlanTypes).mockReturnValue(["pro"]);
326
+
327
+ // Should fall back to available accounts instead of returning null,
328
+ // because the backend model list per plan is incomplete
329
+ const acquired = pool.acquire({ model: "gpt-pro-model" });
330
+ expect(acquired).not.toBeNull();
331
+ });
332
+ });
333
+
334
+ describe("window auto-reset", () => {
335
+ it("catches up across multiple expired windows", () => {
336
+ pool.addAccount("token-aaa");
337
+
338
+ const acquired = pool.acquire()!;
339
+ pool.release(acquired.entryId, { input_tokens: 10, output_tokens: 5 });
340
+
341
+ const entry = pool.getEntry(acquired.entryId)!;
342
+ const nowSec = Math.floor(Date.now() / 1000);
343
+ // Set window_reset_at 3 windows ago (window = 3600s)
344
+ entry.usage.window_reset_at = nowSec - 3 * 3600;
345
+ entry.usage.limit_window_seconds = 3600;
346
+ entry.usage.window_request_count = 5;
347
+ entry.usage.window_input_tokens = 100;
348
+ entry.usage.window_output_tokens = 50;
349
+
350
+ // Trigger refreshStatus via getAccounts
351
+ const accounts = pool.getAccounts();
352
+
353
+ // Window counters should be reset
354
+ expect(accounts[0].usage.window_request_count).toBe(0);
355
+ expect(accounts[0].usage.window_input_tokens).toBe(0);
356
+ expect(accounts[0].usage.window_output_tokens).toBe(0);
357
+ // Next window_reset_at should be in the future
358
+ expect(accounts[0].usage.window_reset_at).not.toBeNull();
359
+ expect(accounts[0].usage.window_reset_at!).toBeGreaterThan(nowSec);
360
+ });
361
+
362
+ it("sets window_reset_at to null when limit_window_seconds is null", () => {
363
+ pool.addAccount("token-aaa");
364
+
365
+ const acquired = pool.acquire()!;
366
+ pool.release(acquired.entryId);
367
+
368
+ const entry = pool.getEntry(acquired.entryId)!;
369
+ const nowSec = Math.floor(Date.now() / 1000);
370
+ entry.usage.window_reset_at = nowSec - 100; // expired
371
+ entry.usage.limit_window_seconds = null;
372
+ entry.usage.window_request_count = 5;
373
+
374
+ const accounts = pool.getAccounts();
375
+
376
+ expect(accounts[0].usage.window_request_count).toBe(0);
377
+ expect(accounts[0].usage.window_reset_at).toBeNull();
378
+ });
379
+ });
380
+
381
+ describe("syncRateLimitWindow", () => {
382
+ it("does not reset counters when timestamp is unchanged", () => {
383
+ pool.addAccount("token-aaa");
384
+
385
+ const acquired = pool.acquire()!;
386
+ pool.release(acquired.entryId, { input_tokens: 10, output_tokens: 5 });
387
+
388
+ const entry = pool.getEntry(acquired.entryId)!;
389
+ // Use a future timestamp so refreshStatus does NOT auto-reset
390
+ const futureResetAt = Math.floor(Date.now() / 1000) + 7200;
391
+ entry.usage.window_reset_at = futureResetAt;
392
+
393
+ // Sync with the SAME timestamp — should be a no-op
394
+ pool.syncRateLimitWindow(acquired.entryId, futureResetAt, 3600);
395
+
396
+ // Counters should NOT be reset (same timestamp)
397
+ const afterEntry = pool.getEntry(acquired.entryId)!;
398
+ expect(afterEntry.usage.window_request_count).toBe(1);
399
+ expect(afterEntry.usage.window_input_tokens).toBe(10);
400
+ expect(afterEntry.usage.window_output_tokens).toBe(5);
401
+ });
402
+ });
403
+
404
+ describe("least_used window_reset_at tiebreaker", () => {
405
+ it("prefers accounts with earlier window_reset_at on same request_count", () => {
406
+ pool.addAccount("token-aaa");
407
+ pool.addAccount("token-bbb");
408
+
409
+ const accounts = pool.getAccounts();
410
+ const entryA = pool.getEntry(accounts[0].id)!;
411
+ const entryB = pool.getEntry(accounts[1].id)!;
412
+
413
+ const nowSec = Math.floor(Date.now() / 1000);
414
+ // A resets in 1 hour, B resets in 5 hours
415
+ entryA.usage.window_reset_at = nowSec + 3600;
416
+ entryB.usage.window_reset_at = nowSec + 18000;
417
+
418
+ const acquired = pool.acquire()!;
419
+ pool.release(acquired.entryId);
420
+ // Should prefer A (sooner reset)
421
+ expect(acquired.entryId).toBe(entryA.id);
422
+ });
423
+
424
+ it("ranks accounts with null window_reset_at after those with known values", () => {
425
+ pool.addAccount("token-aaa");
426
+ pool.addAccount("token-bbb");
427
+
428
+ const accounts = pool.getAccounts();
429
+ const entryA = pool.getEntry(accounts[0].id)!;
430
+ const entryB = pool.getEntry(accounts[1].id)!;
431
+
432
+ // A has no window info, B has a known reset
433
+ entryA.usage.window_reset_at = null;
434
+ entryB.usage.window_reset_at = Math.floor(Date.now() / 1000) + 7200;
435
+
436
+ const acquired = pool.acquire()!;
437
+ pool.release(acquired.entryId);
438
+ // Should prefer B (known reset) over A (unknown/Infinity)
439
+ expect(acquired.entryId).toBe(entryB.id);
440
+ });
441
+
442
+ it("falls back to last_used LRU when window_reset_at is equal", () => {
443
+ pool.addAccount("token-aaa");
444
+ pool.addAccount("token-bbb");
445
+
446
+ const accounts = pool.getAccounts();
447
+ const entryA = pool.getEntry(accounts[0].id)!;
448
+ const entryB = pool.getEntry(accounts[1].id)!;
449
+
450
+ const resetAt = Math.floor(Date.now() / 1000) + 3600;
451
+ entryA.usage.window_reset_at = resetAt;
452
+ entryB.usage.window_reset_at = resetAt;
453
+ // A used more recently, B used earlier → B should be preferred (LRU)
454
+ entryA.usage.last_used = new Date(Date.now() - 1000).toISOString();
455
+ entryB.usage.last_used = new Date(Date.now() - 60000).toISOString();
456
+
457
+ const acquired = pool.acquire()!;
458
+ pool.release(acquired.entryId);
459
+ expect(acquired.entryId).toBe(entryB.id);
460
+ });
461
+ });
462
+
463
+ describe("loadPersisted edge cases", () => {
464
+ it("skips entries without id or token", async () => {
465
+ const { existsSync: mockExistsSync, readFileSync: mockReadFileSync } = await import("fs");
466
+
467
+ vi.mocked(mockExistsSync).mockReturnValue(false); // no legacy
468
+ vi.mocked(mockReadFileSync).mockImplementation(() => {
469
+ throw new Error("ENOENT");
470
+ });
471
+
472
+ // Create fresh pool with specific persisted data
473
+ vi.mocked(mockExistsSync).mockImplementation((path: unknown) => {
474
+ if (typeof path === "string" && path.includes("accounts.json")) return true;
475
+ return false;
476
+ });
477
+ vi.mocked(mockReadFileSync).mockReturnValue(JSON.stringify({
478
+ accounts: [
479
+ { id: null, token: "valid-token", usage: {} }, // missing id
480
+ { id: "entry1", token: null, usage: {} }, // missing token
481
+ { id: "entry2", token: "valid-token-2", usage: { request_count: 0, input_tokens: 0, output_tokens: 0, last_used: null, rate_limit_until: null }, planType: "free", email: "test@test.com", accountId: "acct-123" },
482
+ ],
483
+ }));
484
+
485
+ const freshPool = new AccountPool();
486
+ expect(freshPool.getPoolSummary().total).toBe(1); // only entry2 loaded
487
+ freshPool.destroy();
488
+ });
489
+
490
+ it("handles invalid JSON gracefully", async () => {
491
+ const { existsSync: mockExistsSync, readFileSync: mockReadFileSync } = await import("fs");
492
+
493
+ vi.mocked(mockExistsSync).mockImplementation((path: unknown) => {
494
+ if (typeof path === "string" && path.includes("accounts.json")) return true;
495
+ return false;
496
+ });
497
+ vi.mocked(mockReadFileSync).mockReturnValue("NOT VALID JSON {{{");
498
+
499
+ const freshPool = new AccountPool();
500
+ expect(freshPool.getPoolSummary().total).toBe(0);
501
+ freshPool.destroy();
502
+ });
503
+ });
504
+ });