Files
KLINGO-CLINICAL-SYSLIFE-GCHAT/gti-scheduling-mcp/test/zpro-adapter.test.ts
T
GustavoandClaude Sonnet 5 257afe98c0 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>
2026-08-10 17:23:10 -03:00

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);
});
});