Skip to content

Commit 4cb9624

Browse files
committed
Merge pull request #543 from ndycode/claude/audit-25-coverage-gaps
test(lib): cover the highest-value gaps in four logic modules
2 parents a52ccab + 029dd61 commit 4cb9624

4 files changed

Lines changed: 633 additions & 2 deletions

test/codex-manager-models-command.test.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,5 +52,92 @@ describe("models command", () => {
5252
expect(exitCode).toBe(1);
5353
expect(String(logError.mock.calls[0]?.[0])).toContain("Unknown models option");
5454
});
55+
56+
it("prints usage for --help without loading accounts", async () => {
57+
const logInfo = vi.fn();
58+
const loadAccounts = vi.fn(async () => storage);
59+
const exitCode = await runModelsCommand(["--help"], {
60+
setStoragePath: vi.fn(),
61+
loadAccounts,
62+
logInfo,
63+
logError: vi.fn(),
64+
});
65+
expect(exitCode).toBe(0);
66+
expect(loadAccounts).not.toHaveBeenCalled();
67+
expect(String(logInfo.mock.calls[0]?.[0])).toContain(
68+
"codex-multi-auth models [--json] [--model <model>]",
69+
);
70+
});
71+
72+
it("rejects --model with a missing, empty, or flag-like value", async () => {
73+
const run = async (args: string[]) => {
74+
const logError = vi.fn();
75+
const exitCode = await runModelsCommand(args, {
76+
setStoragePath: vi.fn(),
77+
loadAccounts: async () => storage,
78+
logInfo: vi.fn(),
79+
logError,
80+
});
81+
return { exitCode, message: String(logError.mock.calls[0]?.[0]) };
82+
};
83+
84+
for (const args of [["--model"], ["--model", "--json"], ["--model="]]) {
85+
const { exitCode, message } = await run(args);
86+
expect(exitCode).toBe(1);
87+
expect(message).toContain("Missing value for --model");
88+
}
89+
});
90+
91+
it("prints per-account availability lines in text mode", async () => {
92+
const logInfo = vi.fn();
93+
const exitCode = await runModelsCommand(["--model", "gpt-5.3-codex"], {
94+
setStoragePath: vi.fn(),
95+
loadAccounts: async () => storage,
96+
loadQuotaCache: async () => ({ byAccountId: {}, byEmail: {} }),
97+
logInfo,
98+
logError: vi.fn(),
99+
getNow: () => 123,
100+
});
101+
expect(exitCode).toBe(0);
102+
expect(String(logInfo.mock.calls[0]?.[0])).toBe(
103+
"Account 1 gpt-5.3-codex: available",
104+
);
105+
});
106+
107+
it("marks disabled accounts unavailable with the reason list", async () => {
108+
const disabledStorage: AccountStorageV3 = {
109+
...storage,
110+
accounts: [{ ...storage.accounts[0]!, enabled: false }],
111+
};
112+
const logInfo = vi.fn();
113+
const exitCode = await runModelsCommand(["--model", "gpt-5.3-codex"], {
114+
setStoragePath: vi.fn(),
115+
loadAccounts: async () => disabledStorage,
116+
loadQuotaCache: async () => ({ byAccountId: {}, byEmail: {} }),
117+
logInfo,
118+
logError: vi.fn(),
119+
getNow: () => 123,
120+
});
121+
expect(exitCode).toBe(0);
122+
expect(String(logInfo.mock.calls[0]?.[0])).toBe(
123+
"Account 1 gpt-5.3-codex: unavailable (account disabled)",
124+
);
125+
});
126+
127+
it("reports empty matrices and survives quota cache load failures", async () => {
128+
const logInfo = vi.fn();
129+
const exitCode = await runModelsCommand([], {
130+
setStoragePath: vi.fn(),
131+
loadAccounts: async () => null,
132+
loadQuotaCache: async () => {
133+
throw new Error("quota cache unreadable");
134+
},
135+
logInfo,
136+
logError: vi.fn(),
137+
getNow: () => 123,
138+
});
139+
expect(exitCode).toBe(0);
140+
expect(String(logInfo.mock.calls[0]?.[0])).toBe("No accounts configured.");
141+
});
55142
});
56143

test/codex-manager-usage-command.test.ts

Lines changed: 137 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
1-
import { describe, expect, it, vi } from "vitest";
1+
import { afterEach, describe, expect, it, vi } from "vitest";
2+
import { promises as fs } from "node:fs";
3+
import { tmpdir } from "node:os";
4+
import { join } from "node:path";
25
import type { UsageLedgerRow, UsageSummary } from "../lib/usage/index.js";
36
import { runUsageCommand } from "../lib/codex-manager/commands/usage.js";
7+
import { removeWithRetry } from "./helpers/remove-with-retry.js";
48

59
function makeSummary(overrides: Partial<UsageSummary> = {}): UsageSummary {
610
return {
@@ -228,3 +232,135 @@ describe("usage command", () => {
228232
});
229233
});
230234

