import { test } from "node:test"; import assert from "node:assert/strict"; import { readdirSync, readFileSync } from "node:fs"; import { ApiError } from "../src/errors.js"; import { ALLOWED_PATHS, createClient } from "../src/client.js"; import { fixture } from "./helpers.js"; const BASE = "https://example.test"; const KEY = "orr_live_TESTKEY0000000000000000000000000"; interface Call { url: URL; init: RequestInit | undefined; } const FIXTURE_BY_PATH: Record = { "/v1/models": "v1-models", "/api/models": "api-models", "/api/keys/models": "keys-models", "/api/balance": "api-balance", "/api/usage": "api-usage", "/api/keys": "api-keys", "/v1/auth/key": "v1-auth-key", }; function fakeFetch(override?: (url: URL) => Response | undefined): { fetchImpl: typeof fetch; calls: Call[] } { const calls: Call[] = []; const fetchImpl = (async (input: string | URL | Request, init?: RequestInit) => { const url = new URL(String(input)); calls.push({ url, init }); const custom = override?.(url); if (custom !== undefined) return custom; const name = FIXTURE_BY_PATH[url.pathname]; if (name === undefined) return new Response("{}", { status: 404 }); return new Response(JSON.stringify(fixture(name)), { status: 200, headers: { "content-type": "application/json" }, }); }) as typeof fetch; return { fetchImpl, calls }; } const METHODS = [ "getV1Models", "getApiModels", "getKeyModels", "getBalance", "getUsage", "getKeys", "getAuthKey", ] as const; test("клиент — замороженный объект ровно с семью методами чтения", () => { const client = createClient({ apiKey: KEY, baseUrl: BASE }, fakeFetch().fetchImpl); assert.deepEqual(Object.keys(client).sort(), [...METHODS].sort()); assert.ok(Object.isFrozen(client)); // Модули ESM — strict mode: запись в замороженный объект бросает TypeError. assert.throws(() => { (client as unknown as Record).request = () => undefined; }, TypeError); assert.throws(() => { (client as unknown as Record).getBalance = () => undefined; }, TypeError); assert.equal("request" in client, false); }); test("модуль экспортирует только createClient и ALLOWED_PATHS", async () => { const mod = await import("../src/client.js"); assert.deepEqual(Object.keys(mod).sort(), ["ALLOWED_PATHS", "createClient"]); assert.equal(ALLOWED_PATHS.length, 7); }); test("каждый метод шлёт GET без тела на путь из ALLOWED_PATHS с нужными заголовками", async () => { const { fetchImpl, calls } = fakeFetch(); const client = createClient({ apiKey: KEY, baseUrl: BASE }, fetchImpl); await client.getV1Models(); await client.getApiModels(); await client.getKeyModels(); await client.getBalance(); await client.getUsage({ from: "2026-10-17T00:00:00Z", to: "2026-10-24T00:00:00Z", groupBy: "model" }); await client.getKeys(); await client.getAuthKey(); assert.equal(calls.length, 7); for (const c of calls) { assert.equal(c.init?.method, "GET"); assert.equal(c.init?.body, undefined); assert.equal(c.init?.redirect, "error"); assert.ok(c.init?.signal instanceof AbortSignal); assert.ok(ALLOWED_PATHS.includes(c.url.pathname), c.url.pathname); assert.equal(c.url.origin, BASE); const h = c.init?.headers as Record; assert.equal(h.Authorization, `Bearer ${KEY}`); assert.equal(h.Accept, "application/json"); assert.match(h["User-Agent"] ?? "", /^openrusrouter-mcp\/\d+\.\d+\.\d+/); } assert.deepEqual(new Set(calls.map((c) => c.url.pathname)), new Set(ALLOWED_PATHS)); const usage = calls.find((c) => c.url.pathname === "/api/usage"); assert.equal(usage?.url.searchParams.get("group_by"), "model"); assert.equal(usage?.url.searchParams.get("from"), "2026-10-17T00:00:00Z"); }); test("исходник client.ts не содержит мутирующих HTTP-методов, fetch( вызывается только в нём", () => { const srcDir = new URL("../src/", import.meta.url); const own = readFileSync(new URL("client.ts", srcDir), "utf8"); for (const m of ["POST", "PUT", "PATCH", "DELETE"]) { assert.ok(!own.includes(`"${m}"`) && !own.includes(`'${m}'`) && !own.includes(`\`${m}\``), m); } const files = readdirSync(srcDir, { recursive: true, encoding: "utf8" }).filter( (f) => f.endsWith(".ts") && !f.startsWith("generated"), ); assert.ok(files.length > 5); for (const f of files) { if (f === "client.ts") continue; const text = readFileSync(new URL(f, srcDir), "utf8"); assert.ok(!/\bfetch\s*\(/.test(text), `вызов fetch( в src/${f}`); } }); test("денежное поле числом — fail-closed ошибка, а не String(number)", async () => { const { fetchImpl } = fakeFetch((url) => url.pathname === "/api/balance" ? new Response(JSON.stringify({ balance: 1234.56, referral_balance: "0" }), { status: 200 }) : undefined, ); const client = createClient({ apiKey: KEY, baseUrl: BASE }, fetchImpl); await assert.rejects(client.getBalance(), /неожиданный числовой тип у денежного поля \/api\/balance\.balance/); }); test("числовая сумма в ключе и в /v1/auth/key тоже отвергается", async () => { const { fetchImpl } = fakeFetch((url) => { if (url.pathname === "/api/keys") { const body = fixture<{ keys: Record[] }>("api-keys"); (body.keys[0] as Record).spend_used = 12.5; return new Response(JSON.stringify(body), { status: 200 }); } if (url.pathname === "/v1/auth/key") { return new Response(JSON.stringify({ data: { ...fixture<{ data: object }>("v1-auth-key").data, usage: 1 } }), { status: 200, }); } return undefined; }); const client = createClient({ apiKey: KEY, baseUrl: BASE }, fetchImpl); await assert.rejects(client.getKeys(), /spend_used/); await assert.rejects(client.getAuthKey(), /usage/); }); test("401 с телом ошибки — ApiError со статусом, кодом и путём", async () => { const { fetchImpl } = fakeFetch( () => new Response(JSON.stringify({ error: { code: "invalid_key", message: `bad key ${KEY} ${"x".repeat(500)}` } }), { status: 401, }), ); const client = createClient({ apiKey: KEY, baseUrl: BASE }, fetchImpl); const err = await client.getBalance().then( () => assert.fail("ожидалась ошибка"), (e: unknown) => e, ); assert.ok(err instanceof ApiError); assert.equal(err.status, 401); assert.equal(err.code, "invalid_key"); assert.equal(err.path, "/api/balance"); assert.ok(Array.from(err.message).length <= 300); assert.ok(!err.message.includes(KEY)); }); test("сетевая ошибка оборачивается без cause и с вычищенным ключом", async () => { const fetchImpl = (async () => { const e = new TypeError(`connect failed, Authorization: Bearer ${KEY}`); (e as unknown as { cause: unknown }).cause = { headers: { Authorization: `Bearer ${KEY}` } }; throw e; }) as typeof fetch; const client = createClient({ apiKey: KEY, baseUrl: BASE }, fetchImpl); const err = await client.getBalance().then( () => assert.fail("ожидалась ошибка"), (e: unknown) => e as Error, ); assert.ok(!err.message.includes(KEY)); assert.match(err.message, /TypeError/); assert.equal(err.cause, undefined); }); test("ответ с Content-Length больше 5 МБ отвергается до чтения тела", async () => { const { fetchImpl } = fakeFetch( () => new Response("{}", { status: 200, headers: { "content-length": "5000001" } }), ); const client = createClient({ apiKey: KEY, baseUrl: BASE }, fetchImpl); await assert.rejects(client.getBalance(), /слишком велик/); }); test("/api/keys без обёртки keys — ошибка формы", async () => { const { fetchImpl } = fakeFetch((url) => url.pathname === "/api/keys" ? new Response(JSON.stringify([]), { status: 200 }) : undefined, ); const client = createClient({ apiKey: KEY, baseUrl: BASE }, fetchImpl); await assert.rejects(client.getKeys(), /неожиданная форма ответа/); });