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>
581 lines
25 KiB
JavaScript
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");
|
|
});
|
|
});
|