Клиент — замороженный объект с семью методами GET, без универсального запроса и тела; денежные поля проверяются на строковый тип (fail-closed). Каталог джойнит /api/models и /v1/models: модальность и рубли из первого, USD и доступность ключу из второго. Инструменты: list_models, pick_model, estimate_cost, get_balance, get_usage, list_api_keys. Точка входа stdio уводит console.log/info в stderr первым импортом. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
199 lines
8.3 KiB
TypeScript
199 lines
8.3 KiB
TypeScript
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<string, string> = {
|
||
"/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<string, unknown>).request = () => undefined;
|
||
}, TypeError);
|
||
assert.throws(() => {
|
||
(client as unknown as Record<string, unknown>).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<string, string>;
|
||
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<string, unknown>[] }>("api-keys");
|
||
(body.keys[0] as Record<string, unknown>).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(), /неожиданная форма ответа/);
|
||
});
|