Review of PR #57, suggestions 1 and 2: - the excluded open day is now the most recent date of the snapshot, not its last element, so the rule no longer depends on the order the days were sent in (the sanitizer does not sort them); - a day dated past the server's UTC reception day is never archived, and GET /claude-usage/history no longer takes a month file dated past the current month as its default end. A skewed workstation clock could otherwise open history/2099-12.json and leave the default range empty. Refs #55 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
611 lines
27 KiB
JavaScript
611 lines
27 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("closedDays", () => {
|
|
test("drops the most recent date and any date past the reception day", () => {
|
|
const { closedDays } = freshHistory();
|
|
expect(closedDays([day("2026-10-02"), day("2026-10-01"), day("2026-10-09")], "2026-10-08")).toEqual([day("2026-10-01")]);
|
|
expect(closedDays([day("2026-10-08"), day("2026-10-08", 2), day("2026-10-07")], "2026-10-08")).toEqual([day("2026-10-07")]);
|
|
expect(closedDays([day("2099-12-31")], "2026-10-08")).toEqual([]);
|
|
expect(closedDays([], "2026-10-08")).toEqual([]);
|
|
});
|
|
});
|
|
|
|
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" }],
|
|
["a last archived month past the current one: the current month", null, null, { lastArchived: "2099-12", currentMonth: "2026-10" }, { from: "2025-11", to: "2026-10" }],
|
|
])("%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 most recent date, whatever the order 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-28", 1), day("2026-09-28", 2), day("2026-10-01", 1), day("2026-09-29", 1)]);
|
|
expect(readMonthFile("2026-09").days).toEqual([day("2026-09-28", 2), day("2026-09-29", 1), day("2026-09-30", 1)]);
|
|
expect(fs.existsSync(monthFile("2026-10"))).toBe(false);
|
|
});
|
|
|
|
test("a day dated past the reception day is never archived, and opens no future month", async () => {
|
|
// Today, as the server sees it: the real clock, since receivedAt is stamped on arrival.
|
|
const today = new Date().toISOString().slice(0, 10);
|
|
const yesterday = new Date(Date.now() - 86400000).toISOString().slice(0, 10);
|
|
await push([day(yesterday), day(today), day("2099-12-31")]);
|
|
expect(fs.existsSync(monthFile("2099-12"))).toBe(false);
|
|
// The future day is dropped first: yesterday is still archived, today is not.
|
|
const archived = fs.readdirSync(historyPath).flatMap((name) => readMonthFile(name.slice(0, 7)).days.map((d) => d.date));
|
|
expect(archived).toEqual([yesterday]);
|
|
});
|
|
|
|
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 month file dated past the current month is not the default end", async () => {
|
|
writeMonthFile("2026-09", { schemaVersion: 1, month: "2026-09", days: [day("2026-09-01")] });
|
|
writeMonthFile("2099-12", { schemaVersion: 1, month: "2099-12", days: [day("2099-12-31")] });
|
|
const res = await history();
|
|
expect(res.body).toEqual({ from: "2025-11", to: "2026-10", days: [day("2026-09-01")] });
|
|
});
|
|
|
|
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");
|
|
});
|
|
});
|