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:
@@ -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;
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user