/** * @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ĩ */ 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); }); });