Snapshot: gti-scheduling-mcp (Z-PRO + Klingo adapters) e workflow n8n de agendamento funcionando de ponta a ponta
- 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>
This commit is contained in:
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user