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:
Gustavo
2026-08-10 17:23:10 -03:00
co-authored by Claude Sonnet 5
commit 257afe98c0
48 changed files with 12465 additions and 0 deletions
@@ -0,0 +1,52 @@
/**
* test/adapter-factory.test.ts
*
* Testes unitários para a AdapterFactory.
*/
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { createSchedulingAdapter } from "../src/adapters/adapter-factory.js";
import { ZProAdapter } from "../src/adapters/zpro/zpro.adapter.js";
import { KlingoAdapter } from "../src/adapters/klingo/klingo.adapter.js";
import type { SchedulingProviderConfig } from "../src/config/config.types.js";
describe("createSchedulingAdapter", () => {
it("cria um ZProAdapter quando o provider é 'zpro'", () => {
const adapter = createSchedulingAdapter({
provider: "zpro",
baseUrl: "https://zpro.example.com",
apiToken: "token-secreto",
apiId: "api-1",
});
assert.ok(adapter instanceof ZProAdapter);
});
it("cria um KlingoAdapter quando o provider é 'klingo'", () => {
const adapter = createSchedulingAdapter({
provider: "klingo",
baseUrl: "https://klingo.example.com",
appToken: "app-token-secreto",
});
assert.ok(adapter instanceof KlingoAdapter);
});
it("rejeita um provider desconhecido", () => {
const unsupported = {
provider: "syslife",
baseUrl: "https://syslife.example.com",
} as unknown as SchedulingProviderConfig;
assert.throws(
() => createSchedulingAdapter(unsupported),
(error: unknown) => {
assert.ok(error instanceof Error);
assert.match(error.message, /não suportado/);
return true;
},
);
});
});
+106
View File
@@ -0,0 +1,106 @@
/**
* test/analyze.test.ts
*
* Testes unitários para a identificação da estrutura de uma resposta JSON.
*/
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { analyzeStructure } from "../src/diagnostics/analyze.js";
describe("analyzeStructure", () => {
it("descreve a raiz quando é um objeto", () => {
const summary = analyzeStructure({
data: [],
message: "ok",
});
assert.equal(summary.rootType, "object");
assert.deepEqual(summary.rootKeys.sort(), ["data", "message"]);
assert.equal(summary.arrayItemCount, undefined);
});
it("descreve um array: quantidade de itens, chaves e tipos do primeiro item", () => {
const summary = analyzeStructure([
{
id: "app-1",
status: "confirmed",
patient: { name: "Maria" },
startAt: "2026-04-15T14:00:00.000Z",
},
]);
assert.equal(summary.rootType, "array");
assert.equal(summary.arrayItemCount, 1);
assert.deepEqual(summary.firstItemKeys.sort(), [
"id",
"patient",
"startAt",
"status",
]);
assert.deepEqual(summary.fieldTypes, {
id: "string",
status: "string",
patient: "object",
startAt: "string",
});
});
it("detecta possíveis campos de paginação", () => {
const summary = analyzeStructure({
page: 1,
limit: 20,
totalCount: 42,
items: [],
});
assert.deepEqual(summary.possiblePaginationFields.sort(), [
"limit",
"page",
"totalCount",
]);
});
it("detecta possíveis campos de identificação", () => {
const summary = analyzeStructure({
id: "x",
appointmentId: "app-1",
uuid: "abc",
createdAt: "2026-04-15T14:00:00.000Z",
});
assert.deepEqual(summary.possibleIdFields.sort(), [
"appointmentId",
"id",
"uuid",
]);
});
it("detecta possíveis campos de data e de status", () => {
const summary = analyzeStructure({
createdAt: "2026-04-15T14:00:00.000Z",
startAt: "2026-04-15T14:00:00.000Z",
status: "pending",
state: "active",
});
assert.deepEqual(summary.possibleDateFields.sort(), [
"createdAt",
"startAt",
]);
assert.deepEqual(summary.possibleStatusFields.sort(), ["state", "status"]);
});
it("não inventa campos que não existem na resposta", () => {
const summary = analyzeStructure({
id: "x",
status: "ok",
});
assert.deepEqual(summary.possiblePaginationFields, []);
assert.deepEqual(summary.possibleDateFields, []);
assert.equal(summary.arrayItemCount, undefined);
assert.deepEqual(summary.firstItemKeys.sort(), ["id", "status"]);
});
});
+172
View File
@@ -0,0 +1,172 @@
/**
* test/config.test.ts
*
* Testes unitários para a validação da configuração carregada por
* variáveis de ambiente (src/config/env.config.ts).
*/
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { loadSchedulingConfig } from "../src/config/env.config.js";
function env(
overrides: Record<string, string | undefined> = {},
): NodeJS.ProcessEnv {
return {
SCHEDULING_PROVIDER: "zpro",
ZPRO_BASE_URL: "https://zpro.example.com",
ZPRO_API_TOKEN: "token-secreto",
ZPRO_API_ID: "api-1",
ZPRO_INSTANCE_ID: "",
...overrides,
} as NodeJS.ProcessEnv;
}
describe("loadSchedulingConfig", () => {
it("carrega uma configuração válida do Z-PRO", () => {
const config = loadSchedulingConfig(
env({ ZPRO_INSTANCE_ID: "instancia-1" }),
);
assert.deepEqual(config, {
provider: "zpro",
baseUrl: "https://zpro.example.com",
apiToken: "token-secreto",
apiId: "api-1",
instanceId: "instancia-1",
});
});
it("define o provider como 'zpro' por padrão", () => {
const withoutProvider = env();
delete withoutProvider.SCHEDULING_PROVIDER;
const config = loadSchedulingConfig(withoutProvider);
assert.equal(config.provider, "zpro");
});
it("trata ZPRO_INSTANCE_ID vazio como ausente", () => {
const config = loadSchedulingConfig(env());
assert.equal(config.instanceId, undefined);
});
it("falha quando ZPRO_BASE_URL está ausente", () => {
const missingBaseUrl = env();
delete missingBaseUrl.ZPRO_BASE_URL;
assert.throws(
() => loadSchedulingConfig(missingBaseUrl),
(error: unknown) => {
assert.ok(error instanceof Error);
assert.match(error.message, /ZPRO_BASE_URL/);
return true;
},
);
});
it("falha quando ZPRO_API_TOKEN está ausente", () => {
const missingToken = env();
delete missingToken.ZPRO_API_TOKEN;
assert.throws(
() => loadSchedulingConfig(missingToken),
(error: unknown) => {
assert.ok(error instanceof Error);
assert.match(error.message, /ZPRO_API_TOKEN/);
return true;
},
);
});
it("falha quando ZPRO_API_ID está ausente", () => {
const missingApiId = env();
delete missingApiId.ZPRO_API_ID;
assert.throws(
() => loadSchedulingConfig(missingApiId),
(error: unknown) => {
assert.ok(error instanceof Error);
assert.match(error.message, /ZPRO_API_ID/);
return true;
},
);
});
it("não expõe o token em mensagens de erro", () => {
const missingBaseUrl = env();
delete missingBaseUrl.ZPRO_BASE_URL;
assert.throws(
() => loadSchedulingConfig(missingBaseUrl),
(error: unknown) => {
assert.ok(error instanceof Error);
assert.ok(!error.message.includes("token-secreto"));
return true;
},
);
});
it("rejeita um provider desconhecido", () => {
assert.throws(
() => loadSchedulingConfig(env({ SCHEDULING_PROVIDER: "syslife" })),
(error: unknown) => {
assert.ok(error instanceof Error);
assert.match(error.message, /SCHEDULING_PROVIDER/);
return true;
},
);
});
it("não exige variáveis ZPRO_* quando o provider é 'klingo'", () => {
const config = loadSchedulingConfig({
SCHEDULING_PROVIDER: "klingo",
KLINGO_BASE_URL: "https://klingo.example.com",
KLINGO_APP_TOKEN: "app-token-secreto",
} as NodeJS.ProcessEnv);
assert.deepEqual(config, {
provider: "klingo",
baseUrl: "https://klingo.example.com",
appToken: "app-token-secreto",
});
});
it("falha quando KLINGO_BASE_URL está ausente com provider 'klingo'", () => {
assert.throws(
() =>
loadSchedulingConfig({
SCHEDULING_PROVIDER: "klingo",
KLINGO_APP_TOKEN: "app-token-secreto",
} as NodeJS.ProcessEnv),
(error: unknown) => {
assert.ok(error instanceof Error);
assert.match(error.message, /KLINGO_BASE_URL/);
return true;
},
);
});
it("falha quando KLINGO_APP_TOKEN está ausente com provider 'klingo'", () => {
assert.throws(
() =>
loadSchedulingConfig({
SCHEDULING_PROVIDER: "klingo",
KLINGO_BASE_URL: "https://klingo.example.com",
} as NodeJS.ProcessEnv),
(error: unknown) => {
assert.ok(error instanceof Error);
assert.match(error.message, /KLINGO_APP_TOKEN/);
return true;
},
);
});
it("não exige variáveis KLINGO_* quando o provider é 'zpro'", () => {
const config = loadSchedulingConfig(env());
assert.equal(config.provider, "zpro");
});
});
@@ -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);
});
});
@@ -0,0 +1,431 @@
/**
* test/klingo-adapter.test.ts
*
* Testes unitários do KlingoAdapter, com mocks de `fetch` (nenhuma chamada
* de rede real). Espelha o padrão de `test/zpro-adapter.test.ts`.
*/
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { KlingoAdapter } from "../src/adapters/klingo/klingo.adapter.js";
import type { KlingoConfig } from "../src/config/config.types.js";
import type {
CreateAppointmentInput,
QueryAvailableSlotsInput,
} from "../src/types/scheduling.types.js";
const config: KlingoConfig = {
provider: "klingo",
baseUrl: "https://klingo.example.com",
appToken: "app-token-secreto",
};
function jsonResponse(status: number, body: unknown): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
});
}
function rawResponse(
status: number,
body: string | null,
contentType?: string,
): Response {
return new Response(body, {
status,
headers: contentType ? { "content-type": contentType } : undefined,
});
}
const validPatient = {
name: "Paciente Teste",
phone: "11999999999",
document: "12345678900",
};
const baseAgendarInput: CreateAppointmentInput = {
clinicId: "clinica-1",
slotId: "2026-04-20|71|2|1|18:30",
specialtyId: "CONS",
startAt: "2026-04-20T18:30:00.000Z",
endAt: "2026-04-20T19:00:00.000Z",
patient: validPatient,
};
const sessaoBody = {
access_token: "sessao-token-abc",
token_type: "bearer",
expires_in: 360000,
};
describe("KlingoAdapter.consultarHorarios", () => {
it("lança SLOT_MAPPING_UNAVAILABLE quando a API responde com sucesso", async () => {
const calls: { url: string; init: RequestInit }[] = [];
const fetchImpl = (async (url: string, init: RequestInit) => {
calls.push({ url: String(url), init });
return jsonResponse(200, { procedimento: "CONS", horarios: [] });
}) as unknown as typeof fetch;
const adapter = new KlingoAdapter(config, { fetchImpl });
const input: QueryAvailableSlotsInput = {
clinicId: "clinica-1",
specialtyId: "CONS",
};
await assert.rejects(
() => adapter.consultarHorarios(input),
(error: unknown) => {
assert.ok(error instanceof Error);
assert.equal(
(error as { code?: string }).code,
"SLOT_MAPPING_UNAVAILABLE",
);
return true;
},
);
assert.equal(calls.length, 1);
assert.match(calls[0].url, /\/agenda\/horarios\?especialidade=CONS/);
const headers = calls[0].init.headers as Record<string, string>;
assert.equal(headers["X-APP-TOKEN"], "app-token-secreto");
});
it("mapeia HTTP 401 para AUTHENTICATION_ERROR sem tentar interpretar o corpo", async () => {
const fetchImpl = (async () =>
rawResponse(401, "unauthorized")) as unknown as typeof fetch;
const adapter = new KlingoAdapter(config, { fetchImpl });
await assert.rejects(
() => adapter.consultarHorarios({ clinicId: "clinica-1" }),
(error: unknown) => {
assert.ok(error instanceof Error);
assert.equal((error as { code?: string }).code, "AUTHENTICATION_ERROR");
return true;
},
);
});
it("monta os query params a partir do input genérico", async () => {
let capturedUrl = "";
const fetchImpl = (async (url: string) => {
capturedUrl = String(url);
return jsonResponse(200, { horarios: [] });
}) as unknown as typeof fetch;
const adapter = new KlingoAdapter(config, { fetchImpl });
await assert.rejects(() =>
adapter.consultarHorarios({
clinicId: "12345",
professionalId: "CRM-1",
specialtyId: "CONS",
startDate: "2026-04-20",
endDate: "2026-04-25",
}),
);
assert.match(capturedUrl, /especialidade=CONS/);
assert.match(capturedUrl, /crm=CRM-1/);
assert.match(capturedUrl, /cnes=12345/);
assert.match(capturedUrl, /inicio=2026-04-20/);
assert.match(capturedUrl, /fim=2026-04-25/);
});
});
describe("KlingoAdapter.agendar — validação local (sem chamada de rede)", () => {
it("lança VALIDATION_ERROR quando patient.phone está ausente", async () => {
const fetchImpl = (async () => {
throw new Error("não deveria chamar a rede");
}) as unknown as typeof fetch;
const adapter = new KlingoAdapter(config, { fetchImpl });
await assert.rejects(
() =>
adapter.agendar({
...baseAgendarInput,
patient: { name: "Sem Telefone" },
}),
(error: unknown) => {
assert.ok(error instanceof Error);
assert.equal((error as { code?: string }).code, "VALIDATION_ERROR");
assert.match(error.message, /telefone/);
return true;
},
);
});
it("lança VALIDATION_ERROR quando slotId está ausente (após identificar)", async () => {
const fetchImpl = (async () =>
jsonResponse(200, sessaoBody)) as unknown as typeof fetch;
const adapter = new KlingoAdapter(config, { fetchImpl });
await assert.rejects(
() => adapter.agendar({ ...baseAgendarInput, slotId: undefined }),
(error: unknown) => {
assert.ok(error instanceof Error);
assert.equal((error as { code?: string }).code, "VALIDATION_ERROR");
assert.match(error.message, /slotId/);
return true;
},
);
});
it("lança VALIDATION_ERROR quando specialtyId está ausente (após identificar)", async () => {
const fetchImpl = (async () =>
jsonResponse(200, sessaoBody)) as unknown as typeof fetch;
const adapter = new KlingoAdapter(config, { fetchImpl });
await assert.rejects(
() => adapter.agendar({ ...baseAgendarInput, specialtyId: undefined }),
(error: unknown) => {
assert.ok(error instanceof Error);
assert.equal((error as { code?: string }).code, "VALIDATION_ERROR");
assert.match(error.message, /specialtyId/);
return true;
},
);
});
});
describe("KlingoAdapter.agendar — identificação do paciente", () => {
it("envia telefone + cpf (X-APP-TOKEN) e usa o Bearer retornado na reserva", async () => {
const calls: { url: string; init: RequestInit }[] = [];
const fetchImpl = (async (url: string, init: RequestInit) => {
calls.push({ url: String(url), init });
if (String(url).includes("/paciente/identificar")) {
return jsonResponse(200, sessaoBody);
}
return jsonResponse(201, {
id: 555,
data: "2026-04-20",
hora: "18:30",
profissional: { id: 9, nome: "Dr. Teste" },
});
}) as unknown as typeof fetch;
const adapter = new KlingoAdapter(config, { fetchImpl });
await adapter.agendar(baseAgendarInput);
assert.equal(calls.length, 2);
assert.match(calls[0].url, /\/paciente\/identificar$/);
const identHeaders = calls[0].init.headers as Record<string, string>;
assert.equal(identHeaders["X-APP-TOKEN"], "app-token-secreto");
const identBody = JSON.parse(calls[0].init.body as string);
assert.equal(identBody.telefone, "11999999999");
assert.equal(identBody.cpf, "12345678900");
assert.equal(identBody.apenas_telefone, undefined);
assert.match(calls[1].url, /\/agenda\/reservar$/);
const reservarHeaders = calls[1].init.headers as Record<string, string>;
assert.equal(reservarHeaders.Authorization, "Bearer sessao-token-abc");
});
it("usa apenas_telefone quando document está ausente", async () => {
const calls: { url: string; init: RequestInit }[] = [];
const fetchImpl = (async (url: string, init: RequestInit) => {
calls.push({ url: String(url), init });
if (String(url).includes("/paciente/identificar")) {
return jsonResponse(200, sessaoBody);
}
return jsonResponse(201, { id: 1 });
}) as unknown as typeof fetch;
const adapter = new KlingoAdapter(config, { fetchImpl });
await adapter.agendar({
...baseAgendarInput,
patient: { name: "Sem Documento", phone: "11988887777" },
});
const identBody = JSON.parse(calls[0].init.body as string);
assert.equal(identBody.cpf, undefined);
assert.equal(identBody.apenas_telefone, true);
});
for (const status of [401, 403, 404, 400, 422, 500]) {
it(`mapeia HTTP ${status} de identificar() para o código correto`, async () => {
const fetchImpl = (async () =>
rawResponse(status, "erro")) as unknown as typeof fetch;
const adapter = new KlingoAdapter(config, { fetchImpl });
await assert.rejects(
() => adapter.agendar(baseAgendarInput),
(error: unknown) => {
assert.ok(error instanceof Error);
const code = (error as { code?: string }).code;
if (status === 401 || status === 403)
assert.equal(code, "AUTHENTICATION_ERROR");
else if (status === 404) assert.equal(code, "NOT_FOUND");
else if (status === 400 || status === 422)
assert.equal(code, "VALIDATION_ERROR");
else assert.equal(code, "KLINGO_API_ERROR");
return true;
},
);
});
}
it("lança AUTHENTICATION_ERROR quando identificar responde 2xx sem access_token", async () => {
const fetchImpl = (async () =>
jsonResponse(200, { foo: "bar" })) as unknown as typeof fetch;
const adapter = new KlingoAdapter(config, { fetchImpl });
await assert.rejects(
() => adapter.agendar(baseAgendarInput),
(error: unknown) => {
assert.ok(error instanceof Error);
assert.equal((error as { code?: string }).code, "AUTHENTICATION_ERROR");
return true;
},
);
});
it("não vaza o app token nem o access_token em mensagens de erro", async () => {
const fetchImpl = (async () => {
throw new Error("network down");
}) as unknown as typeof fetch;
const adapter = new KlingoAdapter(config, { fetchImpl });
await assert.rejects(
() => adapter.agendar(baseAgendarInput),
(error: unknown) => {
assert.ok(error instanceof Error);
assert.ok(!error.message.includes("app-token-secreto"));
assert.ok(!error.message.includes("sessao-token-abc"));
return true;
},
);
});
});
describe("KlingoAdapter.agendar — reserva (mapeamento de sucesso verificado)", () => {
it("constrói o Appointment a partir da resposta documentada de reserva", async () => {
const fetchImpl = (async (url: string) => {
if (String(url).includes("/paciente/identificar")) {
return jsonResponse(200, sessaoBody);
}
return jsonResponse(201, {
id: 777,
expira: "2026-04-20 18:40",
data: "2026-04-20",
hora: "18:30",
profissional: { id: 42, nome: "Dra. Exemplo" },
});
}) as unknown as typeof fetch;
const adapter = new KlingoAdapter(config, { fetchImpl });
const appointment = await adapter.agendar(baseAgendarInput);
assert.equal(appointment.id, "777");
assert.equal(appointment.status, "confirmed");
assert.equal(appointment.responseMappingPending, undefined);
assert.equal(appointment.slot.professionalId, "42");
assert.equal(appointment.slot.professionalName, "Dra. Exemplo");
assert.equal(appointment.slot.startDateTime, baseAgendarInput.startAt);
assert.equal(appointment.slot.endDateTime, baseAgendarInput.endAt);
assert.deepEqual(appointment.patient, validPatient);
});
it("envia confirmado preenchido (marcação definitiva, não só reserva temporária)", async () => {
const calls: { url: string; init: RequestInit }[] = [];
const fetchImpl = (async (url: string, init: RequestInit) => {
calls.push({ url: String(url), init });
if (String(url).includes("/paciente/identificar")) {
return jsonResponse(200, sessaoBody);
}
return jsonResponse(201, { id: 1 });
}) as unknown as typeof fetch;
const adapter = new KlingoAdapter(config, { fetchImpl });
await adapter.agendar(baseAgendarInput);
const reservarBody = JSON.parse(calls[1].init.body as string);
assert.equal(reservarBody.procedimento, "CONS");
assert.equal(reservarBody.id, baseAgendarInput.slotId);
assert.ok(
typeof reservarBody.confirmado === "string" &&
reservarBody.confirmado.length > 0,
);
});
for (const status of [401, 403, 404, 400, 422, 500]) {
it(`mapeia HTTP ${status} de reservar() para o código correto`, async () => {
const fetchImpl = (async (url: string) => {
if (String(url).includes("/paciente/identificar")) {
return jsonResponse(200, sessaoBody);
}
return rawResponse(status, "erro");
}) as unknown as typeof fetch;
const adapter = new KlingoAdapter(config, { fetchImpl });
await assert.rejects(
() => adapter.agendar(baseAgendarInput),
(error: unknown) => {
assert.ok(error instanceof Error);
const code = (error as { code?: string }).code;
if (status === 401 || status === 403)
assert.equal(code, "AUTHENTICATION_ERROR");
else if (status === 404) assert.equal(code, "NOT_FOUND");
else if (status === 400 || status === 422)
assert.equal(code, "VALIDATION_ERROR");
else assert.equal(code, "KLINGO_API_ERROR");
return true;
},
);
});
}
});
describe("KlingoAdapter.agendar — qualquer HTTP 2xx de reservar é sucesso", () => {
const scenarios: {
name: string;
status: number;
body: string | null;
contentType?: string;
}[] = [
{
name: "200 com JSON sem id numérico",
status: 200,
body: '{"status":"ok"}',
contentType: "application/json",
},
{
name: "200 com texto simples",
status: 200,
body: "OK",
contentType: "text/plain",
},
{ name: "200 com corpo vazio", status: 200, body: "" },
{ name: "204 sem corpo", status: 204, body: null },
{
name: "2xx com JSON malformado",
status: 200,
body: "{invalid",
contentType: "application/json",
},
];
for (const scenario of scenarios) {
it(`${scenario.name}: NÃO lança erro, retorna Appointment com responseMappingPending`, async () => {
const fetchImpl = (async (url: string) => {
if (String(url).includes("/paciente/identificar")) {
return jsonResponse(200, sessaoBody);
}
return rawResponse(
scenario.status,
scenario.body,
scenario.contentType,
);
}) as unknown as typeof fetch;
const adapter = new KlingoAdapter(config, { fetchImpl });
const appointment = await adapter.agendar(baseAgendarInput);
assert.equal(appointment.responseMappingPending, true);
assert.equal(appointment.status, "pending");
assert.equal(appointment.id, undefined);
assert.equal(appointment.slot.startDateTime, baseAgendarInput.startAt);
assert.deepEqual(appointment.patient, validPatient);
});
}
});
+129
View File
@@ -0,0 +1,129 @@
/**
* test/sanitize.test.ts
*
* Testes unitários para a sanitização recursiva de respostas.
*/
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import {
REDACTED,
isSensitiveField,
sanitizeRecursively,
sanitizeText,
} from "../src/diagnostics/sanitize.js";
describe("isSensitiveField", () => {
it("reconhece nomes de campos sensíveis ignorando maiúsculas/minúsculas", () => {
assert.equal(isSensitiveField("name"), true);
assert.equal(isSensitiveField("patientName"), true);
assert.equal(isSensitiveField("NOME"), true);
assert.equal(isSensitiveField("phone"), true);
assert.equal(isSensitiveField("telefone"), true);
assert.equal(isSensitiveField("email"), true);
assert.equal(isSensitiveField("cpf"), true);
assert.equal(isSensitiveField("document"), true);
assert.equal(isSensitiveField("documento"), true);
assert.equal(isSensitiveField("token"), true);
assert.equal(isSensitiveField("authorization"), true);
assert.equal(isSensitiveField("password"), true);
assert.equal(isSensitiveField("senha"), true);
assert.equal(isSensitiveField("address"), true);
assert.equal(isSensitiveField("endereco"), true);
});
it("não reconhece campos não sensíveis", () => {
assert.equal(isSensitiveField("id"), false);
assert.equal(isSensitiveField("appointmentId"), false);
assert.equal(isSensitiveField("startAt"), false);
assert.equal(isSensitiveField("status"), false);
assert.equal(isSensitiveField("page"), false);
});
});
describe("sanitizeRecursively", () => {
it("mascara valores de campos sensíveis", () => {
const sanitized = sanitizeRecursively({
name: "Maria dos Santos",
phone: "75999999999",
email: "maria@example.com",
});
assert.equal(sanitized.name, REDACTED);
assert.equal(sanitized.phone, REDACTED);
assert.equal(sanitized.email, REDACTED);
});
it("percorre objetos e arrays aninhados", () => {
const sanitized = sanitizeRecursively({
data: [
{
patient: { name: "João", cpf: "12345678900" },
appointmentId: "abc-123",
},
],
});
assert.equal(sanitized.data[0].patient.name, REDACTED);
assert.equal(sanitized.data[0].patient.cpf, REDACTED);
assert.equal(sanitized.data[0].appointmentId, "abc-123");
});
it("mantém campos não sensíveis intactos", () => {
const sanitized = sanitizeRecursively({
id: "app-1",
status: "confirmed",
page: 1,
});
assert.equal(sanitized.id, "app-1");
assert.equal(sanitized.status, "confirmed");
assert.equal(sanitized.page, 1);
});
it("redige ocorrências de segredos mesmo em campos não sensíveis", () => {
const token = "segredo-super-secreto";
const sanitized = sanitizeRecursively(
{
message: `token=${token}`,
data: "x",
},
[token],
);
assert.ok(!JSON.stringify(sanitized).includes(token));
assert.ok(JSON.stringify(sanitized).includes(REDACTED));
});
it("não vaza o token na saída sanitizada", () => {
const token = "token-real-xyz";
const sanitized = sanitizeRecursively(
{
data: [{ patient: { name: "Maria", phone: "75999999999" }, token }],
},
[token],
);
assert.ok(!JSON.stringify(sanitized).includes(token));
assert.ok(!JSON.stringify(sanitized).includes("75999999999"));
assert.ok(!JSON.stringify(sanitized).includes("Maria"));
});
it("lida com valores primitivos e null", () => {
assert.equal(sanitizeRecursively(null), null);
assert.equal(sanitizeRecursively("texto", ["zzz"]), "texto");
assert.equal(sanitizeRecursively(42), 42);
});
});
describe("sanitizeText", () => {
it("redige segredos e limita o comprimento", () => {
const long = "a".repeat(700);
const result = sanitizeText(`token=${long}`, [long]);
assert.ok(!result.includes(long));
assert.ok(result.length <= 500);
assert.ok(result.includes(REDACTED));
});
});
+100
View File
@@ -0,0 +1,100 @@
/**
* test/tools.test.ts
*
* Testes unitários para o tratamento de erros das ferramentas MCP.
*
* Os handlers devem capturar qualquer erro lançado pelo Adapter e devolver
* uma resposta MCP estruturada, sem encerrar o servidor e sem vazar
* detalhes de implementação (stack trace, nomes de classe internos,
* token). Testes de comportamento específico do ZProAdapter (mapeamento
* de requisição, classificação de erros HTTP) ficam em
* `test/zpro-adapter.test.ts`; aqui testamos apenas a camada da tool.
*/
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { ZProAdapter } from "../src/adapters/zpro/zpro.adapter.js";
import { createConsultarHorariosHandler } from "../src/tools/consultar-horarios.tool.js";
import { createAgendarHandler } from "../src/tools/agendar.tool.js";
import type { ZProConfig } from "../src/config/config.types.js";
const config: ZProConfig = {
provider: "zpro",
baseUrl: "https://zpro.example.com",
apiToken: "token-secreto",
apiId: "api-1",
};
describe("consultar_horarios", () => {
it("retorna resposta de erro estruturada quando o Z-PRO não suporta a consulta", async () => {
const adapter = new ZProAdapter(config);
const handler = createConsultarHorariosHandler(adapter);
const result = await handler({ clinicId: "clinic-1" });
const payload = JSON.parse(result.content[0].text);
assert.equal(payload.success, false);
assert.equal(payload.error.code, "INTERNAL_ERROR");
});
it("não retorna stack trace nem nomes de classe internos na resposta", async () => {
const adapter = new ZProAdapter(config);
const handler = createConsultarHorariosHandler(adapter);
const result = await handler({ clinicId: "clinic-1" });
assert.ok(!result.content[0].text.includes("at "));
assert.ok(!result.content[0].text.includes("ZProAdapter"));
assert.ok(!result.content[0].text.includes("ZProApiError"));
});
});
describe("agendar", () => {
const failingFetch = async (): Promise<Response> => {
throw new TypeError("fetch failed");
};
const agendarInput = {
clinicId: "clinic-1",
startAt: "2026-04-15T14:00:00.000Z",
endAt: "2026-04-15T15:00:00.000Z",
patient: { name: "Paciente Exemplo" },
};
it("retorna resposta de erro estruturada quando a chamada ao Z-PRO falha", async () => {
const adapter = new ZProAdapter(config, { fetchImpl: failingFetch });
const handler = createAgendarHandler(adapter);
const result = await handler(agendarInput);
const payload = JSON.parse(result.content[0].text);
assert.equal(payload.success, false);
assert.equal(payload.error.code, "INTERNAL_ERROR");
});
it("não vaza o token do Z-PRO na resposta de erro", async () => {
const adapter = new ZProAdapter(config, { fetchImpl: failingFetch });
const handler = createAgendarHandler(adapter);
const result = await handler(agendarInput);
assert.ok(!result.content[0].text.includes(config.apiToken));
});
it("retorna success:true quando o Z-PRO confirma a criação (HTTP 2xx) — não deve parecer falha", async () => {
const acceptingFetch = async (): Promise<Response> =>
new Response(JSON.stringify({ id: "algo" }), {
status: 201,
headers: { "content-type": "application/json" },
});
const adapter = new ZProAdapter(config, { fetchImpl: acceptingFetch });
const handler = createAgendarHandler(adapter);
const result = await handler(agendarInput);
const payload = JSON.parse(result.content[0].text);
assert.equal(payload.success, true);
assert.equal(payload.data.responseMappingPending, true);
assert.notEqual(result.isError, true);
});
});
@@ -0,0 +1,570 @@
/**
* 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);
});
});