Files
KLINGO-CLINICAL-SYSLIFE-GCHAT/gti-scheduling-mcp/test/klingo-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

432 lines
15 KiB
TypeScript

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