235+
describe("usage command --since parsing", () => {
236+
const NOW = 1_750_000_000_000;
237+
238+
afterEach(() => {
239+
vi.useRealTimers();
240+
});
241+
242+
async function sinceSentToLedger(
243+
args: string[],
244+
): Promise<number | Date | string | undefined> {
245+
let captured: number | Date | string | undefined;
246+
const exitCode = await runUsageCommand(args, {
247+
logInfo: vi.fn(),
248+
summarizeUsage: async (query) => {
249+
captured = query?.since;
250+
return makeSummary();
251+
},
252+
});
253+
expect(exitCode).toBe(0);
254+
return captured;
255+
}
256+
257+
it("resolves relative durations against the current clock", async () => {
258+
vi.useFakeTimers();
259+
vi.setSystemTime(NOW);
260+
261+
await expect(sinceSentToLedger(["--since", "30m"])).resolves.toBe(
262+
NOW - 30 * 60_000,
263+
);
264+
await expect(sinceSentToLedger(["--since=24h"])).resolves.toBe(
265+
NOW - 24 * 3_600_000,
266+
);
267+
await expect(sinceSentToLedger(["--since", "7d"])).resolves.toBe(
268+
NOW - 7 * 86_400_000,
269+
);
270+
await expect(sinceSentToLedger(["--since", "2W"])).resolves.toBe(
271+
NOW - 2 * 604_800_000,
272+
);
273+
});
274+
275+
it("passes epoch numbers through as numbers and dates as strings", async () => {
276+
await expect(sinceSentToLedger(["--since", "12345"])).resolves.toBe(12345);
277+
await expect(sinceSentToLedger(["--since", " 2026-01-02 "])).resolves.toBe(
278+
"2026-01-02",
279+
);
280+
});
281+
282+
it("rejects --since without a value", async () => {
283+
const logError = vi.fn();
284+
const logInfo = vi.fn();
285+
const exitCode = await runUsageCommand(["--since"], { logError, logInfo });
286+
expect(exitCode).toBe(1);
287+
expect(String(logError.mock.calls[0]?.[0])).toContain(
288+
"Missing value for --since",
289+
);
290+
// parse failures also re-print the usage help
291+
expect(String(logInfo.mock.calls[0]?.[0])).toContain("Usage:");
292+
});
293+
});
294+
295+
describe("usage command default file writer", () => {
296+
let tempDir: string;
297+
298+
afterEach(async () => {
299+
vi.restoreAllMocks();
300+
await removeWithRetry(tempDir, { recursive: true, force: true });
301+
});
302+
303+
function deps(overrides: { logError?: ReturnType<typeof vi.fn> } = {}) {
304+
return {
305+
logInfo: vi.fn(),
306+
logError: vi.fn(),
307+
getCwd: () => tempDir,
308+
summarizeUsage: async () => makeSummary(),
309+
...overrides,
310+
};
311+
}
312+
313+
it("writes the rendered report atomically into nested directories", async () => {
314+
tempDir = await fs.mkdtemp(join(tmpdir(), "codex-usage-write-"));
315+
const commandDeps = deps();
316+
const exitCode = await runUsageCommand(
317+
["--out", join("reports", "usage.txt")],
318+
commandDeps,
319+
);
320+
321+
expect(exitCode).toBe(0);
322+
const written = await fs.readFile(
323+
join(tempDir, "reports", "usage.txt"),
324+
"utf8",
325+
);
326+
expect(written).toContain("Usage summary by model");
327+
expect(written.endsWith("\n")).toBe(true);
328+
// the staged .tmp file must be consumed by the rename
329+
const leftovers = await fs.readdir(join(tempDir, "reports"));
330+
expect(leftovers).toEqual(["usage.txt"]);
331+
});
332+
333+
it("retries the final rename on transient EBUSY and still succeeds", async () => {
334+
tempDir = await fs.mkdtemp(join(tmpdir(), "codex-usage-retry-"));
335+
const renameSpy = vi
336+
.spyOn(fs, "rename")
337+
.mockRejectedValueOnce(Object.assign(new Error("busy"), { code: "EBUSY" }));
338+
const exitCode = await runUsageCommand(["--out", "usage.txt"], deps());
339+
340+
expect(exitCode).toBe(0);
341+
expect(renameSpy).toHaveBeenCalledTimes(2);
342+
const written = await fs.readFile(join(tempDir, "usage.txt"), "utf8");
343+
expect(written).toContain("Usage summary by model");
344+
expect(await fs.readdir(tempDir)).toEqual(["usage.txt"]);
345+
});
346+
347+
it("fails fast on non-retryable rename errors and removes the staged temp file", async () => {
348+
tempDir = await fs.mkdtemp(join(tmpdir(), "codex-usage-fail-"));
349+
vi.spyOn(fs, "rename").mockRejectedValue(
350+
Object.assign(new Error("no space"), { code: "ENOSPC" }),
351+
);
352+
const logError = vi.fn();
353+
const exitCode = await runUsageCommand(
354+
["--out", "usage.txt"],
355+
deps({ logError }),
356+
);
357+
358+
expect(exitCode).toBe(1);
359+
expect(String(logError.mock.calls[0]?.[0])).toContain(
360+
"Failed to write usage report: no space",
361+
);
362+
// neither the target nor any secret/staging .tmp file may survive
363+
expect(await fs.readdir(tempDir)).toEqual([]);
364+
});
365+
});
366+

0 commit comments

Comments
 (0)