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
/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":"