feat(inbox): a demanda no painel de quem atende — passo 4 do cap. 5 fecha

O painel lateral mostrava negócio, pedido e histórico: três listas sobre o que
JÁ aconteceu. Nenhuma responde à pergunta que a pessoa do outro lado está
fazendo — o que ela pediu e ainda não foi resolvido. O caso concreto: o
atendente encerra a conversa, a demanda segue aberta e sem próximo passo, e o
vazamento só reaparece depois como número numa métrica que ele não abre.

Corrijo uma afirmação minha anterior: eu havia escrito que "o inbox lê
`crm_leads`" e procurado em `app/app/inbox/`. Lá não há nada — o inbox é um
redirect. A leitura vive em `crm-summary` e o painel em `CRMSidePanel`. Certo em
espírito, errado de endereço.

## Segui o desenho da rota em vez de contornar

`crm-summary` usa client de SESSÃO (RLS ativa) e tem uma regra explícita: "um
pedido, um veredito", as consultas falham juntas. Isso existe porque o defeito
original era erro de permissão traduzido em "Sem leads.". A 4ª consulta entra no
mesmo Promise.all e no mesmo `??` de falha — se `demandas` falhar, a rota falha
inteira, em vez de a seção nova mentir "nenhuma demanda aberta" sobre um erro.

## O risco era a RLS, e só a tela prova isso

A política de `demandas` chama `fn_user_org_ids()`. Se a leitura de sessão não
passasse, o painel diria "Nenhuma demanda aberta." para sempre — o defeito que a
rota veio curar, reintroduzido por mim. Teste unitário com `apiClient` mockado
não pega.

`tests/sonda-inbox-demandas-tela.ts` — 15/15, exit 0, com a contagem do banco
como régua: 2 demandas semeadas, 2 na tela (1 sem passo destacada + 1 com),
status 200, antes dos negócios, sem scroll horizontal, zero erro de console. E a
fase SOB FALHA: confessa "Não consegui ler", não afirma "Nenhuma demanda",
oferece tentar de novo.

## Sabotagens (previsão antes de rodar)

    mesmo data-testid para todas         1 → 2   (o teste da frase também usa)
    a frase some, fica só a cor          1 → 1
    seção movida para depois dos leads   1 → 1
    o flag `erro` não é setado           1 → 1
    setDemandas(null) → setDemandas([])  1 → 0

A última reprovou ZERO e corrigiu meu modelo mental: eu creditava a proteção ao
`setDemandas(null)`, mas quem impede a mentira é o `setErro(true)` — o `null` é
consistência com as irmãs, não a defesa. Mecanismo redundante identificado e
crédito reatribuído.

## Achado PRÉ-EXISTENTE, medido com controle

O painel do inbox NÃO CABE NA TELA: fica 311px fora da viewport em 1280px e
151px em 1440px (cabe só em 1920). `InboxLayout.tsx:164` usa
`xl:grid-cols-[300px_1fr_320px]` e o `xl` dispara em 1280 — a terceira coluna
nasce no ponto exato em que não há espaço. E `scroll_horizontal` é false, então
não há como alcançar o que ficou fora.

Controle que prova não ser meu: a seção CONTATO, anterior a esta branch, está
exatamente tão fora quanto a minha (295px, mesma left/right). É o layout.

## Não medido, e a causa

