Files
AgentHub/server/__tests__/reports.test.js
T
nntrivi2001 4aae61297f feat: analytics parity — tool cost, date ranges, budgets, query explorer, replay, snapshots (v2.4.0)
Analytics parity phases 2–7 (docs/plans/analytics-parity/), built on
138e706 and ported onto v2.3.1:
- Cost by AI tool (?tool= on cost/analytics/stats/workflows, by_tool,
  Codex fast/priority and flex tiers, agenthub_cost_usd_total{tool,model})
- Date ranges (from/to) on cost/analytics/workflows; Analytics range select
  with Today / This week (Monday) / This month (1st) / 7d / 30d / 90d / All /
  Custom; workflow filters carried into workflow-stats.js (read worker)
- Budgets and cost alerts as alert rule types (alert_rules rebuilt without
  its rule_type CHECK), GET /api/budgets/status, rule preview, spend pace,
  status-bar budget chip; rules stay editable in place
- Query Explorer (/query, allowlisted sort, CSV/JSON export) and scheduled
  reports (report_definitions/report_runs, Settings → Reports)
- Session Replay tab and immutable snapshots (session_snapshots, /snapshot/:token)
- Remote Codex sync, push ingest with a hashed token, privacy redaction and
  ignored folders (Settings → Data), Cursor sessions (no cost)
- Read-only MCP server, statusline, Grafana/Prometheus files

Also: resizable sidebar, pet run/wave animations, the workspace home keeps a
half-typed prompt, and the pre-commit hook runs only the related tests for a
small commit.
2026-10-06 17:12:28 +07:00

216 lines
8.0 KiB
JavaScript

