import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { describe, it, expect, afterEach, vi } from "vitest"; // The dashboard's Add/Edit Model modal stores only the toggles the user touched. // That is only safe if the storage layer merges per key: a second POST carrying // { vision:false } must not erase an earlier { reasoning:true } assertion. const originalDataDir = process.env.DATA_DIR; let tempDir = null; function setup() { tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "9router-caps-roundtrip-")); process.env.DATA_DIR = tempDir; global._dbAdapter = { instance: null, initPromise: null, logged: false }; vi.resetModules(); vi.doMock("next/server", () => ({ NextResponse: { json(body, init = {}) { return new Response(JSON.stringify(body), { status: init.status || 200, headers: { "Content-Type": "application/json" }, }); }, }, })); } afterEach(() => { // driver.js caches the open handle on `global`; release it before unlinking. global._dbAdapter = { instance: null, initPromise: null, logged: false }; if (tempDir) fs.rmSync(tempDir, { recursive: true, force: true }); tempDir = null; if (originalDataDir === undefined) delete process.env.DATA_DIR; else process.env.DATA_DIR = originalDataDir; }); function request(method, url, body) { return new Request(url, { method, headers: { "Content-Type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body), }); } describe("POST /api/models/custom caps round-trip", () => { it("stores whitelisted booleans and merges later partial edits", async () => { setup(); const { POST, GET } = await import("@/app/api/models/custom/route.js"); const { getCustomModels } = await import("@/models/index.js"); const NODE = "openai-compatible-chat-abc123"; const first = await POST(request("POST", "https://9router.local/api/models/custom", { providerAlias: NODE, id: "my-model", type: "llm", // junk keys / non-booleans must be dropped by sanitizeCaps caps: { vision: true, reasoning: true, bogus: "yes", contextWindow: "128k" }, })); expect(first.status).toBe(200); expect(await first.json()).toEqual({ success: true, added: true }); const [row] = await getCustomModels(); expect(row.caps).toEqual({ vision: true, reasoning: true }); // Edit mode sends only the changed toggle. await POST(request("POST", "https://9router.local/api/models/custom", { providerAlias: NODE, id: "my-model", type: "llm", caps: { vision: false }, })); // GET /api/models/custom returns the raw stored rows. const listed = await (await GET()).json(); const raw = listed.models.find((m) => m.providerAlias === NODE && m.id === "my-model"); expect(raw).toBeTruthy(); expect(raw.caps).toEqual({ vision: false, reasoning: true }); // GET /api/models is what the dashboard badges read; stored caps must win there too. const { GET: getList } = await import("@/app/api/models/route.js"); const view = (await (await getList()).json()).models.find( (m) => m.provider === NODE && m.model === "my-model", ); expect(view).toBeTruthy(); expect(view.caps.vision).toBe(false); expect(view.caps.reasoning).toBe(true); }); it("keeps an edit with no changed toggles from clearing stored caps", async () => { setup(); const { POST } = await import("@/app/api/models/custom/route.js"); const { getCustomModels } = await import("@/models/index.js"); await POST(request("POST", "https://9router.local/api/models/custom", { providerAlias: "openai-compatible-chat-abc123", id: "stable-model", type: "llm", caps: { vision: false }, })); await POST(request("POST", "https://9router.local/api/models/custom", { providerAlias: "openai-compatible-chat-abc123", id: "stable-model", type: "llm", name: "Renamed", // no caps at all: sanitizeCaps -> null, so the assertion must survive })); const [row] = await getCustomModels(); expect(row.name).toBe("Renamed"); expect(row.caps).toEqual({ vision: false }); }); });