57dc91585d
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.
248 lines
8.1 KiB
JavaScript
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);
|
|
});
|
|
});
|