- gti-scheduling-mcp: MCP server com ZProAdapter e KlingoAdapter (agendar() completo pros dois) - Workflow n8n 'Agendamento - GChat - Z-PRO': webhook -> AI Agent -> MCP -> Z-PRO, testado com sucesso real - Documentacao: specs, plano, runbook de problemas reais e export do workflow Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
185 lines
5.4 KiB
TypeScript
185 lines
5.4 KiB
TypeScript
/**
|
|
* test/diagnose-client.test.ts
|
|
*
|
|
* Testes unitários do cliente HTTP de diagnóstico e do tratamento de
|
|
* respostas. Nenhum teste chama a API real — todos usam um fetch fake.
|
|
*/
|
|
|
|
import { describe, it } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
|
|
import {
|
|
RequestTimeoutError,
|
|
NetworkRequestError,
|
|
buildAppointmentListUrl,
|
|
fetchAppointmentList,
|
|
} from "../src/diagnostics/client.js";
|
|
import {
|
|
tryParseJson,
|
|
buildResponseReport,
|
|
} from "../src/diagnostics/response.js";
|
|
import type { ZProConfig } from "../src/config/config.types.js";
|
|
|
|
const config: ZProConfig = {
|
|
provider: "zpro",
|
|
baseUrl: "https://zpro.example.com",
|
|
apiToken: "token-super-secreto",
|
|
apiId: "api-1",
|
|
};
|
|
|
|
describe("buildAppointmentListUrl", () => {
|
|
it("monta a URL com ApiID e parâmetros conservadores", () => {
|
|
const url = buildAppointmentListUrl({
|
|
baseUrl: "https://zpro.example.com/",
|
|
apiId: "api-1",
|
|
});
|
|
|
|
assert.equal(
|
|
url,
|
|
"https://zpro.example.com/v2/api/external/api-1/appointment/list?page=1&limit=5",
|
|
);
|
|
});
|
|
|
|
it("não inclui o token na URL", () => {
|
|
const url = buildAppointmentListUrl(config);
|
|
|
|
assert.ok(!url.includes(config.apiToken));
|
|
});
|
|
});
|
|
|
|
describe("fetchAppointmentList", () => {
|
|
it("envia GET com Bearer token e Accept, e retorna os dados da resposta", async () => {
|
|
let capturedUrl = "";
|
|
let capturedHeaders: Record<string, string> = {};
|
|
|
|
const fakeFetch = async (
|
|
url: RequestInfo | URL,
|
|
init?: RequestInit,
|
|
): Promise<Response> => {
|
|
capturedUrl = String(url);
|
|
capturedHeaders = Object.fromEntries(
|
|
new Headers(init?.headers).entries(),
|
|
);
|
|
return new Response(JSON.stringify({ ok: true }), {
|
|
status: 200,
|
|
headers: { "content-type": "application/json" },
|
|
});
|
|
};
|
|
|
|
const response = await fetchAppointmentList(config, {
|
|
fetchImpl: fakeFetch,
|
|
});
|
|
|
|
assert.equal(capturedUrl, buildAppointmentListUrl(config));
|
|
assert.equal(capturedHeaders.authorization, "Bearer token-super-secreto");
|
|
assert.equal(capturedHeaders.accept, "application/json");
|
|
assert.equal(response.status, 200);
|
|
assert.equal(response.contentType, "application/json");
|
|
assert.deepEqual(JSON.parse(response.bodyText), { ok: true });
|
|
assert.equal(typeof response.durationMs, "number");
|
|
});
|
|
|
|
it("não envia o token em outro header", async () => {
|
|
let capturedHeaders: Record<string, string> = {};
|
|
const fakeFetch = async (
|
|
_url: RequestInfo | URL,
|
|
init?: RequestInit,
|
|
): Promise<Response> => {
|
|
capturedHeaders = Object.fromEntries(
|
|
new Headers(init?.headers).entries(),
|
|
);
|
|
return new Response("", { status: 200 });
|
|
};
|
|
|
|
await fetchAppointmentList(config, { fetchImpl: fakeFetch });
|
|
|
|
assert.deepEqual(capturedHeaders, {
|
|
authorization: `Bearer ${config.apiToken}`,
|
|
accept: "application/json",
|
|
});
|
|
});
|
|
|
|
it("lança RequestTimeoutError quando a requisição excede o timeout", async () => {
|
|
const fakeFetch = async (
|
|
_url: RequestInfo | URL,
|
|
init?: RequestInit,
|
|
): Promise<Response> => {
|
|
return new Promise((_resolve, reject) => {
|
|
init?.signal?.addEventListener("abort", () => {
|
|
reject(new DOMException("The operation was aborted", "AbortError"));
|
|
});
|
|
});
|
|
};
|
|
|
|
await assert.rejects(
|
|
fetchAppointmentList(config, {
|
|
timeoutMs: 50,
|
|
fetchImpl: fakeFetch,
|
|
}),
|
|
(error: unknown) => error instanceof RequestTimeoutError,
|
|
);
|
|
});
|
|
|
|
it("lança NetworkRequestError em erro de rede, sem expor o token", async () => {
|
|
const fakeFetch = async (): Promise<Response> => {
|
|
throw new TypeError("fetch failed");
|
|
};
|
|
|
|
await assert.rejects(
|
|
fetchAppointmentList(config, { fetchImpl: fakeFetch }),
|
|
(error: unknown) => {
|
|
assert.ok(error instanceof NetworkRequestError);
|
|
assert.ok(!error.message.includes(config.apiToken));
|
|
return true;
|
|
},
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("tratamento de resposta", () => {
|
|
it("identifica uma resposta JSON", () => {
|
|
const parsed = tryParseJson('{"data": [1, 2]}');
|
|
|
|
assert.deepEqual(parsed, { data: [1, 2] });
|
|
});
|
|
|
|
it("identifica uma resposta não JSON", () => {
|
|
const parsed = tryParseJson("<html><body>erro</body></html>");
|
|
|
|
assert.equal(parsed, null);
|
|
});
|
|
|
|
it("monta relatório para resposta JSON com conteúdo sanitizado e estrutura", () => {
|
|
const report = buildResponseReport(
|
|
JSON.stringify({
|
|
data: [
|
|
{
|
|
id: "app-1",
|
|
patient: { name: "Maria dos Santos", phone: "75999999999" },
|
|
status: "confirmed",
|
|
},
|
|
],
|
|
page: 1,
|
|
}),
|
|
["token-super-secreto"],
|
|
);
|
|
|
|
assert.equal(report.isJson, true);
|
|
assert.ok(report.structure);
|
|
assert.equal(report.structure.rootType, "object");
|
|
assert.equal(report.structure.arrayItemCount, undefined);
|
|
assert.ok(!report.sanitizedText.includes("Maria dos Santos"));
|
|
assert.ok(!report.sanitizedText.includes("75999999999"));
|
|
});
|
|
|
|
it("monta relatório para resposta não JSON limitado a 500 caracteres", () => {
|
|
const longHtml = `<!DOCTYPE html>${"x".repeat(1000)}`;
|
|
|
|
const report = buildResponseReport(longHtml, []);
|
|
|
|
assert.equal(report.isJson, false);
|
|
assert.equal(report.structure, undefined);
|
|
assert.ok(report.sanitizedText.length <= 500);
|
|
});
|
|
});
|