A extensão que fiz em `tests/sonda-painel-inbox.ts` (incluir "Nenhuma demanda
aberta." entre as frases proibidas sob falha) NÃO foi executada: a fixture dela,
"Ana Souza LGPD E2E", não existe neste banco — medido, 0 contatos. O
comportamento equivalente está provado no unitário (sabotagem do flag, 1→1) e na
tela (fase SOB FALHA), mas a linha daquele arquivo segue sem execução.

typecheck 0 · lint 0 errors · 1813 unitários em 169 arquivos

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FkS3mzwtXughmjVC5FCoNo
This commit is contained in:
Rafael Melgaço
2026-08-07 10:40:30 -03:00
co-authored by Claude Opus 5
parent 357a45bd9e
commit 3227bf8136
8 changed files with 617 additions and 11 deletions
+29 -2
View File
@@ -38,6 +38,21 @@ const LEAD_COLS = "id, title, status, value_cents, currency, updated_at";
const ORDER_COLS = "id, external_id, status, total_cents, currency, created_at";
/** Acompanha o que a timeline mostra — `reason` e `actor_kind` inclusive. */
const ACTIVITY_COLS = "id, type, source_module, performed_at, payload, reason, actor_kind";
/**
* Passo 4 do cap. 5 — a DEMANDA chega ao lugar onde o humano atende.
*
* O painel mostrava negócios, pedidos e histórico. Nenhum dos três responde à
* pergunta que a pessoa do outro lado está fazendo: **o que ela pediu e ainda
* não foi resolvido.** Lead é o negócio; conversa é o canal; demanda é o que
* precisa acabar (doutrina cap. 5).
*
* O caso concreto que isto evita: o atendente encerra a conversa, a demanda
* segue aberta e sem próximo passo, e o vazamento só aparece depois — como
* número numa métrica que ele não abre. `proximo_passo` vem junto porque a
* ausência dele é o próprio invariante 4, e é o que precisa saltar na tela.
*/
const DEMANDA_COLS =
"id, aberta_em, origem, estado, proximo_passo, proximo_passo_em, prazo_em";
export async function GET(
_req: NextRequest,
@@ -55,7 +70,7 @@ export async function GET(
return fail("unauthenticated", "Auth required.", 401, { requestId });
}
const [leads, orders, activities] = await Promise.all([
const [leads, orders, activities, demandas] = await Promise.all([
supabase
.from("crm_leads")
.select(LEAD_COLS)
@@ -74,11 +89,22 @@ export async function GET(
.eq("contact_id", contactId)
.order("performed_at", { ascending: false })
.limit(5),
// Só as ABERTAS: demanda encerrada é histórico e já vive na timeline. Da
// mais antiga para a mais nova — quem espera há mais tempo aparece primeiro,
// mesma régua do Radar, para as duas telas não contarem histórias
// diferentes sobre o mesmo contato.
supabase
.from("demandas")
.select(DEMANDA_COLS)
.eq("contact_id", contactId)
.is("fechada_em", null)
.order("aberta_em", { ascending: true })
.limit(5),
]);
// A falha SOBE. Engolir aqui devolveria lista vazia ao cliente e recriaria,
// do lado do servidor, exatamente a mentira que esta rota veio desfazer.
const falha = leads.error ?? orders.error ?? activities.error;
const falha = leads.error ?? orders.error ?? activities.error ?? demandas.error;
if (falha) {
return fail("internal_error", falha.message, 500, { requestId });
}
@@ -88,6 +114,7 @@ export async function GET(
leads: leads.data ?? [],
orders: orders.data ?? [],
activities: activities.data ?? [],
demandas: demandas.data ?? [],
},
{ requestId },
);
+99 -3
View File
@@ -52,6 +52,36 @@ interface ActivityRow {
actor_kind: string | null;
}
/**
* Passo 4 do cap. 5 — a demanda no lugar onde o humano atende.
*
* As outras três listas contam o que já aconteceu (negócio, pedido, histórico).
* Esta conta o que **ainda não acabou**, que é a pergunta que a pessoa do outro
* lado está fazendo. Sem ela, o atendente encerra a conversa sem saber que a
* demanda continua aberta e sem próximo passo — e o vazamento só reaparece
* depois, como número numa métrica que ele não abre.
*/
interface DemandaRow {
id: string;
aberta_em: string;
origem: string;
estado: string;
proximo_passo: string | null;
proximo_passo_em: string | null;
prazo_em: string | null;
}
/** Vocabulário de quem atende, não o do banco. */
const ESTADO_LEGIVEL: Record<string, string> = {
aberta: "Aberta",
em_atendimento: "Em atendimento",
aguardando_cliente: "Aguardando o cliente",
};
function horasDesde(iso: string): number {
return Math.max(0, Math.floor((Date.now() - new Date(iso).getTime()) / 3_600_000));
}
function formatMoney(cents: number | null, currency: string | null): string {
if (cents == null) return "—";
const cur = currency ?? "BRL";
@@ -107,6 +137,7 @@ export function CRMSidePanel({ conversation }: Props) {
const [leads, setLeads] = useState<LeadRow[] | null>(null);
const [orders, setOrders] = useState<OrderRow[] | null>(null);
const [activities, setActivities] = useState<ActivityRow[] | null>(null);
const [demandas, setDemandas] = useState<DemandaRow[] | null>(null);
const [loading, setLoading] = useState(false);
/**
* O TERCEIRO ESTADO. Antes existiam dois — carregando e "tem N itens" — e a
@@ -133,6 +164,7 @@ export function CRMSidePanel({ conversation }: Props) {
setLeads(null);
setOrders(null);
setActivities(null);
setDemandas(null);
return;
}
let cancelled = false;
@@ -145,12 +177,22 @@ export function CRMSidePanel({ conversation }: Props) {
async function load() {
try {
const r = await apiClient.get<{
data: { leads: LeadRow[]; orders: OrderRow[]; activities: ActivityRow[] };
data: {
leads: LeadRow[];
orders: OrderRow[];
activities: ActivityRow[];
demandas: DemandaRow[];
};
}>(`/api/v1/contacts/${contactId}/crm-summary`);
if (cancelled) return;
setLeads(r.data.leads);
setOrders(r.data.orders);
setActivities(r.data.activities);
// `?? []` e não `?? null`: aqui a leitura DEU CERTO. Cair em `null`
// faria a lista vazia se disfarçar do terceiro estado e o painel
// mostraria esqueleto para sempre num contato sem demanda aberta —
// que é o caso saudável.
setDemandas(r.data.demandas ?? []);
} catch {
if (cancelled) return;
// Falha NÃO vira lista vazia. Os dados ficam `null` e o painel diz que
@@ -159,6 +201,7 @@ export function CRMSidePanel({ conversation }: Props) {
setLeads(null);
setOrders(null);
setActivities(null);
setDemandas(null);
} finally {
if (!cancelled) setLoading(false);
}
@@ -183,8 +226,10 @@ export function CRMSidePanel({ conversation }: Props) {
// apareceria — o mesmo colapso de significados que criou o defeito original,
// só que trocando "erro→vazio" por "erro→carregando".
const sectionsLoading = useMemo(
() => !erro && (loading || (leads === null && orders === null && activities === null)),
[erro, loading, leads, orders, activities],
() =>
!erro &&
(loading || (leads === null && orders === null && activities === null && demandas === null)),
[erro, loading, leads, orders, activities, demandas],
);
if (!conversation) {
@@ -269,6 +314,57 @@ export function CRMSidePanel({ conversation }: Props) {
<Separator />
{/* ANTES dos negócios de propósito (doutrina cap. 5): lead é o negócio,
conversa é o canal, demanda é o que precisa acabar. Quem abre esta
conversa está atendendo alguém que pediu alguma coisa — a primeira
pergunta a responder é o que ainda está pendente, não quanto vale. */}
<section data-testid="inbox-demandas">
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Demandas abertas
</h3>
{sectionsLoading ? (
<Skeleton className="mt-2 h-14 w-full" />
) : demandas && demandas.length > 0 ? (
<ul className="mt-2 space-y-1.5">
{demandas.map((d) => {
const semPasso = !d.proximo_passo;
return (
<li
key={d.id}
data-testid={semPasso ? "demanda-sem-proximo-passo" : "demanda-com-proximo-passo"}
className={cn(
"rounded-md border p-2 text-xs",
semPasso ? "border-warning-border bg-warning-bg/40" : "border-border",
)}
>
<div className="flex items-baseline justify-between gap-2">
<span className="truncate font-medium">
{ESTADO_LEGIVEL[d.estado] ?? d.estado}
</span>
<span className="shrink-0 tabular-nums text-muted-foreground">
há {horasDesde(d.aberta_em)}h
</span>
</div>
{/* O invariante 4 na frase, não só na cor: quem enxerga mal
cor precisa ler a mesma informação. */}
<div className={cn("mt-0.5", semPasso ? "font-medium" : "text-muted-foreground")}>
{d.proximo_passo ?? "Sem próximo passo definido"}
</div>
</li>
);
})}
</ul>
) : (
<SemLista
vazio="Nenhuma demanda aberta."
erro={erro}
onTentarDeNovo={() => setTentativa((n) => n + 1)}
/>
)}
</section>
<Separator />
<section>
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Leads recentes
+108 -5
View File
@@ -475,12 +475,115 @@ jargao: [] scroll_horizontal: false erros de console: nenhum
**editor legado sem `ToolPicker`** (`page.tsx:53`). A sonda fixa um `mcp_agent`
e o comentário explica por quê, para o próximo não perder o mesmo tempo.
### ⚠️ PENDENTE
---
- **Inbox** segue lendo `crm_leads`. Enquanto isso durar, a conversa ainda é
tratada como unidade em parte do sistema — que é o que o passo 4 existe para
terminar.
- **A `description` morta do catálogo** (acima) — decisão de desenho pendente.
## Passo 4 do cap. 5 — o inbox, e com ele o passo FECHA (2026-08-07)
O painel lateral do inbox mostrava negócio, pedido e histórico: três listas
sobre o que **já aconteceu**. Nenhuma responde à pergunta que a pessoa do outro
lado está fazendo — *o que eu pedi e ainda não foi resolvido*. O caso concreto:
o atendente encerra a conversa, a demanda segue aberta e sem próximo passo, e o
vazamento só reaparece depois como número numa métrica que ele não abre.
**Correção da afirmação anterior deste handoff:** eu havia escrito "o inbox
segue lendo `crm_leads`" e procurado em `app/app/inbox/`. Lá não há nada — o
inbox é só um redirect. A leitura vive em `app/api/v1/contacts/[id]/crm-summary`
e o painel em `components/inbox/CRMSidePanel.tsx`. A afirmação estava certa em
espírito e errada de endereço.
### O desenho da rota, que eu segui em vez de contornar
`crm-summary` usa **client de sessão** (RLS ativa), não service role — e tem uma
regra explícita: *"um pedido, um veredito"*, as consultas falham juntas. Isso
existe porque o defeito original era erro de permissão traduzido em `Sem leads.`
A 4ª consulta entra no mesmo `Promise.all` e no mesmo `??` de falha. Se
`demandas` falhar, a rota falha inteira — em vez de a seção nova dizer "nenhuma
demanda aberta" em cima de um erro.
### O risco real era a RLS, e por isso a prova de tela é a que vale
A política de `demandas` chama `fn_user_org_ids()`. Se a leitura de sessão não
passasse, o painel diria "Nenhuma demanda aberta." para sempre — o defeito que a
rota veio curar, reintroduzido por mim numa seção nova. Um teste unitário com
`apiClient` mockado **não pega isso**.
`tests/sonda-inbox-demandas-tela.ts` — 15/15, exit 0, com 2 demandas semeadas no
banco (uma sem próximo passo) e a contagem do banco como régua:
```
texto na tela: "DEMANDAS ABERTAS
Aberta · há 9h · Sem próximo passo definido ← destacada
Em atendimento · há 3h · Enviar o orçamento revisado"
noBanco: 2 itens na tela: 1 sem passo + 1 com passo = 2 ← bate
statusSummary: 200 · a RLS deixou ler · antes dos negócios: true
scroll horizontal: false · erros de console: nenhum
SOB FALHA (rota interceptada com 500):
confessa "Não consegui ler": true · mente "Nenhuma demanda": false
oferece "Tentar de novo": true
```
### Sabotagens — previsão antes de rodar
| Sabotagem | Previsão | Resultado | |
|---|---|---|---|
| Todas as demandas com o mesmo `data-testid` | 1 | **2** | esqueci que o teste da frase também busca por testid |
| A frase "Sem próximo passo definido" some (fica só a cor) | 1 | **1** ✅ | acessibilidade é vigiada |
| A seção vai para depois dos negócios | 1 | **1** ✅ | ordem é afirmação de qual é a unidade |
| O flag `erro` não é setado | 1 | **1** ✅ | falha volta a parecer ausência |
| `setDemandas(null)` → `setDemandas([])` no catch | 1 | **0** ⚠️ | ver abaixo |
**A última reprovou ZERO, e isso corrigiu meu modelo mental.** Eu creditava a
proteção ao `setDemandas(null)`; o que realmente impede a mentira é o
`setErro(true)`, que faz o `SemLista` mostrar "Não consegui ler"
independentemente da lista estar `[]` ou `null`. O `null` é consistência com as
irmãs, não a defesa. Mecanismo redundante identificado e crédito reatribuído — a
sabotagem que vale para aquele caso é a do flag, e ela reprova 1.
### 🐛 Achado PRÉ-EXISTENTE, medido com controle: o painel do inbox não cabe na tela
| viewport | painel fica fora da viewport |
|---|---|
| 1280px | **311px** |
| 1440px | **151px** |
| 1920px | cabe (−24px) |
`InboxLayout.tsx:164` usa `xl:grid-cols-[300px_1fr_320px]`, e o `xl` do Tailwind
dispara em 1280 — a terceira coluna nasce no exato ponto em que não há espaço
para ela. Pior: `scroll_horizontal` é **false**, então **não há como alcançar** o
que ficou fora.
**Controle que prova não ser meu:** a seção `CONTATO`, que existe desde antes
desta branch, está exatamente tão fora quanto a minha (295px, mesma
`left`/`right`). É o layout, não o conteúdo.
Em 1280px — resolução de trabalho comum — o atendente não vê o painel de CRM
nenhum. Vale issue própria, e é da mesma família do overflow de 390px já
registrado.
### Ambiente consertado de passagem
`.e2e-creds.json` tinha o segredo TOTP do admin **divergente do banco**
(`YVVB64YO…` no arquivo, `RYA6TA36…` em `auth.mfa_factors`) — outra rodada do
seed rotacionou e o arquivo ficou para trás. Corrigido a partir do banco; o
login de admin nas sondas voltou a funcionar.
### ⚠️ NÃO MEDIDO — e a causa
`tests/sonda-painel-inbox.ts` (a guarda antiga do painel, que estendi para
incluir "Nenhuma demanda aberta." na lista de frases que não podem aparecer sob
falha) **não roda neste banco**: sua fixture `Ana Souza LGPD E2E` não existe
aqui — medido, **0 contatos**. A extensão está escrita e não foi executada.
Isso não deixa buraco de cobertura: o mesmo comportamento está provado (a) no
teste unitário, caso 3, com sabotagem do flag `erro` confirmada 1→1, e (b) na
tela, na fase "SOB FALHA" da sonda nova. Mas a linha que escrevi naquele arquivo
segue sem execução, e isso fica declarado.
### Pendente
- **A `description` morta do catálogo** — decisão de desenho (48 de 51 divergem).
- **Issue do painel do inbox cortado** em 1280/1440px.
---
Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

+193
View File
@@ -0,0 +1,193 @@
/**
* PROVA DE TELA — a demanda no painel do inbox (passo 4 do cap. 5).
*
* ## O que esta sonda prova que o teste de componente NÃO pode
*
* O teste unitário mocka `apiClient` e prova o COMPORTAMENTO do painel. Ele não
* toca a RLS. E aqui a RLS é o risco real: a rota `crm-summary` lê com o
* **client de sessão**, e a política de `demandas` chama `fn_user_org_ids()`.
* Se essa leitura não passar, o painel diz "Nenhuma demanda aberta." para
* sempre — que é EXATAMENTE o defeito que a rota inteira veio curar (erro de
* permissão traduzido em afirmação sobre o negócio), reintroduzido por mim numa
* seção nova.
*
* Por isso o caso central não é "a seção apareceu": é **a seção mostrar as duas
* demandas que existem no banco**, com a sem próximo passo distinguível.
*
* Uso: `npx tsx tests/sonda-inbox-demandas-tela.ts`
* Requer o app na 3100 apontando para o Supabase LOCAL. Semeia e limpa.
*/
import { execFileSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { chromium } from "@playwright/test";
const BASE = "http://127.0.0.1:3100";
const CONVERSA = "8b9fcd5f-252d-4f6b-9538-f7c2c538807f";
const CONTATO = "564d1c58-52a1-4610-bd43-2d88df13064c";
const ORG = "ad365e5b-45e5-45d3-99fa-33b388501fec";
const MARCA = "sonda-inbox-demanda";
const c = JSON.parse(readFileSync("/Users/rafaelmelgaco/DeskcommCRM/.e2e-creds.json", "utf8"));
function psql(script: string): string {
return execFileSync(
"docker",
["exec", "-i", "supabase_db_deskcomm-crm", "psql", "-U", "postgres", "-d", "postgres", "-tA"],
{ input: script, encoding: "utf8" },
);
}
const limpar = `delete from public.demandas where assunto = '${MARCA}';`;
function semear(): number {
psql(`
${limpar}
insert into public.demandas
(organization_id, contact_id, aberta_em, origem, estado, assunto, proximo_passo, proximo_passo_em)
values
('${ORG}', '${CONTATO}', now() - interval '9 hours', 'inbound', 'aberta', '${MARCA}', null, null),
('${ORG}', '${CONTATO}', now() - interval '3 hours', 'handoff', 'em_atendimento', '${MARCA}',
'Enviar o orçamento revisado', now() + interval '1 day');
`);
// Quantas demandas ABERTAS o banco tem para este contato — a régua contra a
// qual a tela é comparada. Sem ela, "apareceu alguma coisa" passaria por
// prova.
return Number(
psql(
`select count(*) from public.demandas where contact_id='${CONTATO}' and fechada_em is null;`,
).trim(),
);
}
async function main(): Promise<void> {
const noBanco = semear();
const b = await chromium.launch();
const p = await b.newPage({ viewport: { width: 1440, height: 900 } });
const erros: string[] = [];
let statusSummary = 0;
p.on("console", (m) => {
if (m.type() === "error") erros.push(m.text());
});
p.on("response", (r) => {
if (r.url().includes("/crm-summary")) statusSummary = r.status();
});
await p.goto(`${BASE}/login`, { waitUntil: "domcontentloaded" });
await p.click('input[type="email"]');
await p.locator('input[type="email"]').pressSequentially(c.users.manager.email, { delay: 8 });
await p.click('input[type="password"]');
await p.locator('input[type="password"]').pressSequentially(c.password, { delay: 8 });
await p.click('button[type="submit"]');
await p.waitForURL(/\/app/, { timeout: 30000 });
await p.goto(`${BASE}/app/inbox?id=${CONVERSA}`, { waitUntil: "networkidle" });
await p.waitForTimeout(3000);
const m = (await p.evaluate(`(() => {
var sec = document.querySelector('[data-testid="inbox-demandas"]');
if (!sec) return { presente: false };
var r = sec.getBoundingClientRect();
var sem = sec.querySelectorAll('[data-testid="demanda-sem-proximo-passo"]');
var com = sec.querySelectorAll('[data-testid="demanda-com-proximo-passo"]');
var leads = null;
var h3s = document.querySelectorAll("h3");
for (var i = 0; i < h3s.length; i++) {
// case-INSENSITIVE: o h3 usa \`uppercase\` no CSS e \`innerText\` no browser
// devolve "LEADS RECENTES". No jsdom do teste unitário não há
// text-transform, então lá o texto casa e aqui não casava — a medida
// voltava \`null\` e a asserção reprovava por não ter medido, não por
// estar errada.
if (/leads recentes/i.test(h3s[i].innerText)) { leads = h3s[i]; break; }
}
return {
presente: true,
texto: sec.innerText.replace(/\\s+/g, " ").trim(),
itens_sem_passo: sem.length,
itens_com_passo: com.length,
// A frase, não só a cor — acessibilidade medida, não presumida.
diz_por_escrito: /Sem próximo passo definido/.test(sec.innerText),
// Zero lisonjeiro: com dados no banco, dizer "nenhuma" seria a mentira.
afirma_nenhuma: /Nenhuma demanda aberta/.test(sec.innerText),
confessa_falha: /Não consegui ler/.test(sec.innerText),
antes_dos_leads: leads
? !!(sec.compareDocumentPosition(leads) & Node.DOCUMENT_POSITION_FOLLOWING)
: null,
largura: Math.round(r.width),
visivel: r.width > 0 && r.height > 0,
scroll_horizontal:
document.documentElement.scrollWidth > document.documentElement.clientWidth,
};
})()`)) as Record<string, unknown>;
// CONGELA as medidas do caminho feliz ANTES da fase de falha: o listener de
// `response` e o array de `erros` são cumulativos, e o 500 injetado adiante
// sobrescreveria o status e somaria um erro de console. A primeira versão
// desta sonda reprovou "a rota respondeu 200" e "sem erro de console" por
// isso — a medida do caminho feliz tinha sido contaminada pela fase seguinte.
const statusFeliz = statusSummary;
const errosFeliz = [...erros];
console.log(JSON.stringify({ ...m, statusSummary: statusFeliz, noBanco, erros: errosFeliz }, null, 2));
await p.screenshot({ path: "evidence/passo4-inbox-demandas.png", fullPage: false });
// ---------------------------------------------------------------------------
// O CAMINHO DA FALHA, na tela. `tests/sonda-painel-inbox.ts` já guarda isto
// para as três seções antigas, mas não roda neste banco: sua fixture
// ("Ana Souza LGPD E2E") não existe aqui — medido, 0 contatos. Em vez de
// deixar a seção nova sem prova de tela no caminho de erro, o caso vem junto.
//
// A rota devolve 500 e o painel tem de CONFESSAR. Dizer "Nenhuma demanda
// aberta" aqui seria uma afirmação sobre o negócio feita em cima de uma falha
// de leitura — exatamente o defeito que a rota `crm-summary` veio curar.
// ---------------------------------------------------------------------------
await p.route("**/crm-summary", (r) =>
r.fulfill({ status: 500, contentType: "application/json", body: '{"error":{"code":"boom"}}' }),
);
await p.reload({ waitUntil: "networkidle" });
await p.waitForTimeout(2500);
const falha = (await p.evaluate(`(() => {
var sec = document.querySelector('[data-testid="inbox-demandas"]');
if (!sec) return { presente: false };
var t = sec.innerText;
return {
presente: true,
confessa: /Não consegui ler/.test(t),
mente: /Nenhuma demanda aberta/.test(t),
oferece_retry: /Tentar de novo/.test(t),
};
})()`)) as Record<string, unknown>;
console.log("SOB FALHA:", JSON.stringify(falha));
await p.screenshot({ path: "evidence/passo4-inbox-demandas-falha.png", fullPage: false });
await b.close();
psql(limpar);
const casos: Array<[string, boolean]> = [
["a rota respondeu 200", statusFeliz === 200],
["a seção está na tela", m.presente === true],
["mostra TODAS as demandas abertas do banco", Number(m.itens_sem_passo) + Number(m.itens_com_passo) === noBanco],
["a sem próximo passo é distinguível", Number(m.itens_sem_passo) >= 1],
["a com próximo passo também aparece", Number(m.itens_com_passo) >= 1],
["diz a ausência por escrito, não só por cor", m.diz_por_escrito === true],
["NÃO afirma 'nenhuma demanda' havendo demanda", m.afirma_nenhuma === false],
["não confessa falha de leitura (a RLS deixou ler)", m.confessa_falha === false],
["vem antes dos negócios", m.antes_dos_leads === true],
["sem scroll horizontal", m.scroll_horizontal === false],
["sem erro de console", errosFeliz.length === 0],
["sob falha: a seção continua na tela", falha.presente === true],
["sob falha: CONFESSA que não conseguiu ler", falha.confessa === true],
["sob falha: NÃO afirma 'nenhuma demanda aberta'", falha.mente === false],
["sob falha: oferece tentar de novo", falha.oferece_retry === true],
];
let falhas = 0;
for (const [nome, ok] of casos) {
console.log(`${ok ? " ok " : "FALHA "} ${nome}`);
if (!ok) falhas += 1;
}
console.log(falhas === 0 ? "\nTODOS OS CASOS PASSARAM" : `\n${falhas} CASO(S) FALHARAM`);
process.exit(falhas === 0 ? 0 : 1);
}
main().catch((e) => {
psql(limpar);
console.error(e);
process.exit(1);
});
+7 -1
View File
@@ -95,7 +95,13 @@ async function main(): Promise<void> {
await abrirConversa(page);
const textoFalha = await painel.innerText();
const mente = /Sem leads\.|Sem atividade\.|Sem pedidos\./.test(textoFalha);
// "Nenhuma demanda aberta." entra na lista pelo MESMO motivo das outras
// três: com a leitura falhando, é uma afirmação sobre o negócio feita em
// cima de um erro. A seção nasceu depois da sonda; se não entrasse aqui, o
// painel poderia voltar a mentir por um caminho que ninguém vigia.
const mente = /Sem leads\.|Sem atividade\.|Sem pedidos\.|Nenhuma demanda aberta\./.test(
textoFalha,
);
const confessa = /Não consegui ler/i.test(textoFalha);
record(
"2.8.d-falha-confessa",
+181
View File
@@ -0,0 +1,181 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { CRMSidePanel } from "@/components/inbox/CRMSidePanel";
/**
* O painel monta `ConversationTagsEditor`, que usa react-query. Sem o provider
* o componente estoura antes de renderizar qualquer seção — e o teste falharia
* por montagem, não pelo que quer medir.
*/
function renderPainel() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<CRMSidePanel conversation={conversation} />
</QueryClientProvider>,
);
}
/**
* A DEMANDA no painel do inbox — passo 4 do cap. 5 da doutrina.
*
* O painel já mostrava negócio, pedido e histórico: três listas sobre o que JÁ
* aconteceu. Nenhuma responde à pergunta que a pessoa do outro lado está
* fazendo — o que ela pediu e ainda não foi resolvido. O caso concreto que isto
* evita: o atendente encerra a conversa, a demanda segue aberta e sem próximo
* passo, e o vazamento só reaparece depois como número numa métrica que ele não
* abre.
*
* O que estes casos guardam, e por que cada um:
*
* 1. **Sem próximo passo é DISTINGUÍVEL** — não basta listar. Se as duas
* demandas se parecem, o invariante 4 vira decoração: o atendente lê a lista
* e não sabe qual delas está vazando.
* 2. **A frase existe, não só a cor** — quem enxerga mal cor precisa ler a
* mesma informação. Um teste que só checasse a classe CSS aprovaria uma tela
* inacessível.
* 3. **Erro NÃO vira "nenhuma demanda aberta"** — é o defeito que esta rota
* inteira veio curar, e a seção nova poderia reintroduzi-lo por um caminho
* que a sonda antiga não cobria.
* 4. **A demanda vem ANTES do negócio** — ordem é a afirmação de qual é a
* unidade (cap. 5). Se o lead vier primeiro, a tela diz que a unidade é o
* negócio, contradizendo a doutrina que o resto da branch implementa.
*/
const CONTACT = "c0000000-0000-4000-8000-000000000001";
const conversation = {
id: "cv-1",
organization_id: "org-1",
contact_id: CONTACT,
tags: [],
// `contacts`, PLURAL — é como o painel deriva o contactId (linha 134). Com
// `contact` o efeito nunca dispara e as seções ficam no esqueleto para sempre.
contacts: { id: CONTACT, display_name: "Fulana", name: null, phone_number: "5511999", tags: [] },
} as unknown as React.ComponentProps<typeof CRMSidePanel>["conversation"];
const RESPOSTA = {
leads: [
{
id: "l-1",
title: "Negócio existente",
status: "open",
value_cents: 10_000,
currency: "BRL",
updated_at: "2026-08-01T00:00:00Z",
},
],
orders: [],
activities: [],
demandas: [
{
id: "d-1",
aberta_em: new Date(Date.now() - 5 * 3_600_000).toISOString(),
origem: "inbound",
estado: "aberta",
proximo_passo: null,
proximo_passo_em: null,
prazo_em: null,
},
{
id: "d-2",
aberta_em: new Date(Date.now() - 2 * 3_600_000).toISOString(),
origem: "handoff",
estado: "em_atendimento",
proximo_passo: "Ligar amanhã de manhã",
proximo_passo_em: "2026-08-08T12:00:00Z",
prazo_em: null,
},
],
};
const get = vi.fn();
vi.mock("@/lib/api/client", () => ({
apiClient: {
get: (...args: unknown[]) => get(...args),
post: vi.fn(),
patch: vi.fn(),
},
}));
vi.mock("@/hooks/pipelines/useDefaultPipeline", () => ({
useDefaultPipeline: () => ({ data: null, isError: false }),
}));
vi.mock("sonner", () => ({ toast: { error: vi.fn(), success: vi.fn() } }));
// Os editores de tag buscam vocabulário por react-query. Sem isto o painel
// estoura em `(vocabulary ?? []).filter` e o teste falharia por MONTAGEM — um
// vermelho que não diz nada sobre demanda.
vi.mock("@/hooks/inbox/useConversationTags", () => ({
useUpdateConversationTags: () => ({ mutate: vi.fn(), isPending: false }),
useConversationTagVocabulary: () => ({ data: [] }),
}));
vi.mock("@/hooks/contacts/useUpdateContact", () => ({
useUpdateContact: () => ({ mutate: vi.fn(), isPending: false }),
}));
beforeEach(() => {
get.mockReset();
});
describe("painel do inbox — demandas abertas", () => {
it("distingue a demanda SEM próximo passo da que tem", async () => {
get.mockResolvedValue({ data: RESPOSTA });
renderPainel();
await waitFor(() => expect(screen.getByTestId("inbox-demandas")).toBeTruthy());
// Guarda de vacuidade: as duas precisam estar na tela, senão "distingue"
// passaria por só uma existir.
const sem = await screen.findAllByTestId("demanda-sem-proximo-passo");
const com = screen.getAllByTestId("demanda-com-proximo-passo");
expect(sem.length).toBe(1);
expect(com.length).toBe(1);
});
it("a ausência do próximo passo é dita por ESCRITO, não só por cor", async () => {
get.mockResolvedValue({ data: RESPOSTA });
renderPainel();
const sem = await screen.findByTestId("demanda-sem-proximo-passo");
expect(sem.textContent).toMatch(/sem próximo passo definido/i);
// E a que TEM mostra qual é — listar sem dizer o que foi combinado obrigaria
// o atendente a abrir outra tela para descobrir.
const com = await screen.findByTestId("demanda-com-proximo-passo");
expect(com.textContent).toMatch(/ligar amanhã de manhã/i);
});
it("leitura que FALHA não vira 'nenhuma demanda aberta'", async () => {
get.mockRejectedValue(new Error("500"));
renderPainel();
const secao = await screen.findByTestId("inbox-demandas");
await waitFor(() => expect(secao.textContent).toMatch(/não consegui ler/i));
// A afirmação sobre o negócio não pode ser feita em cima de um erro de
// leitura — é o defeito que a rota `crm-summary` inteira veio curar.
expect(secao.textContent).not.toMatch(/nenhuma demanda aberta/i);
});
it("contato SEM demanda aberta diz isso — e não some da tela", async () => {
get.mockResolvedValue({ data: { ...RESPOSTA, demandas: [] } });
renderPainel();
const secao = await screen.findByTestId("inbox-demandas");
await waitFor(() => expect(secao.textContent).toMatch(/nenhuma demanda aberta/i));
// Some da tela seria pior que dizer: o atendente não distinguiria "não tem"
// de "esta versão não mostra".
expect(screen.queryByTestId("demanda-sem-proximo-passo")).toBeNull();
});
it("a demanda vem ANTES do negócio — a unidade é ela (cap. 5)", async () => {
get.mockResolvedValue({ data: RESPOSTA });
renderPainel();
const secao = await screen.findByTestId("inbox-demandas");
const leads = screen.getByText("Leads recentes");
// `compareDocumentPosition` mede a ordem no documento, não a aparência —
// medida por ferramenta, nunca a olho.
const posicao = secao.compareDocumentPosition(leads);
expect(posicao & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
});