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:
Pessoa
2026-09-15 23:10:44 -03:00
co-authored by Claude Opus 5
parent 0b8cf2d54d
commit 1b65f2f3c2
3 changed files with 200 additions and 6 deletions
+1 -1
View File
@@ -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.
+36 -5
View File
@@ -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 }),
);
});
});