/** * 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, code: string, ): Promise { 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 => { 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 = {}; let capturedBody: unknown; const fetchImpl = async ( url: RequestInfo | URL, init?: RequestInit, ): Promise => { 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 => { 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 => { 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 = {}; const fetchImpl = async ( _url: RequestInfo | URL, init?: RequestInit, ): Promise => { 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 => 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 => { 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 => { 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> = [ 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 => // 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 => 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 => 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 => 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 => 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 => 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 => // 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` 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 => 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 => 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 => 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 => jsonResponse(201, { id: "x" }); const adapter = new ZProAdapter(config, { fetchImpl }); const appointment = await adapter.agendar(baseInput); assert.equal(appointment.responseMappingPending, true); }); });