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