/**
* @file Scheduled reports: definition validation and CRUD behind the
* same-origin guard, `lastSlot`/`reportWindow` in local time, a run writing
* escaped HTML and JSON under <data dir>/reports/<id>/, the download route
* reading only what a run recorded, and the scheduler claiming each slot once.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
"use strict";
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const http = require("node:http");
const ROOT = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "reports-")));
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "t.db");
process.env.CLAUDE_HOME = path.join(ROOT, "claude");
process.env.DASHBOARD_DATA_DIR = path.join(ROOT, "data");
process.env.DASHBOARD_REMOTE_SYNC_MS = "0";
process.env.DASHBOARD_LIVENESS_PROBE = "0";
const { createApp } = require("../index");
const { db, stmts } = require("../db");
const reports = require("../lib/reports");
let server;
let base;
async function call(method, url, body, headers = {}) {
const res = await fetch(`${base}${url}`, {
method,
headers: { "content-type": "application/json", ...headers },
body: body ? JSON.stringify(body) : undefined,
});
const text = await res.text();
let parsed = text;
try {
parsed = JSON.parse(text);
} catch {
/* a file */
}
return { status: res.status, headers: res.headers, body: parsed };
}
const DEF = {
name: "Weekly <b>&</b>",
window_days: 7,
frequency: "weekly",
hour: 8,
weekday: 1,
tz_offset: -420,
formats: ["html", "json"],
};
before(async () => {
server = http.createServer(createApp());
await new Promise((r) => server.listen(0, "127.0.0.1", r));
base = `http://127.0.0.1:${server.address().port}`;
db.prepare(
"INSERT OR REPLACE INTO model_pricing (model_pattern, display_name, input_per_mtok, output_per_mtok, cache_read_per_mtok, cache_write_per_mtok) VALUES ('rep-model%', 'R', 1, 0, 0, 0)"
).run();
const day = new Date(Date.now() - 2 * 86_400_000).toISOString();
db.prepare(
"INSERT INTO sessions (id, name, status, started_at, updated_at, tool) VALUES ('rs', 'rs', 'completed', ?, ?, 'claude')"
).run(day, day);
stmts.setTokenUsage.run(
"rs",
"rep-model<x>",
"standard",
"global",
"standard",
2e6,
0,
0,
0,
0,
0,
0,
0
);
db.prepare(
"INSERT INTO events (session_id, event_type, tool_name, created_at) VALUES ('rs', 'PreToolUse', '<script>', ?)"
).run(day);
});
after(() => {
server.close();
fs.rmSync(ROOT, { recursive: true, force: true });
});
describe("schedule math", () => {
const at = (iso) => Date.parse(iso);
it("finds the latest daily and weekly slot in local time", () => {
const daily = { frequency: "daily", hour: 8, weekday: 1, tz_offset: -420 };
// 2026-10-05 is a Monday. 08:00 at UTC+7 = 01:00Z.
assert.equal(
new Date(reports.lastSlot(daily, at("2026-10-05T02:00:00Z"))).toISOString(),
"2026-10-05T01:00:00.000Z"
);
assert.equal(
new Date(reports.lastSlot(daily, at("2026-10-05T00:30:00Z"))).toISOString(),
"2026-10-04T01:00:00.000Z"
);
const weekly = { ...daily, frequency: "weekly", weekday: 5 }; // Friday
assert.equal(
new Date(reports.lastSlot(weekly, at("2026-10-05T02:00:00Z"))).toISOString(),
"2026-10-02T01:00:00.000Z"
);
});
it("covers the window_days full local days before the run day", () => {
const w = reports.reportWindow({ window_days: 7, tz_offset: -420 }, at("2026-10-04T18:00:00Z"));
assert.deepEqual(w, { from: "2026-09-28", to: "2026-10-04" }); // 01:00 on the 5th locally
});
});
describe("/api/reports", () => {
let id;
it("validates and creates a definition", async () => {
for (const bad of [
{ ...DEF, name: "" },
{ ...DEF, window_days: 0 },
{ ...DEF, frequency: "hourly" },
{ ...DEF, hour: 24 },
{ ...DEF, formats: ["pdf"] },
{ ...DEF, formats: [] },
{ ...DEF, tz_offset: 9999 },
]) {
assert.equal((await call("POST", "/api/reports", bad)).status, 400, JSON.stringify(bad));
}
const r = await call("POST", "/api/reports", DEF);
assert.equal(r.status, 201);
id = r.body.report.id;
assert.deepEqual(r.body.report.formats, ["html", "json"]);
assert.equal(r.body.report.enabled, true);
assert.ok(r.body.report.last_slot, "the slot already past is claimed");
});
it("refuses a cross-origin write", async () => {
const r = await call("POST", "/api/reports", DEF, { origin: "https://evil.example" });
assert.equal(r.status, 403);
});
it("updates part of a definition", async () => {
const r = await call("PUT", `/api/reports/${id}`, { enabled: false });
assert.equal(r.status, 200);
assert.equal(r.body.report.enabled, false);
assert.equal(r.body.report.name, DEF.name);
assert.equal((await call("PUT", `/api/reports/${id}`, { hour: -1 })).status, 400);
assert.equal((await call("PUT", "/api/reports/999", { enabled: true })).status, 404);
});
it("runs now, writing escaped HTML and JSON, and serves them by id", async () => {
const r = await call("POST", `/api/reports/${id}/run`);
assert.equal(r.status, 200);
const run = r.body.run;
assert.equal(run.status, "ok", run.error);
assert.deepEqual(run.files, ["html", "json"]);
assert.equal(run.trigger, "manual");
const json = await call("GET", `/api/reports/${id}/runs/${run.id}/json`);
assert.equal(json.status, 200);
assert.equal(json.body.sessions.total, 1);
assert.equal(json.body.cost.total, 2);
assert.deepEqual(json.body.cost.by_model, [{ model: "rep-model<x>", cost: 2 }]);
assert.deepEqual(json.body.top_tools, [{ tool_name: "<script>", count: 1 }]);
const html = await call("GET", `/api/reports/${id}/runs/${run.id}/html`);
assert.equal(html.status, 200);
assert.match(html.headers.get("content-disposition"), /attachment/);
assert.ok(!html.body.includes("<script>"));
assert.ok(html.body.includes("&lt;script&gt;"));
assert.ok(html.body.includes("Weekly &lt;b&gt;&amp;&lt;/b&gt;"));
assert.ok(fs.existsSync(path.join(ROOT, "data", "reports", String(id))));
assert.equal((await call("GET", `/api/reports/${id}/runs/${run.id}/pdf`)).status, 404);
assert.equal((await call("GET", `/api/reports/${id}/runs/${run.id + 9}/json`)).status, 404);
assert.equal((await call("GET", `/api/reports/${id}/runs/..%2F..%2Fx/json`)).status, 404);
const list = await call("GET", "/api/reports");
assert.equal(list.body.reports[0].last_run.id, run.id);
const runs = await call("GET", `/api/reports/${id}/runs`);
assert.equal(runs.body.runs.length, 1);
});
it("deletes a definition with its runs and files", async () => {
const r = await call("DELETE", `/api/reports/${id}`);
assert.equal(r.status, 200);
assert.equal(db.prepare("SELECT COUNT(*) AS n FROM report_runs").get().n, 0);
assert.ok(!fs.existsSync(path.join(ROOT, "data", "reports", String(id))));
assert.equal((await call("DELETE", `/api/reports/${id}`)).status, 404);
});
});
describe("scheduler", () => {
it("runs a due report once per slot", async () => {
const r = await call("POST", "/api/reports", { ...DEF, frequency: "daily", formats: ["json"] });
const id = r.body.report.id;
const now = Date.now();
assert.equal(reports.tick(now), 0, "the slot claimed at creation is not run");
const tomorrow = now + 86_400_000;
assert.equal(reports.tick(tomorrow), 1);
assert.equal(reports.tick(tomorrow), 0);
const runs = db.prepare("SELECT * FROM report_runs WHERE report_id = ?").all(id);
assert.equal(runs.length, 1);
assert.equal(runs[0].trigger, "schedule");
});
it("survives Clear Data", async () => {
await call("POST", "/api/settings/clear-data");
assert.equal(db.prepare("SELECT COUNT(*) AS n FROM report_definitions").get().n, 1);
assert.equal(db.prepare("SELECT COUNT(*) AS n FROM report_runs").get().n, 1);
});
});