Files
gitmost/packages/mcp/test/unit/error-diagnostics.test.mjs
T
agent_coder 7cb3199d09 refactor(mcp)!: BREAKING — все имена MCP-тулов snake_case → camelCase (унификация с in-app) (#412)
Один логический тул жил под двумя именами: внешний MCP snake_case
(edit_page_text), in-app camelCase (editPageText) — дублирование доков, путаница
при переносе промптов/скиллов, помеха шарингу спек (#294). Решение владельца:
единый camelCase везде, включая внешний MCP. После этого mcpName === inAppKey.

- tool-specs.ts: mcpName ВЫВЕДЕН из ключа спеки (mcpName == inAppKey) для всех
  43 shared-спек — раньше divergent snake, теперь равен ключу (проверено: mcpName
  читается только структурно — цикл регистрации, генератор <tool_inventory>,
  TOOL_FAMILY). +5 inline-регистраций (tableGet/updateComment/deleteComment/
  docmostTransform; search без изменений). Рантайм: 47 тулов, все camelCase, ноль
  подчёркиваний.
- Контракт-конвенция ИНВЕРТИРОВАНА: shared-tool-specs.contract.spec
  `mcpName === toSnake(inAppKey)` → `mcpName === inAppKey`; tool-specs.test
  и tool-inventory.test обновлены.
- ROUTING_PROSE/TOOL_FAMILY/INLINE_MCP_INVENTORY (server-instructions.ts) →
  camelCase (105 замен). ai-chat.prompt/guard уже на in-app camelCase-ключах —
  без изменений (guard прошёл). comment-signal EXCLUDED_TOOLS схлопнут с
  дублей snake+camel до camelCase.
- Некоторое неочевидное: assertUnambiguousMatch(op: "patch_node"|"delete_node")
  в prosemirror-markdown/node-ops — op интерполируется в model-facing ошибку;
  литерал-юнион + call-sites → "patchNode"|"deleteNode".
- Все snake-имена в описаниях/error-строках/комментах/тестах/доках → camelCase
  (whole-token, longest-match-first). CHANGELOG: BREAKING-таблица 46 строк +
  миграция (allowlists mcp__gitmost-*__get_node→__getNode, промпты/скиллы,
  .mcp.json, метрики по tool-label); релизится вместе с #411.
  Внутренние имена методов (PageService.updatePageContent и т.п.) НЕ тронуты —
  переименованы только ИМЕНА ТУЛОВ.

Гейт: mcp node --test 677/677; tsc -p apps/server чисто; jest ai-chat-tools.
service + shared-tool-specs.contract + tool-tiers + ai-chat.prompt +
comment-signal-inapp → 323. Второй линк breaking-окна (#411→ЭТОТ→#413→#415).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 19:10:38 +03:00

451 lines
15 KiB
JavaScript

// Issue #437: central error diagnostics.
//
// Two surfaces are covered here:
// 1. formatDocmostAxiosError — the pure response-interceptor body that
// rewrites an AxiosError's `.message` into an actionable diagnostic.
// 2. assertFullUuid — the fail-fast comment-id guard (absorbs #436) that must
// throw BEFORE any network call.
// Plus an end-to-end pass over a real (offline) http server to prove the
// interceptor is wired, that a re-login retry leaves a success untouched, and
// that a persistent failure gets formatted — and that an invalid comment id
// short-circuits every comment tool with ZERO network traffic.
import { test, after } from "node:test";
import assert from "node:assert/strict";
import http from "node:http";
import axios, { AxiosError } from "axios";
import {
DocmostClient,
formatDocmostAxiosError,
assertFullUuid,
} from "../../build/client.js";
// Build an AxiosError-shaped object the way the interceptor's rejection handler
// receives it. Using the real AxiosError ctor makes axios.isAxiosError() true.
function makeAxiosError({
method = "post",
url = "/comments/resolve",
baseURL = "http://host.example/api",
status,
statusText,
data,
code,
message = "Request failed",
}) {
const config = { method, url, baseURL };
const response =
status === undefined
? undefined
: { status, statusText, data, headers: {}, config };
return new AxiosError(message, code, config, {}, response);
}
// ---------------------------------------------------------------------------
// formatDocmostAxiosError: message-body extraction rules.
// ---------------------------------------------------------------------------
test("class-validator message array is joined with '; '", () => {
const err = makeAxiosError({
status: 400,
statusText: "Bad Request",
data: { message: ["commentId must be a UUID", "resolved must be a boolean"] },
});
formatDocmostAxiosError(err);
assert.equal(
err.message,
"POST /comments/resolve failed (400 Bad Request): commentId must be a UUID; resolved must be a boolean",
);
});
test("a string message is used as-is", () => {
const err = makeAxiosError({
method: "post",
url: "/comments/resolve",
status: 400,
statusText: "Bad Request",
data: { message: "commentId must be a UUID" },
});
formatDocmostAxiosError(err);
assert.equal(
err.message,
"POST /comments/resolve failed (400 Bad Request): commentId must be a UUID",
);
});
test("falls back to data.error when message is absent", () => {
const err = makeAxiosError({
method: "get",
url: "/pages/info",
status: 403,
statusText: "Forbidden",
data: { error: "Forbidden" },
});
formatDocmostAxiosError(err);
assert.equal(err.message, "GET /pages/info failed (403 Forbidden): Forbidden");
});
test("empty object body falls back to statusText", () => {
const err = makeAxiosError({
status: 404,
statusText: "Not Found",
url: "/comments/info",
data: {},
});
formatDocmostAxiosError(err);
assert.equal(err.message, "POST /comments/info failed (404 Not Found): Not Found");
});
test("HTML/string body is NEVER surfaced — only the statusText", () => {
const html = "<html><body>502 Bad Gateway — nginx internals here</body></html>";
const err = makeAxiosError({
status: 502,
statusText: "Bad Gateway",
url: "/comments/create",
data: html,
});
formatDocmostAxiosError(err);
assert.equal(
err.message,
"POST /comments/create failed (502 Bad Gateway): Bad Gateway",
);
assert.ok(!err.message.includes("nginx"), "raw HTML body must not leak");
assert.ok(!err.message.includes("<html>"), "raw HTML body must not leak");
});
test("Buffer body carrying JSON is parsed for its message", () => {
const buf = Buffer.from(JSON.stringify({ message: "file too large" }), "utf8");
const err = makeAxiosError({
method: "get",
url: "/files/abc/x.png",
status: 413,
statusText: "Payload Too Large",
data: buf,
});
formatDocmostAxiosError(err);
assert.equal(
err.message,
"GET /files/abc/x.png failed (413 Payload Too Large): file too large",
);
});
test("Buffer body with non-JSON garbage falls back to statusText", () => {
const buf = Buffer.from("<<< not json at all >>>", "utf8");
const err = makeAxiosError({
method: "get",
url: "/files/abc/x.png",
status: 500,
statusText: "Internal Server Error",
data: buf,
});
formatDocmostAxiosError(err);
assert.equal(
err.message,
"GET /files/abc/x.png failed (500 Internal Server Error): Internal Server Error",
);
assert.ok(!err.message.includes("not json"), "raw buffer body must not leak");
});
test("an oversized Buffer body is not parsed (size cap) — statusText only", () => {
// A >4KB JSON buffer: even though it IS valid JSON with a message, the size
// cap means we do not attempt to parse it, so only the statusText survives.
const big = { message: "x".repeat(5000) };
const buf = Buffer.from(JSON.stringify(big), "utf8");
const err = makeAxiosError({
method: "get",
url: "/files/abc/x.png",
status: 500,
statusText: "Internal Server Error",
data: buf,
});
formatDocmostAxiosError(err);
assert.equal(
err.message,
"GET /files/abc/x.png failed (500 Internal Server Error): Internal Server Error",
);
});
// ---------------------------------------------------------------------------
// formatDocmostAxiosError: no-response and path/method handling.
// ---------------------------------------------------------------------------
test("no response uses error.code + path + 'no response from server'", () => {
const err = makeAxiosError({
method: "post",
url: "/comments/create",
status: undefined,
code: "ECONNREFUSED",
message: "connect ECONNREFUSED 127.0.0.1:3000",
});
formatDocmostAxiosError(err);
assert.equal(
err.message,
"POST /comments/create failed: ECONNREFUSED (no response from server)",
);
});
test("no response with no code falls back to a neutral reason (raw message not leaked — it may embed host:port)", () => {
const err = makeAxiosError({
method: "post",
url: "/comments/create",
status: undefined,
// A raw axios network message like "connect ECONNREFUSED 127.0.0.1:3000"
// embeds the host; #437's invariant is that it never reaches the message.
message: "connect ECONNREFUSED 10.0.0.5:3000",
});
formatDocmostAxiosError(err);
assert.equal(
err.message,
"POST /comments/create failed: network error (no response from server)",
);
// And the host must NOT appear anywhere in the model-visible message.
assert.ok(!err.message.includes("10.0.0.5"));
});
test("path drops the host and the query string", () => {
const err = makeAxiosError({
method: "post",
url: "/comments/resolve?token=secret&x=1",
baseURL: "https://docs.example.com/api",
status: 400,
statusText: "Bad Request",
data: { message: "bad" },
});
formatDocmostAxiosError(err);
assert.equal(
err.message,
"POST /comments/resolve failed (400 Bad Request): bad",
);
assert.ok(!err.message.includes("secret"), "query string must not leak");
assert.ok(!err.message.includes("docs.example.com"), "host must not leak");
});
// ---------------------------------------------------------------------------
// formatDocmostAxiosError: length cap + guard flag + pass-through.
// ---------------------------------------------------------------------------
test("the overall message is capped at ~300 chars", () => {
const err = makeAxiosError({
status: 400,
statusText: "Bad Request",
data: { message: "y".repeat(1000) },
});
formatDocmostAxiosError(err);
assert.ok(err.message.length <= 300, `expected <=300, got ${err.message.length}`);
assert.ok(err.message.endsWith("…"), "a truncated message ends with an ellipsis");
});
test("a formatted error is not re-processed (guard flag)", () => {
const err = makeAxiosError({
status: 400,
statusText: "Bad Request",
data: { message: "first" },
});
formatDocmostAxiosError(err);
const once = err.message;
assert.equal(err._docmostFormatted, true);
// Mutate the body and re-run: the guard makes it a no-op.
err.response.data = { message: "second" };
formatDocmostAxiosError(err);
assert.equal(err.message, once, "the guard flag prevents double-processing");
});
test("a non-axios error is passed through untouched", () => {
const plain = new Error("boom");
formatDocmostAxiosError(plain);
assert.equal(plain.message, "boom");
assert.equal(plain._docmostFormatted, undefined);
});
// ---------------------------------------------------------------------------
// assertFullUuid.
// ---------------------------------------------------------------------------
const GOOD_UUID = "019f499a-9f8c-7d68-b7be-ce100d7c6c56";
test("assertFullUuid accepts a full canonical UUID (any version nibble)", () => {
assert.doesNotThrow(() => assertFullUuid("resolveComment", "commentId", GOOD_UUID));
// A v4 id also passes (version/variant-agnostic).
assert.doesNotThrow(() =>
assertFullUuid("get_comment", "commentId", "3d5b7c1e-2f4a-4b6c-8d9e-0f1a2b3c4d5e"),
);
});
test("assertFullUuid rejects a truncated prefix", () => {
assert.throws(
() => assertFullUuid("resolveComment", "commentId", "019f499a"),
(e) =>
e.message.startsWith(
"resolveComment: 'commentId' must be the FULL comment UUID",
) &&
e.message.includes("got '019f499a'") &&
e.message.includes("Copy the id verbatim"),
);
});
test("assertFullUuid rejects garbage and empty string", () => {
assert.throws(
() => assertFullUuid("deleteComment", "commentId", "not-a-uuid"),
/must be the FULL comment UUID.*got 'not-a-uuid'/s,
);
assert.throws(
() => assertFullUuid("updateComment", "commentId", ""),
/must be the FULL comment UUID.*got ''/s,
);
});
// ---------------------------------------------------------------------------
// End-to-end over an offline http server: interceptor wiring + re-login.
// ---------------------------------------------------------------------------
function readBody(req) {
return new Promise((resolve) => {
let raw = "";
req.on("data", (c) => (raw += c));
req.on("end", () => resolve(raw));
});
}
function startServer(handler) {
return new Promise((resolve) => {
const server = http.createServer(handler);
server.listen(0, "127.0.0.1", () => {
const { port } = server.address();
resolve({ server, baseURL: `http://127.0.0.1:${port}/api` });
});
});
}
function sendJson(res, status, obj, extra = {}) {
res.writeHead(status, { "Content-Type": "application/json", ...extra });
res.end(JSON.stringify(obj));
}
const openServers = [];
async function spawn(handler) {
const { server, baseURL } = await startServer(handler);
openServers.push(server);
return { baseURL };
}
after(async () => {
await Promise.all(openServers.map((s) => new Promise((r) => s.close(r))));
});
test("a 400 on a JSON endpoint is reformatted by the wired interceptor", async () => {
const { baseURL } = await spawn(async (req, res) => {
await readBody(req);
if (req.url === "/api/auth/login") {
sendJson(res, 200, { success: true }, {
"Set-Cookie": "authToken=t; Path=/; HttpOnly",
});
return;
}
if (req.url === "/api/pages/info") {
sendJson(res, 400, { message: "pageId should not be empty" });
return;
}
sendJson(res, 404, {});
});
const client = new DocmostClient(baseURL, "u@example.com", "pw");
await assert.rejects(
() => client.getPageRaw("x"),
(e) => {
assert.ok(axios.isAxiosError(e), "still an AxiosError (mutation, not a subclass)");
assert.equal(e.response?.status, 400, "error.response?.status still readable");
assert.equal(
e.message,
"POST /pages/info failed (400 Bad Request): pageId should not be empty",
);
return true;
},
);
});
test("401 -> re-login -> successful retry: the SUCCESS message is untouched", async () => {
let infoCalls = 0;
const { baseURL } = await spawn(async (req, res) => {
await readBody(req);
if (req.url === "/api/auth/login") {
sendJson(res, 200, { success: true }, {
"Set-Cookie": "authToken=fresh; Path=/; HttpOnly",
});
return;
}
if (req.url === "/api/workspace/info") {
infoCalls++;
if (infoCalls === 1) sendJson(res, 401, { message: "Unauthorized" });
else sendJson(res, 200, { success: true, data: { id: "ws" } });
return;
}
sendJson(res, 404, {});
});
const client = new DocmostClient(baseURL, "u@example.com", "pw");
client.token = "stale";
client.client.defaults.headers.common["Authorization"] = "Bearer stale";
const result = await client.getWorkspace();
assert.equal(result.success, true, "the retried request resolved successfully");
assert.equal(infoCalls, 2, "401 then a successful replay");
});
test("401 -> re-login -> persistent failure: formatted AND retry guard intact", async () => {
let infoCalls = 0;
let loginCalls = 0;
const { baseURL } = await spawn(async (req, res) => {
await readBody(req);
if (req.url === "/api/auth/login") {
loginCalls++;
sendJson(res, 200, { success: true }, {
"Set-Cookie": "authToken=fresh; Path=/; HttpOnly",
});
return;
}
if (req.url === "/api/workspace/info") {
infoCalls++;
// Always 401, even after a fresh login: the _retry guard must stop here.
sendJson(res, 401, { message: "token still invalid" });
return;
}
sendJson(res, 404, {});
});
const client = new DocmostClient(baseURL, "u@example.com", "pw");
client.token = "stale";
client.client.defaults.headers.common["Authorization"] = "Bearer stale";
await assert.rejects(
() => client.getWorkspace(),
(e) => {
assert.equal(
e.message,
"POST /workspace/info failed (401 Unauthorized): token still invalid",
);
return true;
},
);
// The _retry guard is intact: exactly one replay (2 hits), one re-login.
assert.equal(infoCalls, 2, "endpoint hit at most twice (one retry only)");
assert.equal(loginCalls, 1, "re-login attempted exactly once");
});
// ---------------------------------------------------------------------------
// assertFullUuid application points: NO network call when the id is invalid.
// A server that counts EVERY request proves the guard short-circuits before
// even the login round-trip.
// ---------------------------------------------------------------------------
test("all 5 comment-id call sites reject a bad id with ZERO network traffic", async () => {
let requests = 0;
const { baseURL } = await spawn(async (req, res) => {
requests++;
await readBody(req);
sendJson(res, 200, { success: true });
});
const client = new DocmostClient(baseURL, "u@example.com", "pw");
const bad = "019f499a"; // truncated
await assert.rejects(() => client.resolveComment(bad, true), /resolveComment: 'commentId'/);
await assert.rejects(() => client.updateComment(bad, "hi"), /updateComment: 'commentId'/);
await assert.rejects(() => client.deleteComment(bad), /deleteComment: 'commentId'/);
await assert.rejects(() => client.getComment(bad), /get_comment: 'commentId'/);
// createComment validates parentCommentId only when provided.
await assert.rejects(
() => client.createComment("page-1", "body", "inline", "sel", bad),
/createComment: 'parentCommentId'/,
);
assert.equal(requests, 0, "no request (not even /auth/login) may be issued for a bad id");
});