201 lines
8.7 KiB
TypeScript
201 lines
8.7 KiB
TypeScript
/**
|
|
* tests/unit/quota-key-models-route.test.ts
|
|
*
|
|
* Task 2 (quota-share-v2) — source-level assertions for:
|
|
* GET /api/quota/keys/[id]/models
|
|
*
|
|
* Uses the same source-scan technique as:
|
|
* tests/unit/quota-groups-route.test.ts
|
|
* tests/unit/quota-pool-log-route.test.ts
|
|
*
|
|
* This allows the test to run with the Node.js native test runner without a
|
|
* Next.js / DOM setup, while providing strong structural coverage of:
|
|
* - Auth guard pattern (requireManagementAuth, 401 gate)
|
|
* - Error sanitization (buildErrorBody, no raw err.stack / err.message leaks)
|
|
* - Quota model resolution imports (resolveQuotaKeyScope, filterModelsToQuotaPools)
|
|
* - Response shape ({ models })
|
|
* - 404 on missing key (buildErrorBody(404, ...))
|
|
* - force-dynamic export
|
|
* - await params (Next 16 async params pattern)
|
|
* - DB imports sourced from @/lib/localDb
|
|
*
|
|
* The behavioral path (allowedQuotas → scope → filterModelsToQuotaPools → models)
|
|
* is covered by:
|
|
* - tests/unit/quota-catalog-filter.test.ts — filterModelsToQuotaPools unit tests
|
|
* - tests/unit/quota-key-resolve.test.ts — resolveQuotaKeyScope unit tests
|
|
* - tests/unit/quota-group-scope.test.ts — group-scoped pool resolution
|
|
* Re-testing the same logic inline here would duplicate DB-setup boilerplate
|
|
* without additional signal. Source-scan matches the project's established
|
|
* convention for route test files (quota-groups-route.test.ts, etc.).
|
|
*/
|
|
|
|
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { readFileSync } from "node:fs";
|
|
import { join, dirname } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
|
|
const ROUTE_PATH = join(ROOT, "src/app/api/quota/keys/[id]/models/route.ts");
|
|
|
|
const src = readFileSync(ROUTE_PATH, "utf8");
|
|
|
|
// ── Auth guard ────────────────────────────────────────────────────────────────
|
|
|
|
test("quota/keys/[id]/models: imports requireManagementAuth", () => {
|
|
assert.ok(
|
|
src.includes("requireManagementAuth"),
|
|
"route must import and call requireManagementAuth",
|
|
);
|
|
});
|
|
|
|
test("quota/keys/[id]/models: GET calls requireManagementAuth before data access", () => {
|
|
const getIdx = src.indexOf("export async function GET");
|
|
assert.ok(getIdx >= 0, "GET handler must exist");
|
|
const getBody = src.slice(getIdx);
|
|
const authIdx = getBody.indexOf("requireManagementAuth(request)");
|
|
const dataIdx = getBody.indexOf("getApiKeyById(");
|
|
assert.ok(authIdx >= 0, "requireManagementAuth call must be present in GET handler");
|
|
assert.ok(dataIdx >= 0, "getApiKeyById call must be present in GET handler");
|
|
assert.ok(authIdx < dataIdx, "auth check must come before getApiKeyById call");
|
|
});
|
|
|
|
test("quota/keys/[id]/models: GET returns early when authError is truthy", () => {
|
|
const getIdx = src.indexOf("export async function GET");
|
|
const getBody = src.slice(getIdx);
|
|
assert.ok(
|
|
getBody.includes("if (authError) return authError"),
|
|
"GET must return authError immediately — 401 without auth",
|
|
);
|
|
});
|
|
|
|
// ── Error sanitization ────────────────────────────────────────────────────────
|
|
|
|
test("quota/keys/[id]/models: imports buildErrorBody from @omniroute/open-sse/utils/error", () => {
|
|
assert.ok(
|
|
src.includes("buildErrorBody"),
|
|
"route must use buildErrorBody — Hard Rule #12",
|
|
);
|
|
assert.ok(
|
|
src.includes("@omniroute/open-sse/utils/error"),
|
|
"route must import buildErrorBody from @omniroute/open-sse/utils/error",
|
|
);
|
|
});
|
|
|
|
test("quota/keys/[id]/models: does NOT leak raw err.stack in response", () => {
|
|
assert.ok(!src.includes("err.stack"), "route must not leak err.stack in response body");
|
|
});
|
|
|
|
test("quota/keys/[id]/models: does NOT leak raw err.message outside buildErrorBody", () => {
|
|
// The only use of err.message must be inside the buildErrorBody(...) call, never
|
|
// passed raw to NextResponse.json() or similar.
|
|
const rawMessageLeak = /NextResponse\.json\s*\(\s*err\.message/.test(src);
|
|
assert.ok(!rawMessageLeak, "route must not pass raw err.message to NextResponse.json");
|
|
});
|
|
|
|
// ── Quota resolution imports ──────────────────────────────────────────────────
|
|
|
|
test("quota/keys/[id]/models: imports resolveQuotaKeyScope from @/lib/quota/quotaKey", () => {
|
|
assert.ok(
|
|
src.includes("resolveQuotaKeyScope"),
|
|
"route must call resolveQuotaKeyScope to compute pool scope",
|
|
);
|
|
assert.ok(
|
|
src.includes("@/lib/quota/quotaKey"),
|
|
"route must import resolveQuotaKeyScope from @/lib/quota/quotaKey",
|
|
);
|
|
});
|
|
|
|
test("quota/keys/[id]/models: imports filterModelsToQuotaPools from @/lib/quota/quotaCombos", () => {
|
|
assert.ok(
|
|
src.includes("filterModelsToQuotaPools"),
|
|
"route must call filterModelsToQuotaPools to filter combo candidates",
|
|
);
|
|
assert.ok(
|
|
src.includes("@/lib/quota/quotaCombos"),
|
|
"route must import filterModelsToQuotaPools from @/lib/quota/quotaCombos",
|
|
);
|
|
});
|
|
|
|
test("quota/keys/[id]/models: passes scope.poolSlugs to filterModelsToQuotaPools", () => {
|
|
assert.ok(
|
|
src.includes("scope.poolSlugs"),
|
|
"route must pass scope.poolSlugs to filterModelsToQuotaPools (same as catalog.ts approach)",
|
|
);
|
|
});
|
|
|
|
// ── DB imports from localDb ───────────────────────────────────────────────────
|
|
|
|
test("quota/keys/[id]/models: imports getApiKeyById from @/lib/localDb", () => {
|
|
assert.ok(src.includes("getApiKeyById"), "route must call getApiKeyById");
|
|
assert.ok(
|
|
src.includes("@/lib/localDb"),
|
|
"route must import from @/lib/localDb (not from @/lib/db/apiKeys directly)",
|
|
);
|
|
});
|
|
|
|
test("quota/keys/[id]/models: imports getCombos from @/lib/localDb", () => {
|
|
assert.ok(src.includes("getCombos"), "route must call getCombos to build combo candidates");
|
|
});
|
|
|
|
// ── Response shape ────────────────────────────────────────────────────────────
|
|
|
|
test("quota/keys/[id]/models: GET returns { models } shape", () => {
|
|
assert.ok(
|
|
src.includes("{ models }") || src.includes("{models}") || src.includes("models:"),
|
|
"GET must return { models } in the response body",
|
|
);
|
|
});
|
|
|
|
test("quota/keys/[id]/models: maps filtered combos to model id strings (.map(m => m.id))", () => {
|
|
assert.ok(
|
|
src.includes(".map(") && src.includes("m.id"),
|
|
"route must map filtered combo entries to their id strings",
|
|
);
|
|
});
|
|
|
|
// ── 404 on missing key ────────────────────────────────────────────────────────
|
|
|
|
test("quota/keys/[id]/models: returns buildErrorBody(404, ...) when key not found", () => {
|
|
assert.ok(
|
|
src.includes("buildErrorBody(404"),
|
|
"route must use buildErrorBody(404, ...) when the API key is not found",
|
|
);
|
|
assert.ok(src.includes("404"), "route must return HTTP 404 on missing key");
|
|
});
|
|
|
|
// ── force-dynamic ─────────────────────────────────────────────────────────────
|
|
|
|
test("quota/keys/[id]/models: has dynamic = 'force-dynamic' export", () => {
|
|
assert.ok(
|
|
src.includes('dynamic = "force-dynamic"') || src.includes("dynamic = 'force-dynamic'"),
|
|
"route must export dynamic = 'force-dynamic'",
|
|
);
|
|
});
|
|
|
|
// ── Next 16 async params ──────────────────────────────────────────────────────
|
|
|
|
test("quota/keys/[id]/models: reads id via await params (Next 16 pattern)", () => {
|
|
assert.ok(
|
|
src.includes("await params"),
|
|
"route must use await params — Next 16 async params pattern",
|
|
);
|
|
});
|
|
|
|
test("quota/keys/[id]/models: params typed as Promise<{ id: string }>", () => {
|
|
assert.ok(
|
|
src.includes("Promise<") && src.includes("id: string"),
|
|
"params type must be Promise<{ id: string }> — matching groups/[id] pattern",
|
|
);
|
|
});
|
|
|
|
// ── exports GET ───────────────────────────────────────────────────────────────
|
|
|
|
test("quota/keys/[id]/models: exports GET handler", () => {
|
|
assert.ok(
|
|
src.includes("export async function GET"),
|
|
"route must export the GET handler",
|
|
);
|
|
});
|