Files
vasyanskandClaude Opus 5.5 d7d02267e2 Добавь HTTP-клиент, каталог моделей и шесть read-only инструментов MCP
Клиент — замороженный объект с семью методами 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>
2026-10-24 10:59:06 +07:00

199 lines
8.3 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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(), /неожиданная форма ответа/);
});