- 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>
432 lines
15 KiB
TypeScript
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);
|
|
});
|
|
}
|
|
});
|