vps-health-api/__tests__/claude-usage-history.test.js
le king fu 9084d90609 feat(server): archive closed Claude usage days by month, serve them on /claude-usage/history
POST /claude-usage now merges every day of the sanitized snapshot except
the last one (today, still open) into <dir of CLAUDE_USAGE_PATH>/history/
YYYY-MM.json, after the snapshot itself is written. The latest snapshot
wins, so a day leaving the 30-day window keeps the value the agent froze
at D+2. Each month file is written atomically (same-dir temp, mode 600,
rename). A month file that cannot be read back is never overwritten, and
an archive failure is logged (months and error codes only) without
changing the 204.

GET /claude-usage/history?from=YYYY-MM&to=YYYY-MM (read token) serves the
archived days, defaulting to the 12 months up to the last archived month,
400 on a bad format, from > to or a span over 36 months. Month files are
re-read through the snapshot's per-day whitelist, now exported from
claude-usage.js as sanitizeDay().

The new module also carries the one-shot import command used by the
backfill (`node claude-usage-history.js --import <file|->`), where an
already archived date always wins.

Refs #55

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 20:41:30 -04:00

581 lines
25 KiB
JavaScript

const http = require("node:http");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { spawnSync } = require("node:child_process");
// Claude usage history (issue #55): the closed days of the snapshot, kept past
// its 30-day window in one file per month under <dir of CLAUDE_USAGE_PATH>/history/.
//
// Pinned here:
// * the pure helpers of claude-usage-history.js: the two merge rules, the
// grouping by the month of the date string, the whitelist re-read of a
// month file, and the range rules of the read route;
// * the archive written by POST /claude-usage: every day but the last, the
// latest snapshot winning, an unreadable month never overwritten, and an
// archive failure that never changes the 204;
// * GET /claude-usage/history: defaults, bounds, degraded months;
// * the one-shot import command, run as a real process: the archived date
// wins, and an invalid import file writes nothing.
const TOKEN = "test-token";
const CLAUDE_TOKEN = "test-claude-token";
const MODULE = path.join(__dirname, "..", "claude-usage-history.js");
const FROZEN_NOW = Date.UTC(2026, 9, 8, 19, 0, 0); // 2026-10-08
let tmpDir;
let usagePath;
let historyPath;
let server;
let baseUrl;
function day(date, output = 1) {
return {
date,
models: { "claude-opus-5-5": { input: 1, output, cacheCreation: 0, cacheRead: 0, requests: 1 } },
};
}
function snapshotWith(days) {
return {
schemaVersion: 1,
generatedAt: "2026-10-02T19:00:00.000Z",
timeZone: "America/Toronto",
limits: { status: "ok", fetchedAt: "2026-10-02T19:00:00.000Z", items: [] },
days,
projects: [{ name: "proj", work: 3, cacheRead: 4, requests: 1 }],
};
}
function monthFile(month) {
return path.join(historyPath, `${month}.json`);
}
function readMonthFile(month) {
return JSON.parse(fs.readFileSync(monthFile(month), "utf-8"));
}
function writeMonthFile(month, content) {
fs.mkdirSync(historyPath, { recursive: true });
fs.writeFileSync(monthFile(month), typeof content === "string" ? content : JSON.stringify(content));
}
function freshHistory() {
delete require.cache[require.resolve("../claude-usage-history.js")];
delete require.cache[require.resolve("../claude-usage.js")];
return require("../claude-usage-history.js");
}
function startServer() {
for (const mod of ["../index.js", "../claude-usage.js", "../claude-usage-history.js"]) {
delete require.cache[require.resolve(mod)];
}
const { handler } = require("../index.js");
server = http.createServer(handler);
return new Promise((resolve) => {
server.listen(0, () => {
baseUrl = `http://127.0.0.1:${server.address().port}`;
resolve();
});
});
}
function stopServer() {
return new Promise((resolve) => {
if (!server) return resolve();
server.close(() => resolve());
});
}
async function request(route, { method = "GET", auth = `Bearer ${TOKEN}`, body } = {}) {
const headers = {};
if (auth) headers.Authorization = auth;
if (body !== undefined) headers["Content-Type"] = "application/json";
const res = await fetch(`${baseUrl}${route}`, { method, headers, body });
const parsed = await res.json().catch(() => ({}));
return { status: res.status, body: parsed };
}
function push(days) {
return request("/claude-usage", {
method: "POST",
auth: `Bearer ${CLAUDE_TOKEN}`,
body: JSON.stringify(snapshotWith(days)),
});
}
async function history(query = "") {
const realNow = Date.now;
Date.now = () => FROZEN_NOW;
try {
return await request(`/claude-usage/history${query}`);
} finally {
Date.now = realNow;
}
}
// Every file under dir, relative, sorted: what a case may and may not touch.
function tree(dir) {
if (!fs.existsSync(dir)) return [];
return fs
.readdirSync(dir, { recursive: true })
.map(String)
.sort();
}
beforeEach(async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vps-health-history-test-"));
usagePath = path.join(tmpDir, "claude-usage", "claude-usage.json");
historyPath = path.join(tmpDir, "claude-usage", "history");
process.env.HEALTH_TOKEN = TOKEN;
process.env.HOSTS_INGEST_TOKEN = "test-ingest-token";
process.env.CLAUDE_USAGE_INGEST_TOKEN = CLAUDE_TOKEN;
process.env.CLAUDE_USAGE_PATH = usagePath;
process.env.HOSTS_DIR = path.join(tmpDir, "hosts");
process.env.HOSTS_ALLOWED_IDS = "thinkpad";
process.env.REPORTS_DIR = path.join(tmpDir, "reports");
process.env.DEFENSEURS_AGENTS_MAP_PATH = path.join(tmpDir, "agents-map.json");
process.env.DEFENSEURS_STATUS_PATH = path.join(tmpDir, "status.json");
process.env.DEFENSEURS_FINDINGS_STATE_PATH = path.join(tmpDir, "findings-state.json");
// Closed port: no request may reach the real IdP.
process.env.LOGTO_HEALTH_URL = "http://127.0.0.1:1/oidc/.well-known/openid-configuration";
await startServer();
});
afterEach(async () => {
vi.restoreAllMocks();
await stopServer();
fs.rmSync(tmpDir, { recursive: true, force: true });
});
afterAll(() => {
delete process.env.CLAUDE_USAGE_INGEST_TOKEN;
delete process.env.CLAUDE_USAGE_PATH;
});
// --- Pure helpers -----------------------------------------------------------
describe("month arithmetic", () => {
test("addMonths crosses years both ways", () => {
const { addMonths } = freshHistory();
expect(addMonths("2026-10", -11)).toBe("2025-11");
expect(addMonths("2026-12", 1)).toBe("2027-01");
expect(addMonths("2026-01", -1)).toBe("2025-12");
expect(addMonths("2026-01", -36)).toBe("2023-01");
});
test("monthSpan counts both ends", () => {
const { monthSpan } = freshHistory();
expect(monthSpan("2026-01", "2026-01")).toBe(1);
expect(monthSpan("2026-01", "2026-12")).toBe(12);
expect(monthSpan("2024-01", "2026-12")).toBe(36);
});
});
describe("mergeDays", () => {
test("the latest value wins by default, result sorted, inputs untouched", () => {
const { mergeDays } = freshHistory();
const existing = [day("2026-09-02", 1), day("2026-09-01", 1)];
const incoming = [day("2026-09-03", 3), day("2026-09-02", 2)];
const before = JSON.stringify([existing, incoming]);
const merged = mergeDays(existing, incoming);
expect(merged.map((d) => [d.date, d.models["claude-opus-5-5"].output])).toEqual([
["2026-09-01", 1],
["2026-09-02", 2],
["2026-09-03", 3],
]);
expect(JSON.stringify([existing, incoming])).toBe(before);
});
test("preferExisting keeps an archived date and adds only the missing ones", () => {
const { mergeDays } = freshHistory();
const merged = mergeDays([day("2026-09-02", 1)], [day("2026-09-02", 9), day("2026-09-01", 9)], {
preferExisting: true,
});
expect(merged.map((d) => [d.date, d.models["claude-opus-5-5"].output])).toEqual([
["2026-09-01", 9],
["2026-09-02", 1],
]);
});
test("a date repeated in the incoming list: the later entry wins", () => {
const { mergeDays } = freshHistory();
const merged = mergeDays([], [day("2026-09-01", 1), day("2026-09-01", 2)]);
expect(merged).toEqual([day("2026-09-01", 2)]);
});
});
describe("groupByMonth", () => {
test("groups by the YYYY-MM prefix of the date string", () => {
const { groupByMonth } = freshHistory();
const months = groupByMonth([day("2026-10-31"), day("2026-11-01"), day("2026-10-01")]);
expect([...months.keys()]).toEqual(["2026-10", "2026-11"]);
expect(months.get("2026-10").map((d) => d.date)).toEqual(["2026-10-31", "2026-10-01"]);
});
});
describe("sanitizeMonthFile — the snapshot's whitelist on the way back", () => {
test("a clean file comes back identical", () => {
const { sanitizeMonthFile } = freshHistory();
const file = { schemaVersion: 1, month: "2026-09", days: [day("2026-09-01"), day("2026-09-02")] };
expect(sanitizeMonthFile(file, "2026-09")).toEqual(file.days);
});
test("unknown fields, __proto__, invalid values and foreign dates are dropped", () => {
const { sanitizeMonthFile } = freshHistory();
const raw =
'{"schemaVersion":1,"month":"2026-09","extra":"x","days":[' +
'{"date":"2026-09-01","extra":"<script>","models":{"__proto__":{"input":1},"claude-opus-5-5":{"input":-1,"output":2,"polluted":true}}},' +
'{"date":"2026-10-01","models":{}},' +
'{"date":"2026-09-31","models":{}},' +
'"not a day",' +
'{"__proto__":{"polluted":true},"date":"2026-09-02","models":{}}' +
"]}";
const days = sanitizeMonthFile(JSON.parse(raw), "2026-09");
expect(days).toEqual([
{ date: "2026-09-01", models: { "claude-opus-5-5": { output: 2 } } },
{ date: "2026-09-02", models: {} },
]);
expect({}.polluted).toBeUndefined();
expect(Object.keys(days[0].models)).toEqual(["claude-opus-5-5"]);
});
test.each([
["an array", []],
["null", null],
["another schemaVersion", { schemaVersion: 2, month: "2026-09", days: [] }],
["no schemaVersion", { month: "2026-09", days: [] }],
["another month", { schemaVersion: 1, month: "2026-08", days: [] }],
["days not an array", { schemaVersion: 1, month: "2026-09", days: {} }],
])("off-format as a whole (%s) -> null", (_label, parsed) => {
const { sanitizeMonthFile } = freshHistory();
expect(sanitizeMonthFile(parsed, "2026-09")).toBeNull();
});
});
describe("parseRange", () => {
const ctx = { lastArchived: "2026-09", currentMonth: "2026-10" };
test.each([
["nothing archived: 12 months up to the current one", null, null, { lastArchived: null, currentMonth: "2026-10" }, { from: "2025-11", to: "2026-10" }],
["12 months up to the last archived month", null, null, ctx, { from: "2025-10", to: "2026-09" }],
["to alone: 12 months up to it", null, "2026-03", ctx, { from: "2025-04", to: "2026-03" }],
["from alone: up to the last archived month", "2026-01", null, ctx, { from: "2026-01", to: "2026-09" }],
["from alone, past the last archived month: to = from", "2026-12", null, ctx, { from: "2026-12", to: "2026-12" }],
["exactly 36 months", "2024-01", "2026-12", ctx, { from: "2024-01", to: "2026-12" }],
])("%s", (_label, from, to, context, expected) => {
const { parseRange } = freshHistory();
expect(parseRange(from, to, context)).toEqual(expected);
});
test.each([
["37 months", "2023-12", "2026-12"],
["from alone, 37 months before the last archived month", "2023-09", null],
["from after to", "2026-09", "2026-08"],
["month 13", "2026-13", null],
["month 00", "2026-00", null],
["a one-digit month", "2026-1", null],
["a full date", "2026-09-01", null],
["an empty value", "", null],
["a traversal", "../etc", null],
["a bad to", null, "2026-9"],
])("error on %s", (_label, from, to) => {
const { parseRange } = freshHistory();
expect(parseRange(from, to, ctx).error).toEqual(expect.any(String));
});
});
// --- POST /claude-usage writes the archive ----------------------------------
describe("POST /claude-usage — the archive", () => {
test("every day but the last is archived, mode 600, no temp file", async () => {
expect((await push([day("2026-09-29"), day("2026-09-30"), day("2026-10-01")])).status).toBe(204);
expect(fs.readdirSync(historyPath)).toEqual(["2026-09.json"]);
expect(readMonthFile("2026-09")).toEqual({
schemaVersion: 1,
month: "2026-09",
days: [day("2026-09-29"), day("2026-09-30")],
});
expect(fs.statSync(monthFile("2026-09")).mode & 0o777).toBe(0o600);
});
test("a one-day snapshot archives nothing and creates no directory", async () => {
expect((await push([day("2026-10-01")])).status).toBe(204);
expect(fs.existsSync(historyPath)).toBe(false);
});
test("a second identical POST leaves the archive byte for byte identical", async () => {
const days = [day("2026-09-29"), day("2026-09-30"), day("2026-10-01"), day("2026-10-02")];
await push(days);
const before = [fs.readFileSync(monthFile("2026-09")), fs.readFileSync(monthFile("2026-10"))];
await push(days);
expect([fs.readFileSync(monthFile("2026-09")), fs.readFileSync(monthFile("2026-10"))]).toEqual(before);
expect(fs.readdirSync(historyPath).sort()).toEqual(["2026-09.json", "2026-10.json"]);
});
test("a later snapshot carrying a day with other values replaces it", async () => {
await push([day("2026-09-29", 1), day("2026-09-30", 1)]);
await push([day("2026-09-29", 7), day("2026-09-30", 1)]);
expect(readMonthFile("2026-09").days).toEqual([day("2026-09-29", 7)]);
});
test("a day that left the window keeps its archived value", async () => {
await push([day("2026-09-28", 5), day("2026-09-29", 5), day("2026-09-30", 5)]);
await push([day("2026-09-29", 6), day("2026-09-30", 6), day("2026-10-01", 6)]);
expect(readMonthFile("2026-09").days).toEqual([day("2026-09-28", 5), day("2026-09-29", 6), day("2026-09-30", 6)]);
});
test("a snapshot straddling two months writes two files, each day in its own", async () => {
await push([day("2026-09-29"), day("2026-09-30"), day("2026-10-01"), day("2026-10-02"), day("2026-10-03")]);
expect(readMonthFile("2026-09").days.map((d) => d.date)).toEqual(["2026-09-29", "2026-09-30"]);
expect(readMonthFile("2026-10").days.map((d) => d.date)).toEqual(["2026-10-01", "2026-10-02"]);
});
test("the excluded day is the last element sent, and a repeated date resolves to its later entry", async () => {
// Out of order and with a repeat: the agent never sends this, the rule
// must still be the one written down.
await push([day("2026-09-30", 1), day("2026-09-29", 1), day("2026-09-30", 2), day("2026-09-28", 1)]);
expect(readMonthFile("2026-09").days).toEqual([day("2026-09-29", 1), day("2026-09-30", 2)]);
});
test("an unreadable month: 204, snapshot written, that month untouched, the other written, logged without content", async () => {
writeMonthFile("2026-09", "{corrupt");
const error = vi.spyOn(console, "error").mockImplementation(() => {});
const res = await push([day("2026-09-29"), day("2026-09-30"), day("2026-10-01"), day("2026-10-02")]);
expect(res.status).toBe(204);
expect(JSON.parse(fs.readFileSync(usagePath, "utf-8")).days).toHaveLength(4);
expect(fs.readFileSync(monthFile("2026-09"), "utf-8")).toBe("{corrupt");
expect(readMonthFile("2026-10").days.map((d) => d.date)).toEqual(["2026-10-01"]);
const lines = error.mock.calls.map((args) => args.join(" "));
expect(lines).toEqual(["[claude-usage] history 2026-09 skipped: unreadable file left untouched (parse_error)"]);
});
test("an off-format month (another schemaVersion) is not overwritten either", async () => {
const foreign = JSON.stringify({ schemaVersion: 2, month: "2026-09", days: [day("2026-09-01")] });
writeMonthFile("2026-09", foreign);
vi.spyOn(console, "error").mockImplementation(() => {});
expect((await push([day("2026-09-29"), day("2026-09-30")])).status).toBe(204);
expect(fs.readFileSync(monthFile("2026-09"), "utf-8")).toBe(foreign);
});
test("history/ cannot be created: 204, snapshot written, logged", async () => {
fs.mkdirSync(path.dirname(usagePath), { recursive: true });
fs.writeFileSync(historyPath, "a file where the directory should be");
const error = vi.spyOn(console, "error").mockImplementation(() => {});
const res = await push([day("2026-09-29"), day("2026-09-30")]);
expect(res.status).toBe(204);
expect(JSON.parse(fs.readFileSync(usagePath, "utf-8")).days).toHaveLength(2);
const lines = error.mock.calls.map((args) => args.join(" "));
expect(lines).toHaveLength(1);
expect(lines[0]).toMatch(/^\[claude-usage\] history not written \(E[A-Z]+\)$/);
});
test("a month whose rename fails: 204, its temp file removed, the other month written, logged", async () => {
const realRename = fs.renameSync;
vi.spyOn(fs, "renameSync").mockImplementation((from, to) => {
if (String(to).endsWith("2026-09.json")) {
throw Object.assign(new Error("simulated"), { code: "EIO" });
}
return realRename(from, to);
});
const error = vi.spyOn(console, "error").mockImplementation(() => {});
const res = await push([day("2026-09-30"), day("2026-10-01"), day("2026-10-02")]);
expect(res.status).toBe(204);
expect(fs.readdirSync(historyPath)).toEqual(["2026-10.json"]);
expect(error.mock.calls.map((args) => args.join(" "))).toEqual(["[claude-usage] history 2026-09 not written (EIO)"]);
});
test("a rejected POST archives nothing", async () => {
const res = await request("/claude-usage", {
method: "POST",
auth: `Bearer ${CLAUDE_TOKEN}`,
body: JSON.stringify({ ...snapshotWith([day("2026-09-29"), day("2026-09-30")]), schemaVersion: 2 }),
});
expect(res.status).toBe(400);
expect(fs.existsSync(historyPath)).toBe(false);
});
test("a snapshot that cannot be written archives nothing", async () => {
fs.mkdirSync(path.join(usagePath, "occupied"), { recursive: true });
vi.spyOn(console, "error").mockImplementation(() => {});
expect((await push([day("2026-09-29"), day("2026-09-30")])).status).toBe(500);
expect(fs.existsSync(historyPath)).toBe(false);
});
});
// --- GET /claude-usage/history ----------------------------------------------
describe("GET /claude-usage/history", () => {
test("nothing archived: 200, 12 months up to the current one, no day", async () => {
const res = await history();
expect(res.status).toBe(200);
expect(res.body).toEqual({ from: "2025-11", to: "2026-10", days: [] });
});
test("defaults to 12 months up to the last archived month, days sorted across files", async () => {
writeMonthFile("2026-08", { schemaVersion: 1, month: "2026-08", days: [day("2026-08-31")] });
writeMonthFile("2026-09", { schemaVersion: 1, month: "2026-09", days: [day("2026-09-01"), day("2026-09-02")] });
writeMonthFile("2025-09", { schemaVersion: 1, month: "2025-09", days: [day("2025-09-30")] });
const res = await history();
expect(res.body).toEqual({
from: "2025-10",
to: "2026-09",
days: [day("2026-08-31"), day("2026-09-01"), day("2026-09-02")],
});
});
test("from and to select the months, an absent month is simply empty", async () => {
writeMonthFile("2026-06", { schemaVersion: 1, month: "2026-06", days: [day("2026-06-15")] });
writeMonthFile("2026-09", { schemaVersion: 1, month: "2026-09", days: [day("2026-09-01")] });
const res = await history("?from=2026-06&to=2026-08");
expect(res.body).toEqual({ from: "2026-06", to: "2026-08", days: [day("2026-06-15")] });
});
test("an unreadable month is left out and logged, the others are served", async () => {
writeMonthFile("2026-08", "{corrupt");
writeMonthFile("2026-09", { schemaVersion: 1, month: "2026-09", days: [day("2026-09-01")] });
const error = vi.spyOn(console, "error").mockImplementation(() => {});
const res = await history("?from=2026-08&to=2026-09");
expect(res.status).toBe(200);
expect(res.body.days).toEqual([day("2026-09-01")]);
expect(error.mock.calls.map((args) => args.join(" "))).toEqual([
"[claude-usage] history 2026-08 unreadable, left out (parse_error)",
]);
});
test("the files are re-sanitized on the way out", async () => {
writeMonthFile(
"2026-09",
'{"schemaVersion":1,"month":"2026-09","days":[{"date":"2026-09-01","extra":1,"models":{"__proto__":{"input":1},"claude-opus-5-5":{"output":-1,"input":3}}},{"date":"2026-08-31","models":{}}]}',
);
const res = await history("?from=2026-09&to=2026-09");
expect(res.body.days).toEqual([{ date: "2026-09-01", models: { "claude-opus-5-5": { input: 3 } } }]);
});
test("a stray file name that is not a month is not the default end", async () => {
writeMonthFile("2026-09", { schemaVersion: 1, month: "2026-09", days: [day("2026-09-01")] });
writeMonthFile("2026-13", "{}");
fs.writeFileSync(path.join(historyPath, ".2026-12.json.1.2.tmp"), "{}");
expect((await history()).body.to).toBe("2026-09");
});
test.each([
["?from=2026-13"],
["?to=2026-9"],
["?from=2026-09&to=2026-08"],
["?from=2023-12&to=2026-12"],
["?from=../../etc"],
["?from="],
])("400 on %s", async (query) => {
const res = await history(query);
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/^Bad request: /);
});
test("36 months exactly is served", async () => {
expect((await history("?from=2024-01&to=2026-12")).status).toBe(200);
});
test("the read token only: no token, the claude ingest token -> 401", async () => {
expect((await request("/claude-usage/history", { auth: null })).status).toBe(401);
expect((await request("/claude-usage/history", { auth: `Bearer ${CLAUDE_TOKEN}` })).status).toBe(401);
});
test("POST /claude-usage/history -> 404, before any auth", async () => {
expect((await request("/claude-usage/history", { method: "POST", auth: null })).status).toBe(404);
});
test("POST then GET: the closed days come back", async () => {
await push([day("2026-09-30"), day("2026-10-01"), day("2026-10-02")]);
const res = await history("?from=2026-09&to=2026-10");
expect(res.body.days).toEqual([day("2026-09-30"), day("2026-10-01")]);
});
});
// --- Import command ---------------------------------------------------------
describe("node claude-usage-history.js --import", () => {
function runImport(args, { input } = {}) {
return spawnSync(process.execPath, [MODULE, ...args], {
env: { PATH: process.env.PATH, CLAUDE_USAGE_PATH: usagePath },
input,
encoding: "utf-8",
});
}
function importFile(content) {
const file = path.join(tmpDir, "backfill.json");
fs.writeFileSync(file, typeof content === "string" ? content : JSON.stringify(content));
return file;
}
test("an archived date is never replaced, absent dates are added, only history/ changes", async () => {
writeMonthFile("2026-09", { schemaVersion: 1, month: "2026-09", days: [day("2026-09-02", 1)] });
const file = importFile({ days: [day("2026-08-31", 9), day("2026-09-01", 9), day("2026-09-02", 9)] });
const before = tree(tmpDir).filter((name) => !name.startsWith(`claude-usage${path.sep}history`));
const result = runImport(["--import", file]);
expect(result.stderr).toBe("");
expect(result.status).toBe(0);
expect(result.stdout).toBe("import: 3 day(s) read, 2 added, 1 already archived, 2 month(s) written\n");
expect(readMonthFile("2026-09").days).toEqual([day("2026-09-01", 9), day("2026-09-02", 1)]);
expect(readMonthFile("2026-08").days).toEqual([day("2026-08-31", 9)]);
expect(fs.statSync(monthFile("2026-08")).mode & 0o777).toBe(0o600);
expect(fs.readdirSync(historyPath).sort()).toEqual(["2026-08.json", "2026-09.json"]);
expect(tree(tmpDir).filter((name) => !name.startsWith(`claude-usage${path.sep}history`))).toEqual(before);
});
test("reads standard input on `-`", () => {
const result = runImport(["--import", "-"], { input: JSON.stringify({ days: [day("2026-07-01")] }) });
expect(result.status).toBe(0);
expect(readMonthFile("2026-07").days).toEqual([day("2026-07-01")]);
});
test.each([
["not JSON", "{nope"],
["no days", JSON.stringify({})],
["days not an array", JSON.stringify({ days: {} })],
["an invalid day", JSON.stringify({ days: [day("2026-07-01"), { date: "2026-02-31", models: {} }] })],
["a repeated date", JSON.stringify({ days: [day("2026-07-01"), day("2026-07-01", 2)] })],
])("an invalid import file (%s) writes nothing, exit 1", (_label, content) => {
const file = importFile(content);
const before = tree(tmpDir);
const result = runImport(["--import", file]);
expect(result.status).toBe(1);
expect(result.stderr).toBe("import: invalid import file, nothing written\n");
expect(tree(tmpDir)).toEqual(before);
});
test("an unreadable month is skipped and left as is, the others are written, exit 1", () => {
writeMonthFile("2026-09", "{corrupt");
const file = importFile({ days: [day("2026-08-31"), day("2026-09-01")] });
const result = runImport(["--import", file]);
expect(result.status).toBe(1);
expect(result.stderr).toBe("import: month 2026-09 skipped, unreadable (parse_error)\n");
expect(fs.readFileSync(monthFile("2026-09"), "utf-8")).toBe("{corrupt");
expect(readMonthFile("2026-08").days).toEqual([day("2026-08-31")]);
});
test("a missing import file: exit 1, nothing written", () => {
const result = runImport(["--import", path.join(tmpDir, "absent.json")]);
expect(result.status).toBe(1);
expect(result.stderr).toBe("import: cannot read the import file (ENOENT)\n");
expect(fs.existsSync(historyPath)).toBe(false);
});
test.each([[[]], [["--import"]], [["--other", "x"]]])("usage error on %j: exit 2", (args) => {
const result = runImport(args);
expect(result.status).toBe(2);
expect(fs.existsSync(historyPath)).toBe(false);
});
});
describe("the server image", () => {
test("the Dockerfile COPY line ships claude-usage-history.js", () => {
const dockerfile = fs.readFileSync(path.join(__dirname, "..", "Dockerfile"), "utf-8");
const copy = dockerfile.split("\n").find((line) => /^\s*COPY\s/.test(line));
expect(copy.trim().split(/\s+/)).toContain("claude-usage-history.js");
});
});