Skip to content

Commit 43f0dcc

Browse files
committed
Merge pull request #572 from ndycode/claude/audit-53-rotation-proxy-state-tests
test: cover rotation proxy state init and stale-state recovery
2 parents 5ddd970 + a032a28 commit 43f0dcc

1 file changed

Lines changed: 200 additions & 0 deletions

File tree

test/rotation-proxy-state.test.ts

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2+
import { AccountManager } from "../lib/accounts.js";
3+
import type { RotationProxyStateInit } from "../lib/runtime/rotation-proxy-state.js";
4+
import type { AccountStorageV3 } from "../lib/storage.js";
5+
6+
const { recordRuntimeResetMock, recordRuntimeReloadMock } = vi.hoisted(() => ({
7+
recordRuntimeResetMock: vi.fn(),
8+
recordRuntimeReloadMock: vi.fn(),
9+
}));
10+
11+
vi.mock("../lib/runtime/runtime-observability.js", async (importOriginal) => {
12+
const actual = await importOriginal<
13+
typeof import("../lib/runtime/runtime-observability.js")
14+
>();
15+
return {
16+
...actual,
17+
recordRuntimeReset: recordRuntimeResetMock,
18+
recordRuntimeReload: recordRuntimeReloadMock,
19+
};
20+
});
21+
22+
const { createRotationProxyState, recoverStaleRuntimeState } = await import(
23+
"../lib/runtime/rotation-proxy-state.js"
24+
);
25+
26+
const NOW = Date.now();
27+
28+
function storageWith(count: number): AccountStorageV3 {
29+
return {
30+
version: 3,
31+
activeIndex: 0,
32+
activeIndexByFamily: {},
33+
accounts: Array.from({ length: count }, (_unused, index) => ({
34+
email: `account-${index + 1}@example.com`,
35+
accountId: `acc_${index + 1}`,
36+
refreshToken: `refresh-${index + 1}`,
37+
accessToken: `access-${index + 1}`,
38+
expiresAt: NOW + 3_600_000,
39+
addedAt: NOW - 60_000,
40+
lastUsed: NOW - 60_000,
41+
enabled: true,
42+
})),
43+
};
44+
}
45+
46+
function stateInit(): RotationProxyStateInit {
47+
return {
48+
activeAccountManager: new AccountManager(undefined, storageWith(1)),
49+
routingMutexMode: "enabled",
50+
schedulingStrategy: "hybrid",
51+
fetchImpl: fetch,
52+
upstreamBaseUrl: "https://upstream.example",
53+
clientApiKey: "key",
54+
now: () => NOW,
55+
tokenRefreshSkewMs: 30_000,
56+
networkErrorCooldownMs: 10_000,
57+
serverErrorCooldownMs: 10_000,
58+
tokenInvalidationCooldownMs: 300_000,
59+
minRotationIntervalMs: 0,
60+
pidOffsetEnabled: false,
61+
fetchTimeoutMs: 30_000,
62+
streamStallTimeoutMs: 30_000,
63+
maxRuntimeAccountAttempts: 3,
64+
maxRequestBodyBytes: 1024,
65+
quotaRemainingPercentThreshold: 0,
66+
sessionAffinityStore: null,
67+
lastObservedAffinityGeneration: 0,
68+
};
69+
}
70+
71+
let loadFromDisk: ReturnType<typeof vi.spyOn>;
72+
73+
beforeEach(() => {
74+
vi.clearAllMocks();
75+
loadFromDisk = vi.spyOn(AccountManager, "loadFromDisk");
76+
});
77+
78+
afterEach(() => {
79+
loadFromDisk.mockRestore();
80+
});
81+
82+
describe("createRotationProxyState", () => {
83+
it("seeds the known-manager set and a zeroed status from the injected clock", () => {
84+
const init = stateInit();
85+
86+
const state = createRotationProxyState(init);
87+
88+
expect([...state.knownAccountManagers]).toEqual([
89+
init.activeAccountManager,
90+
]);
91+
expect(state.status).toStrictEqual({
92+
startedAt: NOW,
93+
totalRequests: 0,
94+
upstreamRequests: 0,
95+
retries: 0,
96+
rotations: 0,
97+
streamsStarted: 0,
98+
lastError: null,
99+
lastAccountIndex: null,
100+
lastAccountLabel: null,
101+
lastAccountId: null,
102+
lastAccountUpdatedAt: null,
103+
});
104+
expect(state.lastGlobalAccountIndex).toBeNull();
105+
expect(state.lastStaleRuntimeReloadAt).toBe(0);
106+
});
107+
});
108+
109+
describe("recoverStaleRuntimeState", () => {
110+
it("reloads from disk, swaps the active manager, and records observability", async () => {
111+
const state = createRotationProxyState(stateInit());
112+
const previousManager = state.activeAccountManager;
113+
const reloaded = new AccountManager(undefined, storageWith(2));
114+
loadFromDisk.mockResolvedValue(reloaded);
115+
116+
const result = await recoverStaleRuntimeState(state);
117+
118+
expect(result).toBe(reloaded);
119+
expect(state.activeAccountManager).toBe(reloaded);
120+
// The previous manager stays known so in-flight requests can finish.
121+
expect(state.knownAccountManagers.has(previousManager)).toBe(true);
122+
expect(state.knownAccountManagers.has(reloaded)).toBe(true);
123+
// The configured mutex mode carries over to the reloaded pool.
124+
expect(reloaded.getRoutingMutexMode()).toBe("enabled");
125+
expect(state.lastStaleRuntimeReloadAt).toBeGreaterThan(0);
126+
expect(recordRuntimeResetMock).toHaveBeenCalledExactlyOnceWith(
127+
"pool-exhausted-no-account",
128+
);
129+
expect(recordRuntimeReloadMock).toHaveBeenCalledExactlyOnceWith(
130+
"pool-exhausted-no-account",
131+
);
132+
});
133+
134+
it("dedupes within the 1s window and reloads again once it expires", async () => {
135+
// The dedupe guard runs on the real wall clock (deliberately not the
136+
// injected now()), so fake timers make the window deterministic.
137+
vi.useFakeTimers();
138+
try {
139+
const state = createRotationProxyState(stateInit());
140+
const reloaded = new AccountManager(undefined, storageWith(2));
141+
loadFromDisk.mockResolvedValue(reloaded);
142+
143+
await recoverStaleRuntimeState(state);
144+
const second = await recoverStaleRuntimeState(state);
145+
146+
// The second call lands inside the 1s dedupe window: no second reload.
147+
expect(second).toBe(reloaded);
148+
expect(loadFromDisk).toHaveBeenCalledTimes(1);
149+
150+
// Once the window expires, the next call reloads from disk again.
151+
vi.advanceTimersByTime(1_001);
152+
const freshest = new AccountManager(undefined, storageWith(3));
153+
loadFromDisk.mockResolvedValue(freshest);
154+
await expect(recoverStaleRuntimeState(state)).resolves.toBe(freshest);
155+
expect(loadFromDisk).toHaveBeenCalledTimes(2);
156+
} finally {
157+
vi.useRealTimers();
158+
}
159+
});
160+
161+
it("shares one in-flight reload between concurrent callers", async () => {
162+
const state = createRotationProxyState(stateInit());
163+
const reloaded = new AccountManager(undefined, storageWith(2));
164+
let releaseReload!: () => void;
165+
const gate = new Promise<void>((resolve) => {
166+
releaseReload = resolve;
167+
});
168+
loadFromDisk.mockImplementation(async () => {
169+
await gate;
170+
return reloaded;
171+
});
172+
173+
const firstPending = recoverStaleRuntimeState(state);
174+
const secondPending = recoverStaleRuntimeState(state);
175+
releaseReload();
176+
const [first, second] = await Promise.all([firstPending, secondPending]);
177+
178+
expect(first).toBe(reloaded);
179+
expect(second).toBe(reloaded);
180+
expect(loadFromDisk).toHaveBeenCalledTimes(1);
181+
});
182+
183+
it("reports a failed reload and allows the next attempt to retry", async () => {
184+
const state = createRotationProxyState(stateInit());
185+
loadFromDisk.mockRejectedValueOnce(new Error("disk exploded"));
186+
187+
const failed = await recoverStaleRuntimeState(state);
188+
189+
expect(failed).toBeNull();
190+
expect(state.status.lastError).toBe("disk exploded");
191+
// The failure must not arm the dedupe window.
192+
expect(state.lastStaleRuntimeReloadAt).toBe(0);
193+
// The failure does not arm the dedupe window or leak a stale promise:
194+
// the next call retries the reload.
195+
const reloaded = new AccountManager(undefined, storageWith(2));
196+
loadFromDisk.mockResolvedValue(reloaded);
197+
await expect(recoverStaleRuntimeState(state)).resolves.toBe(reloaded);
198+
expect(loadFromDisk).toHaveBeenCalledTimes(2);
199+
});
200+
});

0 commit comments

Comments
 (0)