Files
Claude-Code-Monitor/server/__tests__/pricing-intro-edit.test.js
T
nntrivi2001 57dc91585d feat: Claude Code Monitor — lanes, pipelines and a merged workspace
Internal SmartGift build of a Claude Code monitoring dashboard.

Lanes: a durable unit of parallel agent work, one per working directory,
tracked across session restarts. Managed lanes are git worktrees the
dashboard provisions and can reset or remove behind a three-check destroy
guard and a counted preflight; adopted lanes are directories you already
own and are never destroyable.

Pipelines: a lane moves through pipeline stages. A stage the agent declares
with evidence renders green; a stage inferred from the tool-event stream
renders dashed amber and never counts as done. Detection is forward-only
within a 30-minute window, and never writes the declared stage.

Workspace: one page at /run with a lane grid, the selected lane's pipeline,
and a full Claude console behind a disclosure.
2026-07-30 14:39:03 +07:00

248 lines
8.1 KiB
JavaScript

/**
* @file Tests for editing time-limited introductory pricing via PUT /api/pricing.
*
* The Settings page lets users edit a model's introductory (promo) rates, not
* just its standard rates. This suite verifies the route contract that backs
* that UI, and that the mechanism is generic (works for any model pattern, not
* just the seeded Sonnet 5 promo):
*
* 1. PUT with intro fields + a valid intro_until persists the intro rates.
* 2. PUT that omits every intro field preserves an existing promo (backward
* compatible with older clients that only send standard rates).
* 3. PUT with an empty intro_until clears the promo AND zeroes the intro
* rates so a stale value can't resurface later.
* 4. A malformed intro_until is rejected with 400 and writes nothing.
* 5. Editing standard rates never disturbs the intro block.
*
* It also covers the route's numeric-rate validation: any present rate field
* must be a non-negative finite number, else the PUT is rejected with 400 and
* nothing is written (NaN/negative rates would corrupt all cost math).
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const path = require("path");
const fs = require("fs");
const os = require("os");
const http = require("http");
const TEST_DB = path.join(os.tmpdir(), `dashboard-intro-edit-${Date.now()}-${process.pid}.db`);
process.env.DASHBOARD_DB_PATH = TEST_DB;
const { createApp, startServer } = require("../index");
const { db, stmts } = require("../db");
let server;
let BASE;
function fetch(urlPath, options = {}) {
return new Promise((resolve, reject) => {
const url = new URL(urlPath, BASE);
const opts = {
hostname: url.hostname,
port: url.port,
path: url.pathname + url.search,
method: options.method || "GET",
headers: { "Content-Type": "application/json", ...options.headers },
};
const req = http.request(opts, (res) => {
let body = "";
res.on("data", (c) => (body += c));
res.on("end", () => {
let parsed;
try {
parsed = JSON.parse(body);
} catch {
parsed = body;
}
resolve({ status: res.statusCode, body: parsed });
});
});
req.on("error", reject);
if (options.body) req.write(JSON.stringify(options.body));
req.end();
});
}
const PATTERN = "test-promo-model%";
before(async () => {
const app = createApp();
server = await startServer(app, 0);
BASE = `http://127.0.0.1:${server.address().port}`;
});
after(() => {
if (server) server.close();
if (db) db.close();
for (const suffix of ["", "-wal", "-shm"]) {
try {
fs.unlinkSync(TEST_DB + suffix);
} catch {
/* ignore */
}
}
});
describe("PUT /api/pricing — introductory rate editing", () => {
it("persists intro rates when a valid intro_until is supplied", async () => {
const res = await fetch("/api/pricing", {
method: "PUT",
body: {
model_pattern: PATTERN,
display_name: "Test Promo Model",
input_per_mtok: 3,
output_per_mtok: 15,
intro_until: "2026-08-31",
intro_input_per_mtok: 2,
intro_output_per_mtok: 10,
intro_cache_read_per_mtok: 0.2,
intro_cache_write_per_mtok: 2.5,
intro_cache_write_1h_per_mtok: 4,
},
});
assert.equal(res.status, 200);
const row = stmts.getPricing.get(PATTERN);
assert.equal(row.intro_until, "2026-08-31");
assert.equal(row.intro_input_per_mtok, 2);
assert.equal(row.intro_output_per_mtok, 10);
assert.equal(row.intro_cache_read_per_mtok, 0.2);
assert.equal(row.intro_cache_write_per_mtok, 2.5);
assert.equal(row.intro_cache_write_1h_per_mtok, 4);
// Standard rates persisted too.
assert.equal(row.input_per_mtok, 3);
assert.equal(row.output_per_mtok, 15);
});
it("preserves an existing promo when the PUT omits all intro fields", async () => {
// Older client shape: standard rates only, no intro_* keys at all.
const res = await fetch("/api/pricing", {
method: "PUT",
body: {
model_pattern: PATTERN,
display_name: "Test Promo Model (renamed)",
input_per_mtok: 4,
output_per_mtok: 16,
},
});
assert.equal(res.status, 200);
const row = stmts.getPricing.get(PATTERN);
// Standard rates updated…
assert.equal(row.input_per_mtok, 4);
assert.equal(row.display_name, "Test Promo Model (renamed)");
// …but the promo is untouched.
assert.equal(row.intro_until, "2026-08-31");
assert.equal(row.intro_input_per_mtok, 2);
assert.equal(row.intro_output_per_mtok, 10);
});
it("rejects a malformed intro_until without mutating the row", async () => {
const before = stmts.getPricing.get(PATTERN);
const res = await fetch("/api/pricing", {
method: "PUT",
body: {
model_pattern: PATTERN,
display_name: "Test Promo Model",
input_per_mtok: 4,
output_per_mtok: 16,
intro_until: "August 31",
intro_input_per_mtok: 1,
},
});
assert.equal(res.status, 400);
const after = stmts.getPricing.get(PATTERN);
assert.deepEqual(after, before);
});
it("clears the promo and zeroes intro rates when intro_until is emptied", async () => {
const res = await fetch("/api/pricing", {
method: "PUT",
body: {
model_pattern: PATTERN,
display_name: "Test Promo Model",
input_per_mtok: 4,
output_per_mtok: 16,
intro_until: "",
// Even though rates are still sent, an empty date clears them so a
// re-added date later can't silently resurrect stale values.
intro_input_per_mtok: 2,
intro_output_per_mtok: 10,
},
});
assert.equal(res.status, 200);
const row = stmts.getPricing.get(PATTERN);
assert.equal(row.intro_until, null);
assert.equal(row.intro_input_per_mtok, 0);
assert.equal(row.intro_output_per_mtok, 0);
});
});
describe("PUT /api/pricing — numeric rate validation", () => {
it("rejects a non-numeric rate without mutating the row", async () => {
const before = stmts.getPricing.get(PATTERN);
const res = await fetch("/api/pricing", {
method: "PUT",
body: {
model_pattern: PATTERN,
display_name: "Test Promo Model",
input_per_mtok: "abc",
output_per_mtok: 16,
},
});
assert.equal(res.status, 400);
assert.equal(res.body.error.code, "INVALID_INPUT");
assert.match(res.body.error.message, /input_per_mtok/);
assert.deepEqual(stmts.getPricing.get(PATTERN), before);
});
it("rejects a negative rate without mutating the row", async () => {
const before = stmts.getPricing.get(PATTERN);
const res = await fetch("/api/pricing", {
method: "PUT",
body: {
model_pattern: PATTERN,
display_name: "Test Promo Model",
input_per_mtok: 4,
output_per_mtok: -1,
},
});
assert.equal(res.status, 400);
assert.match(res.body.error.message, /output_per_mtok/);
assert.deepEqual(stmts.getPricing.get(PATTERN), before);
});
it("rejects NaN in intro and fast rate fields too", async () => {
for (const field of ["fast_input_per_mtok", "intro_output_per_mtok"]) {
const res = await fetch("/api/pricing", {
method: "PUT",
body: {
model_pattern: PATTERN,
display_name: "Test Promo Model",
intro_until: "2026-12-31",
[field]: "not-a-number",
},
});
assert.equal(res.status, 400, `${field} should be rejected`);
assert.match(res.body.error.message, new RegExp(field));
}
});
it("accepts numeric strings by coercing them to numbers", async () => {
const res = await fetch("/api/pricing", {
method: "PUT",
body: {
model_pattern: PATTERN,
display_name: "Test Promo Model",
input_per_mtok: "4.5",
output_per_mtok: "18",
},
});
assert.equal(res.status, 200);
const row = stmts.getPricing.get(PATTERN);
assert.equal(row.input_per_mtok, 4.5);
assert.equal(row.output_per_mtok, 18);
});
});