Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,167 @@ describe('claude subscription snapshot hydration race', () => {
expect(current?.updatedAt).toBe(2);
});

it('does not clobber the persisted row with a window-less snapshot when hydration failed', async () => {
const broadcaster = await import('../usageBroadcaster');
// 冷缓存 hydration 读库失败 → 内存为空; 一笔 status-only headers 快照
// (仅 rateLimitStatus, 无任何窗口)到达。merge 无旧值可保 → 全空快照会被
// 无条件 upsert, 抹掉持久化行里的有效窗口(与 codex 侧同形状的覆盖事故)。
mocks.queryOne.mockRejectedValue(new Error('db busy'));

await broadcaster.recordClaudeSubscriptionUsageSnapshot({
fiveHour: null,
sevenDay: null,
rateLimitStatus: 'allowed',
source: 'unified-headers',
updatedAt: 5,
});

expect(mocks.exec).not.toHaveBeenCalled();
});

// 与 codex 侧同一条: 读库失败不得把缓存标记成已加载, 否则本进程之后再也无法落库。
it('retries hydration after a transient read failure instead of giving up', async () => {
const broadcaster = await import('../usageBroadcaster');
mocks.queryOne.mockRejectedValueOnce(new Error('db busy'));

await broadcaster.recordClaudeSubscriptionUsageSnapshot({
fiveHour: null, sevenDay: null, rateLimitStatus: 'allowed',
source: 'unified-headers', updatedAt: 1,
});
expect(mocks.exec).not.toHaveBeenCalled();

mocks.queryOne.mockResolvedValue(null);
await broadcaster.recordClaudeSubscriptionUsageSnapshot({
fiveHour: { utilization: 42 }, source: 'unified-headers', updatedAt: 2,
});

expect(mocks.exec).toHaveBeenCalled();
const lastExecParams = (mocks.exec.mock.calls.at(-1) as unknown[] | undefined)?.[1] as unknown[];
const persisted = JSON.parse(lastExecParams[1] as string);
expect(persisted.fiveHour?.utilization).toBe(42);
});

// 与 codex 侧同一条: 重试读到的旧行不得顶掉 hydration 失败期间收到的新快照。
it('keeps snapshots received while hydration was failing', async () => {
const broadcaster = await import('../usageBroadcaster');
mocks.queryOne.mockRejectedValueOnce(new Error('db busy'));

await broadcaster.recordClaudeSubscriptionUsageSnapshot({
fiveHour: { utilization: 42 }, source: 'unified-headers', updatedAt: 2,
});
expect(mocks.exec).not.toHaveBeenCalled();

mocks.queryOne.mockResolvedValue({
snapshot: JSON.stringify({
fiveHour: { utilization: 5 },
scoped: [{ utilization: 12, modelDisplayName: 'Opus' }],
source: 'oauth-endpoint',
updatedAt: 1,
}),
});
const current = await broadcaster.readClaudeSubscriptionUsageSnapshot();
// 内存里的 42% 胜出, 同时补上库里独有的 scoped(端点源才有, headers 源没有)。
expect(current?.fiveHour?.utilization).toBe(42);
expect(current?.scoped?.[0]?.modelDisplayName).toBe('Opus');
});

// 与 codex 侧同款: owner 缺失时 IIFE 同步走完, 句柄不能在它的 finally 里清 ——
// 否则会被外层赋值写回, 之后永远复用这个已 resolve 的 Promise, 再也不查库。
it('reads the database once the owner becomes available', async () => {
const broadcaster = await import('../usageBroadcaster');
mocks.getCurrentUserId.mockReturnValue(null as unknown as string);

await broadcaster.recordClaudeSubscriptionUsageSnapshot({
fiveHour: { utilization: 10 }, source: 'unified-headers', updatedAt: 1,
});
expect(mocks.queryOne).not.toHaveBeenCalled();
expect(mocks.exec).not.toHaveBeenCalled();

// 登录后必须重新查库。跨 owner 变化的那一笔按既有世代语义会被丢弃(它属于换号前
// 的上下文), 下一笔恢复正常落库。
mocks.getCurrentUserId.mockReturnValue('user-1');
mocks.queryOne.mockResolvedValue(null);
await broadcaster.recordClaudeSubscriptionUsageSnapshot({
fiveHour: { utilization: 20 }, source: 'unified-headers', updatedAt: 2,
});
expect(mocks.queryOne).toHaveBeenCalled();

await broadcaster.recordClaudeSubscriptionUsageSnapshot({
fiveHour: { utilization: 30 }, source: 'unified-headers', updatedAt: 3,
});
expect(mocks.exec).toHaveBeenCalled();
});

it('persists a rejected status even without windows (与 codex 侧 reached 标记同口径)', async () => {
const broadcaster = await import('../usageBroadcaster');
// rejected 是权威的「请求已被拒」信号(isClaudeSubscriptionAlerting 直接据此告警),
// 缺窗口时也必须落库 —— 否则重启后 chip 不知道当前正被限流。
mocks.queryOne.mockResolvedValue(null);

await broadcaster.recordClaudeSubscriptionUsageSnapshot({
fiveHour: null,
sevenDay: null,
rateLimitStatus: 'rejected',
source: 'unified-headers',
updatedAt: 5,
});

expect(mocks.exec).toHaveBeenCalled();
});

// 反向转换同样必须落库: 库里是 rejected、后续 allowed 的 status-only 事件没有窗口,
// 按内容判会被拦下, 库里就永远停在 rejected —— 重启后 chip 挂着一个假的限流警告。
it('persists an allowed transition that clears a persisted rejected status', async () => {
const broadcaster = await import('../usageBroadcaster');
mocks.queryOne.mockResolvedValue({
snapshot: JSON.stringify({
fiveHour: null,
sevenDay: null,
rateLimitStatus: 'rejected',
source: 'unified-headers',
updatedAt: 1,
}),
});

await broadcaster.recordClaudeSubscriptionUsageSnapshot({
fiveHour: null,
sevenDay: null,
rateLimitStatus: 'allowed',
source: 'unified-headers',
updatedAt: 5,
});

expect(mocks.exec).toHaveBeenCalled();
const lastExecParams = (mocks.exec.mock.calls.at(-1) as unknown[] | undefined)?.[1] as unknown[];
const persisted = JSON.parse(lastExecParams[1] as string);
expect(persisted.rateLimitStatus).toBe('allowed');
});

it('persists a status-only snapshot merged onto hydrated windows (regression guard)', async () => {
const broadcaster = await import('../usageBroadcaster');
// hydration 正常命中 → status-only 增量并入已有窗口, 照常落库且窗口保留。
mocks.queryOne.mockResolvedValue({
snapshot: JSON.stringify({
fiveHour: { utilization: 54, resetsAt: 1_786_355_999 },
source: 'oauth-endpoint',
updatedAt: 1,
}),
});

await broadcaster.recordClaudeSubscriptionUsageSnapshot({
fiveHour: null,
sevenDay: null,
rateLimitStatus: 'allowed',
source: 'unified-headers',
updatedAt: 5,
});

expect(mocks.exec).toHaveBeenCalled();
const lastExecParams = (mocks.exec.mock.calls.at(-1) as unknown[] | undefined)?.[1] as unknown[];
const persisted = JSON.parse(lastExecParams[1] as string);
expect(persisted.fiveHour?.utilization).toBe(54);
});

it('discards an in-flight hydration result when clear wins the race', async () => {
const broadcaster = await import('../usageBroadcaster');
const dbRead = deferred<{ snapshot: string } | null>();
Expand Down
Loading