- 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>
571 lines
18 KiB
TypeScript
571 lines
18 KiB
TypeScript
/**
|
|
* test/zpro-adapter.test.ts
|
|
*
|
|
* Testes unitários do ZProAdapter real (agendar/consultarHorarios).
|
|
* Todos usam um fetch fake injetado via `fetchImpl` — nenhum teste chama
|
|
* a API real do Z-PRO.
|
|
*/
|
|
|
|
import { describe, it } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
|
|
import { ZProAdapter } from "../src/adapters/zpro/zpro.adapter.js";
|
|
import {
|
|
buildAppointmentCreateUrl,
|
|
describeResponseMetadata,
|
|
} from "../src/adapters/zpro/zpro-http-client.js";
|
|
import { ZProApiError } from "../src/adapters/zpro/zpro.errors.js";
|
|
import type { ZProConfig } from "../src/config/config.types.js";
|
|
import type { CreateAppointmentInput } from "../src/types/scheduling.types.js";
|
|
|
|
const config: ZProConfig = {
|
|
provider: "zpro",
|
|
baseUrl: "https://zpro.example.com",
|
|
apiToken: "token-super-secreto",
|
|
apiId: "api-1",
|
|
};
|
|
|
|
const baseInput: CreateAppointmentInput = {
|
|
clinicId: "clinic-1",
|
|
startAt: "2026-04-15T14:00:00.000Z",
|
|
endAt: "2026-04-15T15:00:00.000Z",
|
|
patient: { name: "Paciente Exemplo" },
|
|
};
|
|
|
|
function jsonResponse(status: number, body: unknown): Response {
|
|
return new Response(JSON.stringify(body), {
|
|
status,
|
|
headers: { "content-type": "application/json" },
|
|
});
|
|
}
|
|
|
|
async function expectCode(
|
|
promise: Promise<unknown>,
|
|
code: string,
|
|
): Promise<void> {
|
|
await assert.rejects(promise, (error: unknown) => {
|
|
assert.ok(error instanceof ZProApiError);
|
|
assert.equal(error.code, code);
|
|
return true;
|
|
});
|
|
}
|
|
|
|
describe("ZProAdapter.consultarHorarios", () => {
|
|
it("lança ZProApiError com código NOT_SUPPORTED, sem chamar a rede", async () => {
|
|
let called = false;
|
|
const fetchImpl = async (): Promise<Response> => {
|
|
called = true;
|
|
return jsonResponse(200, {});
|
|
};
|
|
const adapter = new ZProAdapter(config, { fetchImpl });
|
|
|
|
await expectCode(
|
|
adapter.consultarHorarios({ clinicId: "clinic-1" }),
|
|
"NOT_SUPPORTED",
|
|
);
|
|
assert.equal(called, false);
|
|
});
|
|
});
|
|
|
|
describe("ZProAdapter.agendar — montagem da requisição", () => {
|
|
it("envia POST para a URL correta, com Bearer token e corpo mapeado", async () => {
|
|
let capturedUrl = "";
|
|
let capturedMethod = "";
|
|
let capturedHeaders: Record<string, string> = {};
|
|
let capturedBody: unknown;
|
|
|
|
const fetchImpl = async (
|
|
url: RequestInfo | URL,
|
|
init?: RequestInit,
|
|
): Promise<Response> => {
|
|
capturedUrl = String(url);
|
|
capturedMethod = init?.method ?? "";
|
|
capturedHeaders = Object.fromEntries(
|
|
new Headers(init?.headers).entries(),
|
|
);
|
|
capturedBody = JSON.parse(String(init?.body));
|
|
return jsonResponse(201, { ok: true });
|
|
};
|
|
|
|
const adapter = new ZProAdapter(config, { fetchImpl });
|
|
|
|
await adapter.agendar({
|
|
...baseInput,
|
|
patient: { id: "contact-1", name: "Maria Souza", phone: "5511999999999" },
|
|
});
|
|
|
|
assert.equal(capturedUrl, buildAppointmentCreateUrl(config));
|
|
assert.equal(capturedMethod, "POST");
|
|
assert.equal(capturedHeaders.authorization, "Bearer token-super-secreto");
|
|
assert.equal(capturedHeaders["content-type"], "application/json");
|
|
assert.deepEqual(capturedBody, {
|
|
title: "Consulta - Maria Souza",
|
|
contactId: "contact-1",
|
|
contactName: "Maria Souza",
|
|
contactPhone: "5511999999999",
|
|
startAt: baseInput.startAt,
|
|
endAt: baseInput.endAt,
|
|
status: "pending",
|
|
});
|
|
});
|
|
|
|
it("usa o título informado pelo chamador quando presente", async () => {
|
|
let capturedBody: unknown;
|
|
const fetchImpl = async (
|
|
_url: RequestInfo | URL,
|
|
init?: RequestInit,
|
|
): Promise<Response> => {
|
|
capturedBody = JSON.parse(String(init?.body));
|
|
return jsonResponse(201, { ok: true });
|
|
};
|
|
const adapter = new ZProAdapter(config, { fetchImpl });
|
|
|
|
await adapter.agendar({ ...baseInput, title: "Retorno - Dr. Alex" });
|
|
|
|
assert.equal(
|
|
(capturedBody as { title: string }).title,
|
|
"Retorno - Dr. Alex",
|
|
);
|
|
});
|
|
|
|
it("omite contactId e contactPhone quando o paciente não os informa", async () => {
|
|
let capturedBody: unknown;
|
|
const fetchImpl = async (
|
|
_url: RequestInfo | URL,
|
|
init?: RequestInit,
|
|
): Promise<Response> => {
|
|
capturedBody = JSON.parse(String(init?.body));
|
|
return jsonResponse(201, { ok: true });
|
|
};
|
|
const adapter = new ZProAdapter(config, { fetchImpl });
|
|
|
|
await adapter.agendar(baseInput);
|
|
|
|
assert.ok(!("contactId" in (capturedBody as object)));
|
|
assert.ok(!("contactPhone" in (capturedBody as object)));
|
|
});
|
|
|
|
it("não inclui o token em nenhum outro header", async () => {
|
|
let capturedHeaders: Record<string, string> = {};
|
|
const fetchImpl = async (
|
|
_url: RequestInfo | URL,
|
|
init?: RequestInit,
|
|
): Promise<Response> => {
|
|
capturedHeaders = Object.fromEntries(
|
|
new Headers(init?.headers).entries(),
|
|
);
|
|
return jsonResponse(201, { ok: true });
|
|
};
|
|
const adapter = new ZProAdapter(config, { fetchImpl });
|
|
|
|
await adapter.agendar(baseInput);
|
|
|
|
assert.deepEqual(Object.keys(capturedHeaders).sort(), [
|
|
"accept",
|
|
"authorization",
|
|
"content-type",
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("ZProAdapter.agendar — classificação de erros HTTP", () => {
|
|
const cases: Array<{ status: number; code: string }> = [
|
|
{ status: 401, code: "AUTHENTICATION_ERROR" },
|
|
{ status: 403, code: "AUTHENTICATION_ERROR" },
|
|
{ status: 404, code: "NOT_FOUND" },
|
|
{ status: 400, code: "VALIDATION_ERROR" },
|
|
{ status: 422, code: "VALIDATION_ERROR" },
|
|
{ status: 500, code: "ZPRO_API_ERROR" },
|
|
];
|
|
|
|
for (const { status, code } of cases) {
|
|
it(`mapeia HTTP ${status} para ${code}`, async () => {
|
|
const fetchImpl = async (): Promise<Response> =>
|
|
jsonResponse(status, { message: "erro" });
|
|
const adapter = new ZProAdapter(config, { fetchImpl });
|
|
|
|
await expectCode(adapter.agendar(baseInput), code);
|
|
});
|
|
}
|
|
|
|
it("mapeia timeout para ZPRO_TIMEOUT", async () => {
|
|
const fetchImpl = 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"));
|
|
});
|
|
});
|
|
};
|
|
const adapter = new ZProAdapter(config, { fetchImpl, timeoutMs: 50 });
|
|
|
|
await expectCode(adapter.agendar(baseInput), "ZPRO_TIMEOUT");
|
|
});
|
|
|
|
it("mapeia erro de rede para ZPRO_API_ERROR, sem vazar o token", async () => {
|
|
const fetchImpl = async (): Promise<Response> => {
|
|
throw new TypeError("fetch failed");
|
|
};
|
|
const adapter = new ZProAdapter(config, { fetchImpl });
|
|
|
|
await assert.rejects(adapter.agendar(baseInput), (error: unknown) => {
|
|
assert.ok(error instanceof ZProApiError);
|
|
assert.equal(error.code, "ZPRO_API_ERROR");
|
|
assert.ok(!error.message.includes(config.apiToken));
|
|
return true;
|
|
});
|
|
});
|
|
|
|
it("não inclui o token em nenhuma mensagem de erro (todos os cenários)", async () => {
|
|
const scenarios: Array<() => Promise<Response>> = [
|
|
async () => jsonResponse(401, {}),
|
|
async () => jsonResponse(500, {}),
|
|
async () => {
|
|
throw new TypeError("fetch failed");
|
|
},
|
|
];
|
|
|
|
for (const fetchImpl of scenarios) {
|
|
const adapter = new ZProAdapter(config, { fetchImpl });
|
|
await assert.rejects(adapter.agendar(baseInput), (error: unknown) => {
|
|
assert.ok(error instanceof Error);
|
|
assert.ok(!error.message.includes(config.apiToken));
|
|
return true;
|
|
});
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("ZProAdapter.agendar — sucesso confirmado pela API (HTTP 2xx)", () => {
|
|
it("sinaliza responseMappingPending, sem extrair nenhum campo da resposta", async () => {
|
|
const fetchImpl = async (): Promise<Response> =>
|
|
// Corpo da resposta deliberadamente com campos que poderiam parecer
|
|
// um id/status "óbvios" — o teste garante que NADA disso é lido.
|
|
jsonResponse(201, {
|
|
id: "id-do-zpro-nao-deve-aparecer",
|
|
status: "confirmed",
|
|
});
|
|
const adapter = new ZProAdapter(config, { fetchImpl });
|
|
|
|
const appointment = await adapter.agendar(baseInput);
|
|
|
|
assert.equal(appointment.responseMappingPending, true);
|
|
assert.equal(appointment.id, undefined);
|
|
assert.equal(appointment.slot.id, undefined);
|
|
// O status é o que FOI ENVIADO ("pending"), não o que a API retornou
|
|
// ("confirmed") — não lemos o corpo da resposta.
|
|
assert.equal(appointment.status, "pending");
|
|
});
|
|
|
|
it("constrói slot/patient apenas a partir do input (nunca da resposta)", async () => {
|
|
const fetchImpl = async (): Promise<Response> =>
|
|
jsonResponse(201, { anything: true });
|
|
const adapter = new ZProAdapter(config, { fetchImpl });
|
|
const input: CreateAppointmentInput = {
|
|
...baseInput,
|
|
patient: { id: "contact-1", name: "Maria Souza", phone: "5511999999999" },
|
|
};
|
|
|
|
const appointment = await adapter.agendar(input);
|
|
|
|
assert.deepEqual(appointment.slot, {
|
|
startDateTime: input.startAt,
|
|
endDateTime: input.endAt,
|
|
});
|
|
assert.deepEqual(appointment.patient, input.patient);
|
|
});
|
|
});
|
|
|
|
describe("ZProAdapter.agendar — mapeamento real da resposta (envelope verificado)", () => {
|
|
it("mapeia id/startAt/endAt/status reais quando o corpo bate com o envelope verificado", async () => {
|
|
const fetchImpl = async (): Promise<Response> =>
|
|
jsonResponse(201, {
|
|
success: true,
|
|
data: {
|
|
id: 2,
|
|
title: "TESTE-MCP-2026-04-20-001",
|
|
contactName: "Paciente Teste MCP",
|
|
startAt: "2026-04-20T14:00:00.000Z",
|
|
endAt: "2026-04-20T14:30:00.000Z",
|
|
status: "pending",
|
|
createdAt: "2026-08-09T20:32:06.426Z",
|
|
},
|
|
});
|
|
const adapter = new ZProAdapter(config, { fetchImpl });
|
|
|
|
const appointment = await adapter.agendar(baseInput);
|
|
|
|
assert.equal(appointment.id, "2");
|
|
assert.equal(appointment.responseMappingPending, undefined);
|
|
assert.equal(appointment.status, "pending");
|
|
assert.deepEqual(appointment.slot, {
|
|
startDateTime: "2026-04-20T14:00:00.000Z",
|
|
endDateTime: "2026-04-20T14:30:00.000Z",
|
|
});
|
|
// patient continua vindo do input, nunca do corpo da resposta.
|
|
assert.deepEqual(appointment.patient, baseInput.patient);
|
|
});
|
|
|
|
it("mapeia status 'confirmed' e 'cancelled' quando retornados", async () => {
|
|
for (const status of ["confirmed", "cancelled"] as const) {
|
|
const fetchImpl = async (): Promise<Response> =>
|
|
jsonResponse(200, {
|
|
success: true,
|
|
data: {
|
|
id: 9,
|
|
startAt: baseInput.startAt,
|
|
endAt: baseInput.endAt,
|
|
status,
|
|
},
|
|
});
|
|
const adapter = new ZProAdapter(config, { fetchImpl });
|
|
|
|
const appointment = await adapter.agendar(baseInput);
|
|
assert.equal(appointment.status, status);
|
|
}
|
|
});
|
|
|
|
it("cai em 'pending' para um status desconhecido (nunca inventa 'confirmed')", async () => {
|
|
const fetchImpl = async (): Promise<Response> =>
|
|
jsonResponse(200, {
|
|
success: true,
|
|
data: {
|
|
id: 9,
|
|
startAt: baseInput.startAt,
|
|
endAt: baseInput.endAt,
|
|
status: "completed",
|
|
},
|
|
});
|
|
const adapter = new ZProAdapter(config, { fetchImpl });
|
|
|
|
const appointment = await adapter.agendar(baseInput);
|
|
assert.equal(appointment.status, "pending");
|
|
});
|
|
|
|
it("cai no mapeamento pendente quando success:true mas data.id não é numérico", async () => {
|
|
const fetchImpl = async (): Promise<Response> =>
|
|
jsonResponse(200, {
|
|
success: true,
|
|
data: {
|
|
id: "não-numérico",
|
|
startAt: baseInput.startAt,
|
|
endAt: baseInput.endAt,
|
|
status: "pending",
|
|
},
|
|
});
|
|
const adapter = new ZProAdapter(config, { fetchImpl });
|
|
|
|
const appointment = await adapter.agendar(baseInput);
|
|
assert.equal(appointment.responseMappingPending, true);
|
|
assert.equal(appointment.id, undefined);
|
|
});
|
|
|
|
it("cai no mapeamento pendente quando success:false", async () => {
|
|
const fetchImpl = async (): Promise<Response> =>
|
|
// HTTP 2xx com success:false no corpo é incomum, mas o status HTTP
|
|
// continua sendo a fonte da verdade sobre erro/sucesso operacional
|
|
// (ver docblock do adapter) — o corpo só é usado para enriquecer o
|
|
// mapeamento quando bate exatamente com o envelope esperado.
|
|
jsonResponse(200, { success: false, error: "algo não documentado" });
|
|
const adapter = new ZProAdapter(config, { fetchImpl });
|
|
|
|
const appointment = await adapter.agendar(baseInput);
|
|
assert.equal(appointment.responseMappingPending, true);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Cobertura explícita pedida: qualquer HTTP 2xx deve ser tratado como
|
|
* sucesso operacional, independentemente do formato/ausência do corpo, e
|
|
* um erro não-2xx deve continuar sendo tratado como erro.
|
|
*/
|
|
describe("ZProAdapter.agendar — qualquer HTTP 2xx é sucesso, seja qual for o corpo", () => {
|
|
const scenarios: Array<{
|
|
label: string;
|
|
status: number;
|
|
contentType: string | undefined;
|
|
body: string;
|
|
}> = [
|
|
{
|
|
label: "200 com JSON",
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({ id: "x", status: "confirmed" }),
|
|
},
|
|
{
|
|
label: "200 com texto simples",
|
|
status: 200,
|
|
contentType: "text/plain",
|
|
body: "OK",
|
|
},
|
|
{
|
|
label: "200 com corpo vazio",
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: "",
|
|
},
|
|
{
|
|
label: "201 com JSON",
|
|
status: 201,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({ appointmentId: 123 }),
|
|
},
|
|
{
|
|
label: "204 sem corpo",
|
|
status: 204,
|
|
contentType: undefined,
|
|
body: "",
|
|
},
|
|
{
|
|
label: "2xx com Content-Type application/json e JSON malformado",
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: "{ isto não é json válido",
|
|
},
|
|
];
|
|
|
|
for (const scenario of scenarios) {
|
|
it(`${scenario.label}: NÃO lança erro e retorna Appointment com responseMappingPending`, async () => {
|
|
const fetchImpl = async (): Promise<Response> =>
|
|
// `Response` nativo não aceita corpo (nem string vazia) em status
|
|
// "null body" como 204 — usar `null` nesses casos.
|
|
new Response(scenario.body === "" ? null : scenario.body, {
|
|
status: scenario.status,
|
|
headers: scenario.contentType
|
|
? { "content-type": scenario.contentType }
|
|
: {},
|
|
});
|
|
const adapter = new ZProAdapter(config, { fetchImpl });
|
|
|
|
// Se agendar() lançar aqui, o teste falha — é o comportamento antigo
|
|
// que este requisito elimina.
|
|
const appointment = await adapter.agendar(baseInput);
|
|
|
|
assert.equal(appointment.responseMappingPending, true);
|
|
assert.equal(appointment.id, undefined);
|
|
assert.equal(appointment.status, "pending");
|
|
assert.deepEqual(appointment.slot, {
|
|
startDateTime: baseInput.startAt,
|
|
endDateTime: baseInput.endAt,
|
|
});
|
|
});
|
|
}
|
|
|
|
it("erro não-2xx continua sendo tratado como erro (sem regressão)", async () => {
|
|
const fetchImpl = async (): Promise<Response> =>
|
|
jsonResponse(500, { message: "erro interno do Z-PRO" });
|
|
const adapter = new ZProAdapter(config, { fetchImpl });
|
|
|
|
await expectCode(adapter.agendar(baseInput), "ZPRO_API_ERROR");
|
|
});
|
|
});
|
|
|
|
describe("describeResponseMetadata — metadados seguros de diagnóstico", () => {
|
|
it("classifica corpo vazio", () => {
|
|
const metadata = describeResponseMetadata({
|
|
status: 204,
|
|
contentType: null,
|
|
bodyText: "",
|
|
});
|
|
|
|
assert.deepEqual(metadata, {
|
|
status: 204,
|
|
contentType: null,
|
|
hasBody: false,
|
|
bodyFormat: "empty",
|
|
});
|
|
});
|
|
|
|
it("classifica corpo JSON válido", () => {
|
|
const metadata = describeResponseMetadata({
|
|
status: 201,
|
|
contentType: "application/json",
|
|
bodyText: JSON.stringify({ patient: { name: "Nome Sensível" } }),
|
|
});
|
|
|
|
assert.equal(metadata.bodyFormat, "json");
|
|
assert.equal(metadata.hasBody, true);
|
|
// Nenhum dado do corpo (ex.: nome do paciente) vaza para os metadados.
|
|
assert.ok(!JSON.stringify(metadata).includes("Nome Sensível"));
|
|
});
|
|
|
|
it("classifica corpo não JSON (texto simples ou JSON malformado)", () => {
|
|
assert.equal(
|
|
describeResponseMetadata({
|
|
status: 200,
|
|
contentType: "text/plain",
|
|
bodyText: "OK",
|
|
}).bodyFormat,
|
|
"non-json",
|
|
);
|
|
assert.equal(
|
|
describeResponseMetadata({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
bodyText: "{ inválido",
|
|
}).bodyFormat,
|
|
"non-json",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("ZProAdapter — hook onResponseMetadata (diagnóstico opcional)", () => {
|
|
it("recebe apenas metadados seguros, nunca o corpo, token ou dados do paciente", async () => {
|
|
const fetchImpl = async (): Promise<Response> =>
|
|
new Response(
|
|
JSON.stringify({ id: "x", patient: { name: "Nome Sensível" } }),
|
|
{
|
|
status: 201,
|
|
headers: { "content-type": "application/json" },
|
|
},
|
|
);
|
|
|
|
let received: unknown;
|
|
const adapter = new ZProAdapter(config, {
|
|
fetchImpl,
|
|
onResponseMetadata: (metadata) => {
|
|
received = metadata;
|
|
},
|
|
});
|
|
|
|
await adapter.agendar(baseInput);
|
|
|
|
assert.deepEqual(received, {
|
|
status: 201,
|
|
contentType: "application/json",
|
|
hasBody: true,
|
|
bodyFormat: "json",
|
|
});
|
|
assert.ok(!JSON.stringify(received).includes("Nome Sensível"));
|
|
assert.ok(!JSON.stringify(received).includes(config.apiToken));
|
|
});
|
|
|
|
it("também é chamado em respostas de erro (não só em sucesso)", async () => {
|
|
const fetchImpl = async (): Promise<Response> =>
|
|
jsonResponse(500, { message: "erro" });
|
|
|
|
let called = false;
|
|
const adapter = new ZProAdapter(config, {
|
|
fetchImpl,
|
|
onResponseMetadata: () => {
|
|
called = true;
|
|
},
|
|
});
|
|
|
|
await assert.rejects(adapter.agendar(baseInput));
|
|
assert.equal(called, true);
|
|
});
|
|
|
|
it("por padrão (sem o hook), não afeta o comportamento normal", async () => {
|
|
const fetchImpl = async (): Promise<Response> =>
|
|
jsonResponse(201, { id: "x" });
|
|
const adapter = new ZProAdapter(config, { fetchImpl });
|
|
|
|
const appointment = await adapter.agendar(baseInput);
|
|
|
|
assert.equal(appointment.responseMappingPending, true);
|
|
});
|
|
});
|