mirror of
https://github.com/melgarafael/DeskcommCRM.git
synced 2026-10-02 01:28:34 +08:00
`followup-dossie.spec.ts:190` ficou vermelho no `e2e` do lote com "element(s)
not found" para o toast "Follow-up pausado.". A spec é byte a byte igual à da
`main` — `git diff origin/main...HEAD -- tests/e2e/followup-dossie.spec.ts` sai
vazio —, então o que mudou foi o produto.
## A medição
Trace do run 34876435491 (`playwright-report-parte-2`, `trace.zip`):
POST /api/v1/ai/followups/enrollments/…/pause
time 9999.558ms status -1 _failureText net::ERR_ABORTED 1 tentativa
9999,558ms é o `DEFAULT_TIMEOUT_MS` de `lib/api/client.ts` cravado: quem
desistiu foi o NAVEGADOR, não o servidor. O frame de screencast seguinte mostra
a tela com "Erro inesperado. Tente novamente." sobre um dossiê ainda escrito
"Ativo".
Não foi máquina lenta: dos 16 testes `followup-*` vizinhos no MESMO job, 15
correram mais rápido que no run verde da `main` 34878063927 (builder:414 9,9s
contra 11,7s; queue:153 8,9s contra 12,2s; dossie:288 11,4s contra 14,6s); o
único mais lento foi builder:287, por 1s. Foi uma paralisada isolada daquela
escrita.
Nem é determinístico: a mesma spec passou nos SHAs 461550b52 e 263e734d6 do
próprio lote, em 13,9s e 13,8s. O que o lote mudou não foi a paralisada — foi a
rede que a absorvia.
## A causa
O #787 parou de retentar método mutante, e a razão é certa — timeout numa
escrita é "não sei", e repetir executa a escrita de novo. O que passou
despercebido é que aquele retry também era, sem querer, o ORÇAMENTO DE ESPERA
de toda escrita do produto: 10s + backoff + 10s + backoff + 10s ≈ 30,6s de
parede. Tirar a repetição sem repor a espera cortou esse orçamento para 10s de
uma vez só, em todas as mutações da base.
O próprio #787 mediu esse teto ("Testar agente" leva 14,5s) e o levantou — mas
só no ponto onde doía, `TestPanel.tsx` com `timeoutMs: 120_000`. Conserto por
instância; a classe ficou com 10s.
## O conserto
`MUTATION_TIMEOUT_MS = 30_000`, aplicado quando o método é mutante e quem chama
não pediu outro prazo. Não é folga nova: é a mesma parede que a escrita já
tinha, entregue como UMA tentativa em vez de três. Leitura segue em 10s — ela
continua sendo repetida, então o orçamento dela não mudou, e desistir cedo numa
leitura não perde nada.
Esperar mais é a direção certa pelo mesmo motivo que não se repete: o servidor
não cancela nada quando o cliente desiste. Abortar aos 10s não impede a
gravação, só joga fora a resposta que estava a caminho.
## De quebra: um caso que passava pelo motivo errado
`t9b` chamava `apiClient.delete("/x", { timeoutMs: 5 })`, mas a assinatura é
`delete(path, body?, opts?)` — aquele objeto virava CORPO e o `opts` ficava
vazio. O caso rodava com o prazo padrão e passava porque 10s cabia no
`testTimeout` de 15s; com 30s ele estourou e denunciou a confusão. Passou a
`delete("/x", undefined, { timeoutMs: 5 })`, medindo o que dizia medir.
Guardas novos em `lib/api/client.test.ts`: t12 prende os 30s da escrita (e que
ela segue sem repetir), t13 prende os 10s da leitura. Sabotados um de cada vez,
com a contagem prevista antes: devolver a escrita a 10s reprova só t12; dar 30s
à leitura reprova só t13 — 1 falha, 17 verdes, as duas vezes.
Co-authored-by: 423313 <423313@users.noreply.github.com>
Co-authored-by: paulolimajr77 <paulolimajr77@users.noreply.github.com>
339 lines
14 KiB
TypeScript
339 lines
14 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
import { apiClient } from "@/lib/api/client";
|
|
import { ApiError } from "@/lib/api/types";
|
|
|
|
function jsonResponse(status: number, body: unknown, headers: Record<string, string> = {}): Response {
|
|
const text = typeof body === "string" ? body : JSON.stringify(body);
|
|
return new Response(text, {
|
|
status,
|
|
headers: { "Content-Type": "application/json", ...headers },
|
|
});
|
|
}
|
|
|
|
describe("apiClient", () => {
|
|
let fetchMock: ReturnType<typeof vi.fn>;
|
|
|
|
beforeEach(() => {
|
|
fetchMock = vi.fn();
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("t1: POST injects Idempotency-Key (uuid) and X-Request-Id headers", async () => {
|
|
fetchMock.mockResolvedValueOnce(jsonResponse(200, { data: { ok: true } }));
|
|
await apiClient.post("/x", { a: 1 });
|
|
const headers = fetchMock.mock.calls[0]![1].headers as Record<string, string>;
|
|
expect(headers["X-Request-Id"]).toMatch(
|
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,
|
|
);
|
|
expect(headers["Idempotency-Key"]).toMatch(
|
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,
|
|
);
|
|
});
|
|
|
|
it("t2: GET injects X-Request-Id but NOT Idempotency-Key", async () => {
|
|
fetchMock.mockResolvedValueOnce(jsonResponse(200, { data: { ok: true } }));
|
|
await apiClient.get("/x");
|
|
const headers = fetchMock.mock.calls[0]![1].headers as Record<string, string>;
|
|
expect(headers["X-Request-Id"]).toBeTruthy();
|
|
expect(headers["Idempotency-Key"]).toBeUndefined();
|
|
});
|
|
|
|
it("t3: 200 response returns parsed JSON", async () => {
|
|
fetchMock.mockResolvedValueOnce(jsonResponse(200, { data: { hello: "world" } }));
|
|
const result = await apiClient.get<{ data: { hello: string } }>("/x");
|
|
expect(result).toEqual({ data: { hello: "world" } });
|
|
});
|
|
|
|
it("t4: 422 response throws ApiError with status, code, and fieldErrors in details", async () => {
|
|
fetchMock.mockResolvedValueOnce(
|
|
jsonResponse(422, {
|
|
error: {
|
|
code: "validation_error",
|
|
message: "Validation failed",
|
|
details: { fieldErrors: { name: ["Required"] } },
|
|
},
|
|
}),
|
|
);
|
|
await expect(apiClient.post("/x", {})).rejects.toMatchObject({
|
|
status: 422,
|
|
code: "validation_error",
|
|
details: { fieldErrors: { name: ["Required"] } },
|
|
});
|
|
});
|
|
|
|
it("t5: 500 response throws ApiError immediately (no retry)", async () => {
|
|
fetchMock.mockResolvedValueOnce(
|
|
jsonResponse(500, { error: { code: "internal_error", message: "boom" } }),
|
|
);
|
|
await expect(apiClient.get("/x")).rejects.toBeInstanceOf(ApiError);
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("t6: 429 with Retry-After=1 retries once and succeeds", async () => {
|
|
fetchMock
|
|
.mockResolvedValueOnce(
|
|
jsonResponse(
|
|
429,
|
|
{ error: { code: "rate_limited", message: "slow down" } },
|
|
{ "Retry-After": "1" },
|
|
),
|
|
)
|
|
.mockResolvedValueOnce(jsonResponse(200, { data: { ok: true } }));
|
|
const result = await apiClient.get<{ data: { ok: boolean } }>("/x");
|
|
expect(result).toEqual({ data: { ok: true } });
|
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("t7: opts.idempotencyKey overrides auto-uuid", async () => {
|
|
fetchMock.mockResolvedValueOnce(jsonResponse(200, { data: { ok: true } }));
|
|
await apiClient.post("/x", { a: 1 }, { idempotencyKey: "custom-key-123" });
|
|
const headers = fetchMock.mock.calls[0]![1].headers as Record<string, string>;
|
|
expect(headers["Idempotency-Key"]).toBe("custom-key-123");
|
|
});
|
|
|
|
it("t8: timeout carrega um motivo descritivo — não a mensagem genérica do navegador", async () => {
|
|
// `fetch` real, ligado a um signal abortado, rejeita com o `.reason` desse
|
|
// signal — é esse contrato que este mock reproduz.
|
|
fetchMock.mockImplementation(
|
|
(_url: string, init: { signal: AbortSignal }) =>
|
|
new Promise((_resolve, reject) => {
|
|
init.signal.addEventListener("abort", () => reject(init.signal.reason), { once: true });
|
|
}),
|
|
);
|
|
|
|
const err: unknown = await apiClient
|
|
.get("/x", { timeoutMs: 5 })
|
|
.catch((e: unknown) => e);
|
|
|
|
// `DOMException` não é `instanceof Error` no Node — checa `.name`/`.message`
|
|
// diretamente, que é a mesma superfície que qualquer chamador consulta.
|
|
const e = err as { name: string; message: string };
|
|
// O bug: `AbortController.abort()` sem argumento sintetiza um DOMException
|
|
// cuja MENSAGEM LITERAL é "signal is aborted without reason" — foi isso
|
|
// que chegou à tela como "Runtime AbortError". Trava as duas pontas: o
|
|
// motivo tem nome reconhecível (a mesma convenção de `TimeoutError` que o
|
|
// cliente HTTP da camada de canal já usa) e a mensagem genérica do
|
|
// navegador não aparece mais.
|
|
expect(e.name).toBe("TimeoutError");
|
|
expect(e.message).not.toMatch(/aborted without reason/i);
|
|
expect(e.message).toMatch(/\d+ms/);
|
|
}, 10_000);
|
|
|
|
/**
|
|
* Timeout numa ESCRITA não é "não aconteceu" — é "não sei".
|
|
*
|
|
* O servidor não cancela o trabalho quando o cliente desiste: ele termina e
|
|
* devolve para ninguém. Retentar executa a escrita de novo.
|
|
*
|
|
* Medido (issue #783): "Testar agente" leva ~14,5s de modelo contra um
|
|
* timeout padrão de 10s. Um clique virava até TRÊS execuções completas do
|
|
* LLM, as três pagas, nenhuma devolvida à tela.
|
|
*/
|
|
function abortaSempre() {
|
|
return (_url: string, init: { signal: AbortSignal }) =>
|
|
new Promise((_resolve, reject) => {
|
|
init.signal.addEventListener("abort", () => reject(init.signal.reason), { once: true });
|
|
});
|
|
}
|
|
|
|
it("t9: POST que estoura o tempo NÃO é repetido — a escrita pode ter acontecido", async () => {
|
|
fetchMock.mockImplementation(abortaSempre());
|
|
|
|
await apiClient.post("/x", { a: 1 }, { timeoutMs: 5 }).catch(() => undefined);
|
|
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("t9b: PATCH e DELETE seguem a mesma regra", async () => {
|
|
for (const chamar of [
|
|
() => apiClient.patch("/x", { a: 1 }, { timeoutMs: 5 }),
|
|
// `delete(path, body?, opts?)` — o `body` opcional entrou no meio quando a
|
|
// rota de cancelar agendamento passou a exigir motivo. Escrito como
|
|
// `delete("/x", { timeoutMs: 5 })`, este objeto virava CORPO e o `opts`
|
|
// ficava vazio: o caso rodava com o prazo padrão e passava porque o
|
|
// padrão de então (10s) cabia no `testTimeout` de 15s — media a contagem
|
|
// de tentativas, nunca o prazo que dizia estar medindo.
|
|
() => apiClient.delete("/x", undefined, { timeoutMs: 5 }),
|
|
]) {
|
|
fetchMock.mockClear();
|
|
fetchMock.mockImplementation(abortaSempre());
|
|
await chamar().catch(() => undefined);
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
}
|
|
});
|
|
|
|
it("t10: GET que estoura o tempo CONTINUA sendo repetido — ler de novo é barato e seguro", async () => {
|
|
fetchMock.mockImplementation(abortaSempre());
|
|
|
|
await apiClient.get("/x", { timeoutMs: 5 }).catch(() => undefined);
|
|
|
|
expect(fetchMock).toHaveBeenCalledTimes(3);
|
|
});
|
|
|
|
it("t11: 429 num POST segue retentando — ali o servidor DISSE que não processou", async () => {
|
|
// A regra nova é sobre incerteza, não sobre método: um 429 é resposta, e
|
|
// resposta não deixa dúvida sobre o que aconteceu.
|
|
fetchMock
|
|
.mockResolvedValueOnce(jsonResponse(429, { error: {} }, { "Retry-After": "0" }))
|
|
.mockResolvedValueOnce(jsonResponse(200, { data: { ok: true } }));
|
|
|
|
await apiClient.post("/x", { a: 1 });
|
|
|
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
/**
|
|
* O ORÇAMENTO DE ESPERA DA ESCRITA (o vermelho de `followup-dossie:190`).
|
|
*
|
|
* Enquanto o método mutante era retentado, uma escrita tinha 10s + backoff +
|
|
* 10s + backoff + 10s ≈ 30,6s de parede. Parar de repetir era certo; o que
|
|
* passou despercebido é que a repetição também era o PRAZO — e ele caiu para
|
|
* 10s em toda mutação do produto de uma vez só.
|
|
*
|
|
* Medido no trace do CI (run 34876435491): `POST …/pause` cortado em
|
|
* 9999,558ms com `net::ERR_ABORTED` e UMA tentativa, num job onde os testes
|
|
* vizinhos correram mais rápido que na `main`. Quem desistiu foi o navegador.
|
|
*
|
|
* Estes dois casos prendem os dois prazos, que são diferentes de propósito:
|
|
* escrever espera 30s (desistir não cancela nada no servidor — só perde a
|
|
* resposta), ler desiste em 10s (a tela não fica presa, e a leitura é
|
|
* repetida, então o orçamento dela não mudou).
|
|
*/
|
|
it("t12: escrita só desiste depois de 30s — aos 10s ela ainda está de pé", async () => {
|
|
vi.useFakeTimers();
|
|
fetchMock.mockImplementation(abortaSempre());
|
|
|
|
const desfecho = vi.fn();
|
|
void apiClient.post("/x", { a: 1 }).then(desfecho, desfecho);
|
|
|
|
// 10s é o prazo da LEITURA. Se ele estiver valendo aqui, a escrita já
|
|
// morreu neste ponto — que é exatamente o defeito.
|
|
await vi.advanceTimersByTimeAsync(10_500);
|
|
expect(desfecho).not.toHaveBeenCalled();
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
|
|
await vi.advanceTimersByTimeAsync(20_000);
|
|
expect(desfecho).toHaveBeenCalled();
|
|
// E continua sem repetir: o prazo mudou, a regra do #787 não.
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("t13: leitura continua desistindo aos 10s — ela é repetida, prender a tela não paga", async () => {
|
|
vi.useFakeTimers();
|
|
fetchMock.mockImplementation(abortaSempre());
|
|
|
|
const desfecho = vi.fn();
|
|
void apiClient.get("/x").then(desfecho, desfecho);
|
|
|
|
// Aos 10,5s a primeira já estourou e a segunda tentativa começou.
|
|
await vi.advanceTimersByTimeAsync(10_500);
|
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
|
|
await vi.advanceTimersByTimeAsync(30_000);
|
|
expect(desfecho).toHaveBeenCalled();
|
|
expect(fetchMock).toHaveBeenCalledTimes(3);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* A ORGANIZAÇÃO SUMIU COM A TELA ABERTA.
|
|
*
|
|
* Achado por Paulo em 2026-09-10, testando pela tela: revogar o acesso de quem
|
|
* está usando o CRM naquele instante deixava a pessoa sentada lá — menu inteiro
|
|
* no lugar, dados falhando, um aviso vermelho em inglês. A tela de acesso
|
|
* revogado só aparecia depois de recarregar à mão.
|
|
*
|
|
* Nenhum teste automático pegaria isso: o defeito vive ENTRE dois carregamentos
|
|
* de página. Os casos abaixo travam o conserto, não o defeito.
|
|
*/
|
|
describe("apiClient — quando a organização some debaixo da tela", () => {
|
|
let fetchMock: ReturnType<typeof vi.fn>;
|
|
let reload: ReturnType<typeof vi.fn>;
|
|
|
|
function semOrganizacao() {
|
|
return jsonResponse(403, {
|
|
error: { code: "no_active_org", message: "No active organization." },
|
|
});
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.resetModules();
|
|
fetchMock = vi.fn();
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
reload = vi.fn();
|
|
// `window.location.reload` não é substituível direto no jsdom.
|
|
vi.stubGlobal("window", {
|
|
location: { reload },
|
|
sessionStorage: (() => {
|
|
const caixa = new Map<string, string>();
|
|
return {
|
|
getItem: (k: string) => caixa.get(k) ?? null,
|
|
setItem: (k: string, v: string) => void caixa.set(k, v),
|
|
removeItem: (k: string) => void caixa.delete(k),
|
|
};
|
|
})(),
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
it("pede ao servidor que decida de novo, em vez de deixar a pessoa na tela quebrada", async () => {
|
|
fetchMock.mockResolvedValue(semOrganizacao());
|
|
const { apiClient: cliente } = await import("@/lib/api/client");
|
|
|
|
// `toBeInstanceOf(ApiError)` NÃO serve aqui: o `vi.resetModules()` acima faz
|
|
// o módulo importado dinamicamente carregar uma SEGUNDA cópia da classe, e
|
|
// a comparação de identidade falha mesmo com o erro certo.
|
|
await expect(cliente.get("/api/v1/conversations")).rejects.toThrow(
|
|
"No active organization.",
|
|
);
|
|
expect(reload).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("NÃO entra em laço: a segunda recusa seguida não recarrega de novo", async () => {
|
|
// Sem esta trava o navegador piscaria para sempre — pior que o defeito.
|
|
fetchMock.mockResolvedValue(semOrganizacao());
|
|
const { apiClient: cliente } = await import("@/lib/api/client");
|
|
|
|
await expect(cliente.get("/api/v1/conversations")).rejects.toThrow();
|
|
await expect(cliente.get("/api/v1/conversations")).rejects.toThrow();
|
|
expect(reload).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("CONTROLE — outro erro 403 não recarrega nada", async () => {
|
|
// Sem este caso, um recarregamento em TODO 403 passaria verde — e a tela
|
|
// piscaria em cada falta de permissão, que é situação comum.
|
|
fetchMock.mockResolvedValue(
|
|
jsonResponse(403, { error: { code: "forbidden", message: "sem permissão" } }),
|
|
);
|
|
const { apiClient: cliente } = await import("@/lib/api/client");
|
|
|
|
await expect(cliente.get("/api/v1/conversations")).rejects.toThrow();
|
|
expect(reload).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("depois que a instalação volta a responder, uma revogação futura recarrega de novo", async () => {
|
|
// A marca tem de ser limpa no sucesso, senão a aba fica imune para sempre:
|
|
// a pessoa entra de novo, é revogada de novo, e nada acontece.
|
|
const { apiClient: cliente } = await import("@/lib/api/client");
|
|
|
|
fetchMock.mockResolvedValue(semOrganizacao());
|
|
await expect(cliente.get("/api/v1/conversations")).rejects.toThrow();
|
|
expect(reload).toHaveBeenCalledTimes(1);
|
|
|
|
fetchMock.mockResolvedValue(jsonResponse(200, { data: [] }));
|
|
await cliente.get("/api/v1/conversations");
|
|
|
|
fetchMock.mockResolvedValue(semOrganizacao());
|
|
await expect(cliente.get("/api/v1/conversations")).rejects.toThrow();
|
|
expect(reload).toHaveBeenCalledTimes(2);
|
|
});
|
|
});
|