mirror of
https://github.com/melgarafael/DeskcommCRM.git
synced 2026-10-02 01:28:34 +08:00
test(funil): o quadro passa a ter guarda no ponto de uso, e a sonda do 12.c volta a medir
Três consertos da revisão do PR #919 (lote 12, grupo leads e funil): 1. `tests/sonda-pulso-12c.ts` media a SEGUNDA escrita comparando o `updated_at` que a rota devolveu com o que ficou no banco. O próprio #919 passou a reler o lead DEPOIS de gravar a atividade, então os dois viraram o mesmo valor por construção: a sonda diria "só uma escrita — INCONCLUSIVO" num arrasto que continua produzindo duas. NÃO apliquei o conserto proposto (comparar contra o `expected_updated_at` que o cliente mandou) porque ele não distingue o que precisa distinguir: UMA escrita já muda o `updated_at`, então "difere do enviado?" responde SIM com uma escrita e com duas — a sonda trocaria de cego para otimista. A segunda escrita é o carimbo de `last_activity_at` (`trg_update_last_activity_at` → UPDATE no lead → `trg_crm_leads_updated_at`) e acontece DEPOIS do INSERT da atividade; a primeira, antes. Quem separa uma de duas é `lead.updated_at > atividade.created_at`. O corte que isola a atividade deste arrasto é o `updated_at` de ANTES, lido do banco — não o relógio deste processo, que diverge do `now()` do Postgres. 2. O ponto de uso ficou sem guarda: quem escolhe o `expected_updated_at` é `components/kanban/KanbanBoard.tsx:211`, lendo `lead.updated_at` da lista que o quadro renderiza — nenhum teste o alcançava. `tests/unit/arrastar-le-o-updated-at-do-cache.test.tsx` renderiza o quadro de verdade (dnd, colunas e dossiê como dublês), dispara o mesmo `DropResult` do gesto real e prova a volta inteira: o segundo arrasto do mesmo card manda o `updated_at` que o primeiro devolveu. O GET do board fica PENDENTE no fixture de propósito — a janela do #916 é o intervalo entre a resposta do move e o refetch do `onSettled` chegar. Este arquivo cobre também o que a revisão pedia como segundo caso em tests/unit/mover-card-duas-vezes.test.tsx (o round-trip pela chave ["board", pipeline]): ele usa o `useBoard` e o `useMoveCard` reais, com a mesma chave, então o caso no hook seria subconjunto estrito. Não dupliquei. 3. O fragmento prometia "mover o mesmo card várias vezes seguidas" sem a ressalva de que um segundo arrasto disparado ANTES da primeira resposta continua caindo em 409 (o quadro não tranca o card em voo). Passa a dizer "assim que o primeiro movimento é confirmado". Medido: `pnpm typecheck` exit 0; os dois arquivos de teste, "Tests 3 passed (3)". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,4 +3,4 @@ impacto: nada_mudou
|
||||
secao: corrigido
|
||||
titulo: Arrastar o mesmo card duas vezes seguidas no funil deixa de dar "modificado por outro usuário"
|
||||
---
|
||||
No funil, o primeiro arrastar de um card funcionava, mas arrastar o mesmo card de novo logo em seguida mostrava "Lead foi modificado por outro usuário. Recarregue e tente novamente.", sem ninguém mais usando, e só voltava a funcionar recarregando a página. O próprio movimento registra a mudança de etapa no histórico, e esse registro atualizava o card de novo depois que a tela já tinha guardado a versão anterior. Agora o servidor devolve a versão final do card e a tela a guarda na hora, então dá para mover o mesmo card várias vezes seguidas. Crédito: @rafaelbatistazz.
|
||||
No funil, o primeiro arrastar de um card funcionava, mas arrastar o mesmo card de novo logo em seguida mostrava "Lead foi modificado por outro usuário. Recarregue e tente novamente.", sem ninguém mais usando, e só voltava a funcionar recarregando a página. O próprio movimento registra a mudança de etapa no histórico, e esse registro atualizava o card de novo depois que a tela já tinha guardado a versão anterior. Agora o servidor devolve a versão final do card e a tela a guarda na hora, então, assim que o primeiro movimento é confirmado, dá para mover o mesmo card de novo sem recarregar a página. Crédito: @rafaelbatistazz.
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
* exercitado, e o verde seria sobre um caso que não ocorreu. A segunda
|
||||
* escrita é o carimbo de `last_activity_at` da atividade `stage_changed`
|
||||
* (Wave 3), que chega ~112ms depois e era exatamente quem fazia a aba
|
||||
* piscar. Detectada comparando o `updated_at` que a rota devolveu com o
|
||||
* que ficou no banco.
|
||||
* piscar. Detectada comparando o `updated_at` que ficou no lead com o
|
||||
* `created_at` da atividade: a segunda escrita é a única posterior a ela.
|
||||
*
|
||||
* O alvo é escolhido DENTRO do pipeline do CRM Vivo e fora do último estágio:
|
||||
* `position` empata entre pipelines, então "o primeiro estágio" global é
|
||||
@@ -60,7 +60,7 @@ async function main(): Promise<void> {
|
||||
// o erro não diz nada sobre o pulso. Sem `order`, o `.limit(1)` era loteria.
|
||||
const { data: candidatos } = await admin
|
||||
.from("crm_leads")
|
||||
.select("id, title, stage_id")
|
||||
.select("id, title, stage_id, updated_at")
|
||||
.eq("pipeline_id", pipelineId)
|
||||
.in("stage_id", stages.slice(0, -1).map((s) => s.id));
|
||||
const posicaoDoEstagio = new Map(stages.map((s) => [s.id, s.position]));
|
||||
@@ -122,8 +122,39 @@ async function main(): Promise<void> {
|
||||
.select("stage_id, updated_at")
|
||||
.eq("id", lead.id)
|
||||
.maybeSingle();
|
||||
|
||||
// A SEGUNDA escrita é o carimbo de `last_activity_at` que o INSERT da
|
||||
// atividade `stage_changed` dispara (`trg_update_last_activity_at` → UPDATE no
|
||||
// lead → `trg_crm_leads_updated_at`). Ela acontece DEPOIS da atividade; a
|
||||
// primeira (o UPDATE do próprio move) acontece ANTES. Então quem separa uma
|
||||
// escrita de duas é o `updated_at` do lead contra o `created_at` da atividade.
|
||||
//
|
||||
// ⚠️ Esta sonda comparava o `updated_at` que a ROTA devolveu com o que ficou no
|
||||
// banco. Isso deixou de medir qualquer coisa quando o #919 passou a reler o
|
||||
// lead DEPOIS de gravar a atividade: os dois valores passaram a ser o mesmo
|
||||
// por construção, e a sonda diria "só uma escrita" — INCONCLUSIVO — num
|
||||
// arrasto que continua produzindo duas. Comparar contra o que o CLIENTE mandou
|
||||
// também não serve: uma escrita só já muda o `updated_at`, então a pergunta
|
||||
// "mudou em relação ao enviado?" responde SIM com uma escrita e com duas.
|
||||
//
|
||||
// O corte é o `updated_at` de ANTES do arrasto, e não o relógio deste
|
||||
// processo: os dois relógios divergem, e a atividade é carimbada pelo
|
||||
// Postgres.
|
||||
const { data: atividade } = await admin
|
||||
.from("crm_lead_activities")
|
||||
.select("created_at")
|
||||
.eq("lead_id", lead.id)
|
||||
.eq("type", "stage_changed")
|
||||
.gt("created_at", lead.updated_at)
|
||||
.order("created_at", { ascending: false })
|
||||
.limit(1)
|
||||
.maybeSingle();
|
||||
|
||||
const mudou = depois?.stage_id !== lead.stage_id;
|
||||
const duasEscritas = !!updatedAtDaRota && depois?.updated_at !== updatedAtDaRota;
|
||||
const duasEscritas =
|
||||
!!depois?.updated_at &&
|
||||
!!atividade?.created_at &&
|
||||
Date.parse(depois.updated_at) > Date.parse(atividade.created_at);
|
||||
const pulsos = await page.evaluate(
|
||||
() => (window as unknown as { __pulsos: number }).__pulsos,
|
||||
);
|
||||
@@ -131,7 +162,7 @@ async function main(): Promise<void> {
|
||||
console.info(`1. card mudou de coluna: ${mudou ? "SIM" : "NÃO"}`);
|
||||
console.info(`2. pulsos na própria aba: ${pulsos}`);
|
||||
console.info(
|
||||
`3. a ação produziu 2 escritas: ${duasEscritas ? "SIM" : "NÃO"} (rota ${updatedAtDaRota} / banco ${depois?.updated_at})`,
|
||||
`3. a ação produziu 2 escritas: ${duasEscritas ? "SIM" : "NÃO"} (banco ${depois?.updated_at} / atividade ${atividade?.created_at ?? "nenhuma"} / rota ${updatedAtDaRota})`,
|
||||
);
|
||||
|
||||
if (!mudou) {
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* O PONTO DE USO do conserto do #916/#919 — o quadro, não o hook.
|
||||
*
|
||||
* Quem decide o `expected_updated_at` que vai no fio é
|
||||
* `components/kanban/KanbanBoard.tsx` (`handleDragEnd`), lendo `lead.updated_at`
|
||||
* da lista que ele mesmo renderiza. O hook só repassa o que recebeu: um teste
|
||||
* que para no `useMoveCard` fica verde com esse valor congelado na origem —
|
||||
* medido como sabotagem-controle, zero casos vermelhos.
|
||||
*
|
||||
* Este arquivo fecha a volta inteira, como o operador a faz: arrasta um card,
|
||||
* o servidor devolve a versão final, e o SEGUNDO arrasto do MESMO card manda o
|
||||
* `updated_at` que veio do primeiro — que é o que impede o 409 "modificado por
|
||||
* outro usuário".
|
||||
*
|
||||
* O arrasto entra pelo `onDragEnd` que o `DragDropContext` recebe (o dnd é
|
||||
* dublê aqui): é o mesmo `DropResult` que o gesto do mouse e o do teclado
|
||||
* produzem, sem depender de arrastar pixels no jsdom.
|
||||
*/
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { act, render, waitFor } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { BoardData } from "@/lib/kanban/types";
|
||||
|
||||
const post = vi.hoisted(() => vi.fn());
|
||||
const capturado = vi.hoisted(() => ({
|
||||
onDragEnd: null as ((r: unknown) => void) | null,
|
||||
}));
|
||||
|
||||
vi.mock("@hello-pangea/dnd", () => ({
|
||||
DragDropContext: ({
|
||||
onDragEnd,
|
||||
children,
|
||||
}: {
|
||||
onDragEnd: (r: unknown) => void;
|
||||
children: ReactNode;
|
||||
}) => {
|
||||
capturado.onDragEnd = onDragEnd;
|
||||
return <div>{children}</div>;
|
||||
},
|
||||
}));
|
||||
vi.mock("@/components/kanban/StageColumn", () => ({ StageColumn: () => null }));
|
||||
vi.mock("@/components/kanban/LeadDossier", () => ({ LeadDossier: () => null }));
|
||||
vi.mock("@/hooks/i18n/useT", () => ({ useT: () => (texto: string) => texto }));
|
||||
vi.mock("@/hooks/inbox/useAssignableMembers", () => ({
|
||||
useAssignableMembers: () => ({ data: [] }),
|
||||
}));
|
||||
vi.mock("@/hooks/leads/useAtRiskLeads", () => ({ useAtRiskLeads: () => ({ data: null }) }));
|
||||
vi.mock("@/hooks/leads/useReactivations", () => ({ useReactivations: () => ({ data: [] }) }));
|
||||
vi.mock("@/hooks/realtime/useRealtimeChannel", () => ({
|
||||
useRealtimeChannel: () => ({ status: "SUBSCRIBED", ultimaEntrega: null }),
|
||||
}));
|
||||
vi.mock("@/hooks/realtime/useRefetchDeSeguranca", () => ({
|
||||
useRefetchDeSeguranca: () => undefined,
|
||||
}));
|
||||
// O GET do board fica PENDENTE de propósito: a janela do #916 é justamente o
|
||||
// intervalo entre a resposta do move e o refetch do `onSettled` chegar. Um GET
|
||||
// que responde fecha essa janela e o teste passaria a medir o refetch.
|
||||
vi.mock("@/lib/api/client", () => ({
|
||||
apiClient: { post, get: vi.fn(() => new Promise<never>(() => {})) },
|
||||
}));
|
||||
vi.mock("@/components/feedback/ApiErrorToast", () => ({ showApiError: vi.fn() }));
|
||||
|
||||
import { KanbanBoard } from "@/components/kanban/KanbanBoard";
|
||||
|
||||
const PIPELINE = "p-1";
|
||||
const LEAD = "l-1";
|
||||
const ANTES = "2026-09-15T12:00:00.000Z";
|
||||
const DEPOIS_DO_PRIMEIRO = "2026-09-15T12:00:01.500Z";
|
||||
|
||||
function quadro(): BoardData {
|
||||
return {
|
||||
pipeline: { id: PIPELINE, settings: null } as unknown as BoardData["pipeline"],
|
||||
stages: [
|
||||
{ id: "s-1", name: "Novo", position: 0 },
|
||||
{ id: "s-2", name: "Contato", position: 1 },
|
||||
] as unknown as BoardData["stages"],
|
||||
leads: [
|
||||
{
|
||||
id: LEAD,
|
||||
stage_id: "s-1",
|
||||
position_in_stage: 1000,
|
||||
updated_at: ANTES,
|
||||
} as BoardData["leads"][number],
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/** O `DropResult` de soltar o card na coluna `s-2`, na primeira posição. */
|
||||
const soltarEmS2 = {
|
||||
draggableId: LEAD,
|
||||
source: { droppableId: "s-1", index: 0 },
|
||||
destination: { droppableId: "s-2", index: 0 },
|
||||
reason: "DROP",
|
||||
type: "DEFAULT",
|
||||
mode: "FLUID",
|
||||
};
|
||||
|
||||
let qc: QueryClient;
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
|
||||
);
|
||||
|
||||
async function arrastar(): Promise<void> {
|
||||
await act(async () => {
|
||||
capturado.onDragEnd?.(soltarEmS2);
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
post.mockReset();
|
||||
capturado.onDragEnd = null;
|
||||
qc = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
qc.setQueryData(["board", PIPELINE], quadro());
|
||||
});
|
||||
|
||||
describe("o quadro manda o updated_at do card que ele renderiza", () => {
|
||||
it("o primeiro arrasto manda o updated_at que veio do servidor no board", async () => {
|
||||
post.mockResolvedValue({
|
||||
data: { id: LEAD, stage_id: "s-2", position_in_stage: 500, updated_at: DEPOIS_DO_PRIMEIRO },
|
||||
});
|
||||
render(<KanbanBoard pipelineId={PIPELINE} />, { wrapper });
|
||||
await waitFor(() => expect(capturado.onDragEnd).not.toBeNull());
|
||||
|
||||
await arrastar();
|
||||
|
||||
expect(post).toHaveBeenCalledWith(
|
||||
`/api/v1/leads/${LEAD}/move`,
|
||||
expect.objectContaining({ stage_id: "s-2", expected_updated_at: ANTES }),
|
||||
);
|
||||
});
|
||||
|
||||
it("o segundo arrasto do MESMO card manda o updated_at que o primeiro devolveu", async () => {
|
||||
post
|
||||
.mockResolvedValueOnce({
|
||||
data: { id: LEAD, stage_id: "s-2", position_in_stage: 500, updated_at: DEPOIS_DO_PRIMEIRO },
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
data: {
|
||||
id: LEAD,
|
||||
stage_id: "s-2",
|
||||
position_in_stage: 250,
|
||||
updated_at: "2026-09-15T12:00:03.000Z",
|
||||
},
|
||||
});
|
||||
render(<KanbanBoard pipelineId={PIPELINE} />, { wrapper });
|
||||
await waitFor(() => expect(capturado.onDragEnd).not.toBeNull());
|
||||
|
||||
await arrastar();
|
||||
await waitFor(() => expect(post).toHaveBeenCalledTimes(1));
|
||||
// A resposta do primeiro já está no cache — é dela que o quadro lê agora.
|
||||
await arrastar();
|
||||
await waitFor(() => expect(post).toHaveBeenCalledTimes(2));
|
||||
|
||||
expect(post).toHaveBeenLastCalledWith(
|
||||
`/api/v1/leads/${LEAD}/move`,
|
||||
expect.objectContaining({ expected_updated_at: DEPOIS_DO_PRIMEIRO }),
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user