fix(webhooks): o invariante que codificava o defeito, o extrator cego e o instrumento cego

Três consertos que só apareceram quando os gates rodaram de verdade — e os três
são do INSTRUMENTO, não do produto. Vale nomear cada um, porque os três têm o
mesmo modo de falha: acusar o alvo errado com uma mensagem convincente.

## 1. O invariante que afirmava o defeito

`automation-send-whatsapp.test.ts` caso 2 exigia `result.status === "success"`
**e**, na linha seguinte, `queued_reason: "waha_not_configured"`. Ou seja: ele
sabia que a mensagem não tinha saído e chamava aquilo de sucesso. Era o relato
do usuário congelado em asserção.

Agora exige `postponed` + `reason`. Não afrouxa nada: a mensagem continua sendo
criada com o corpo renderizado, e o motivo continua sendo cobrado.

## 2. O extrator de vocabulário só entendia uma das duas formas

`literaisDoUnionType` lia `type X = "a" | "b";` e estourava em
`const X = [...] as const` — que é a forma que o Zod exige (`z.enum(X)` precisa
do array em runtime). A própria mensagem de erro mandava "ENSINE O EXTRATOR",
e é o que este commit faz: as duas formas caem no mesmo caminho de comparação.
Escrever o union ao lado do array seria a terceira lista, exatamente o que
aquele invariante existe para proibir.

## 3. O instrumento cego do meu próprio invariante

O caso "ninguém com sessão consegue ESCREVER" provava a recusa capturando um
`raise notice` — que sai em **stderr**, e o `execFileSync` do arquivo lê só
stdout. Reprovava por cegueira, com a RLS fazendo o certo. Agora a prova é por
CONTAGEM: ou a linha forjada existe, ou não.

E dois consertos das specs, pela mesma família:

  * `count()` não tem auto-wait — perguntado logo após o clique, respondia 0
    porque o dropdown do Radix ainda não montara, e o teste dizia "nenhum
    número no seed" com o número lá. Trocado por `expect(...).toBeVisible()`.
  * `locator("div").filter({has: texto}).last()` devolve o div mais INTERNO
    que contém o título, sem o badge de status (irmão dele na árvore). Passou
    a usar o mesmo `cardDe()` de `webhooks.spec.ts`, que sobe por XPath até o
    container do card.

`scripts/seed-e2e-numero-conectado.ts`: um número `WORKING`, que o seed base
não cria (conectar de verdade exige QR no celular). Ele NÃO é um WhatsApp que
funciona — o `WAHA_API_BASE_URL` do rig aponta para porta vazia —, e isso é
proposital: é a instalação com o WhatsApp fora do ar do relato.

Evidência visual em `evidence/webhooks-historico-e-ia/` (4 telas), produzida
por `scripts/evidencia-webhooks.ts`.

## tests/invariants/** — o que foi tocado

`DESKCOMM_GOV_INVARIANTS_EDIT=1`. Dois arquivos, e nenhum afrouxa:
`automation-send-whatsapp` troca uma asserção que codificava o defeito (com a
razão escrita no próprio caso), e `vocabulario-banco-x-typescript` ensina o
extrator a segunda forma — sem isso o par novo não conseguia nem LER a lista.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Rar8AjvB5rzqm8QKWeLMx
This commit is contained in:
Rafael Melgaço
2026-08-24 15:30:54 -03:00
co-authored by Claude Opus 5
parent 87a0ac0185
commit e3fa07c0ee
10 changed files with 258 additions and 25 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

+77
View File
@@ -0,0 +1,77 @@
/**
* Fotografa as telas desta entrega para a evidência do PR.
*
* Não é teste: é o instrumento que produz as imagens que a doutrina de QA
* Visual cobra (`evidence/`, que é versionado — `.superpowers/evidence/` é
* gitignored). Roda contra o app já de pé, com o banco já semeado.
*
* Run: E2E_PORT=3021 npx tsx scripts/evidencia-webhooks.ts
*/
import * as fs from "node:fs";
import * as path from "node:path";
import { chromium } from "@playwright/test";
const APP = `http://localhost:${process.env.E2E_PORT ?? "3001"}`;
const DESTINO = path.join(process.cwd(), "evidence", "webhooks-historico-e-ia");
async function main(): Promise<void> {
fs.mkdirSync(DESTINO, { recursive: true });
const creds = JSON.parse(fs.readFileSync(".e2e-creds.json", "utf8")) as {
password: string;
users: Record<string, { email: string }>;
};
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
await page.goto(`${APP}/login`);
await page.locator("#email").fill(creds.users.manager!.email);
await page.locator("#password").fill(creds.password);
await page.getByRole("button", { name: /entrar/i }).click();
await page.waitForURL(/\/app\//);
await page.goto(`${APP}/app/webhooks`);
await page.getByRole("tab", { name: "Leads recebidos" }).click();
await page.waitForTimeout(1500);
await page.screenshot({ path: path.join(DESTINO, "01-leads-recebidos.png"), fullPage: false });
// O painel de uma captação: dados, hora, origem, IP.
const primeira = page.getByRole("row").nth(1).getByRole("button").first();
if (await primeira.count()) {
await primeira.click();
await page.waitForTimeout(800);
await page.screenshot({ path: path.join(DESTINO, "02-detalhe-da-captacao.png") });
await page.keyboard.press("Escape");
await page.waitForTimeout(400);
}
await page.getByRole("tab", { name: "Atividade" }).click();
await page.waitForTimeout(1500);
await page.screenshot({ path: path.join(DESTINO, "03-atividade-diz-falhou.png") });
// O editor com a ação nova de IA.
await page.getByRole("tab", { name: "Automações" }).click();
await page.getByRole("button", { name: /Nova automação/ }).click();
const editor = page.getByRole("dialog");
await editor.getByRole("combobox").first().click();
await page.getByRole("option", { name: /contato novo \(webhook\)/i }).click();
await editor.getByRole("combobox").filter({ hasText: "Adicionar ação" }).click();
await page.getByRole("option", { name: "Mensagem escrita pela IA" }).click();
await editor
.locator("#ai-instruction")
.fill(
"Agradeça citando o segmento que a pessoa informou, mostre em uma frase como resolvemos a dificuldade que ela descreveu, e pergunte qual o melhor horário para conversar.",
);
await page.waitForTimeout(600);
await page.screenshot({ path: path.join(DESTINO, "04-acao-mensagem-pela-ia.png") });
await browser.close();
console.log(`evidência em ${DESTINO}`);
}
void main().catch((err) => {
console.error(err);
process.exit(1);
});
+91
View File
@@ -0,0 +1,91 @@
/**
* Um número de WhatsApp CONECTADO (`status = 'WORKING'`) para as specs que
* exercitam envio automatizado.
*
* ═══ Por que isto precisa de seed, e não da tela ═══
*
* Conectar um número de verdade exige ler um QR code no celular. Numa
* instalação real isso acontece uma vez, à mão; num rig de teste não acontece
* nunca. Sem uma sessão `WORKING` a tela de automação desabilita TODOS os
* números no seletor — corretamente, porque mandar mensagem por um número
* desconectado é o defeito que aquele `disabled` existe para evitar — e a spec
* não tem o que medir.
*
* `seed-e2e-followup-agent.ts` também cria uma sessão, mas ela nasce
* `STARTING` (o default da coluna): serve para o que aquele seed precisa (uma
* FK não-nula para publicar agente) e não serve aqui.
*
* ═══ O que este número NÃO é ═══
*
* Não é um WhatsApp que funciona. O `WAHA_API_BASE_URL` do `.env.e2e` aponta
* para uma porta vazia, então todo envio por ele MORRE — e isso é proposital:
* é exatamente a instalação com o WhatsApp fora do ar que o relato de
* 2026-08-24 descreve, e é sobre esse desfecho que a spec afirma.
*
* Idempotente pelo `waha_session_name`. Run: npx tsx scripts/seed-e2e-numero-conectado.ts
*/
import * as fs from "node:fs";
import * as path from "node:path";
import { createClient } from "@supabase/supabase-js";
import { carregarEnvLocal } from "./lib/env-de-teste";
const CREDS_PATH = path.join(process.cwd(), ".e2e-creds.json");
const SESSION_NAME = "e2e-numero-conectado";
interface Creds {
org_id: string;
numero_conectado?: { channel_session_id: string };
}
async function main(): Promise<void> {
const env = carregarEnvLocal();
const admin = createClient(
env.NEXT_PUBLIC_SUPABASE_URL!,
env.SUPABASE_SERVICE_ROLE_KEY!,
{ auth: { persistSession: false } },
);
const creds = JSON.parse(fs.readFileSync(CREDS_PATH, "utf8")) as Creds;
const orgId = creds.org_id;
const { data: existente } = await admin
.from("channel_sessions")
.select("id, status")
.eq("organization_id", orgId)
.eq("waha_session_name", SESSION_NAME)
.maybeSingle();
let id: string;
if (existente) {
id = (existente as { id: string }).id;
// Uma spec anterior pode ter deixado o status em outro valor (o watchdog
// reconcilia com o WAHA real, que aqui não existe). Reafirma WORKING.
await admin.from("channel_sessions").update({ status: "WORKING" }).eq("id", id);
} else {
const { data, error } = await admin
.from("channel_sessions")
.insert({
organization_id: orgId,
waha_session_name: SESSION_NAME,
display_name: "Número conectado (E2E)",
phone_number: "+5511999990000",
status: "WORKING",
webhook_secret_encrypted: "\\x00",
} as never)
.select("id")
.single();
if (error || !data) throw new Error(`insert channel_sessions: ${error?.message}`);
id = (data as { id: string }).id;
}
creds.numero_conectado = { channel_session_id: id };
fs.writeFileSync(CREDS_PATH, JSON.stringify(creds, null, 2));
console.log(`[seed] número conectado (WORKING): ${id}`);
}
void main().catch((err) => {
console.error(err);
process.exit(1);
});
+32 -11
View File
@@ -26,7 +26,7 @@ import * as fs from "node:fs";
import * as path from "node:path";
import { execFileSync } from "node:child_process";
import { test, expect, type Page, type APIRequestContext } from "@playwright/test";
import { test, expect, type Page, type Locator, type APIRequestContext } from "@playwright/test";
import { carregarEnvLocal } from "../../scripts/lib/env-de-teste";
const APP_URL = `http://localhost:${process.env.E2E_PORT ?? "3001"}`;
@@ -41,6 +41,10 @@ function loadCreds(): Creds {
if (!fs.existsSync(CREDS_PATH)) {
execFileSync("npx", ["tsx", "scripts/seed-e2e-credentials.ts"], { stdio: "inherit" });
}
// Um número CONECTADO é pré-condição: a tela desabilita todo número que não
// esteja `WORKING`, e o seed base não cria nenhum. Conectar de verdade exige
// ler QR no celular, o que não existe num rig — ver o cabeçalho do seed.
execFileSync("npx", ["tsx", "scripts/seed-e2e-numero-conectado.ts"], { stdio: "inherit" });
return JSON.parse(fs.readFileSync(CREDS_PATH, "utf8")) as Creds;
}
@@ -56,6 +60,20 @@ const SOURCE_NAME = `E2E Verdade ${ts}`;
const RULE_NAME = `E2E Abordar ${ts}`;
const LEAD_NAME = `Carlos Verdade ${ts}`;
/**
* Sobe do texto até o CARD do design system (o container com `border-border`).
*
* Mesmo helper de `webhooks.spec.ts`, e a razão de ele existir foi medida aqui:
* `locator("div").filter({ has: texto }).last()` devolve o div mais INTERNO que
* contém o título — que não contém o badge de status, irmão dele na árvore. A
* asserção reprovava com a tela certa na frente.
*/
function cardDe(locator: Locator): Locator {
return locator.locator(
"xpath=ancestor::div[contains(concat(' ', normalize-space(@class), ' '), ' border-border ')][1]",
);
}
async function login(page: Page, email: string): Promise<void> {
await page.goto(`${APP_URL}/login`);
await page.locator("#email").fill(email);
@@ -122,15 +140,21 @@ test.describe("a automação conta o que aconteceu de verdade", () => {
await editor.getByRole("combobox").filter({ hasText: "Adicionar ação" }).click();
await page.getByRole("option", { name: "Enviar mensagem no WhatsApp" }).click();
// O número: o seed cria uma sessão; se nenhuma estiver WORKING o teste
// não teria o que provar, então a ausência é falha explícita.
// O número: o seed garante uma sessão WORKING; se não houvesse, o teste
// não teria o que provar — daí a espera EXPLÍCITA antes de contar.
//
// `count()` não tem auto-wait: perguntado logo depois do clique, ele
// responde 0 porque o dropdown do Radix ainda não montou, e o teste
// reprova dizendo "nenhum número no seed" com o número lá. Instrumento
// cego acusando o alvo errado — o `expect(...).toBeVisible()` é que
// espera de verdade.
const seletorDeNumero = editor.getByRole("combobox").filter({ hasText: /Escolha o número/ });
await seletorDeNumero.click();
const numeros = page.getByRole("option");
expect(
await numeros.count(),
"nenhum número de WhatsApp no seed — a spec não teria o que medir",
).toBeGreaterThan(0);
await expect(
numeros.first(),
"nenhum número de WhatsApp WORKING — rode scripts/seed-e2e-numero-conectado.ts",
).toBeVisible({ timeout: 10_000 });
await numeros.first().click();
await editor.getByRole("textbox").last().fill(`Olá {{nome}}, vi que você se cadastrou.`);
@@ -160,10 +184,7 @@ test.describe("a automação conta o que aconteceu de verdade", () => {
// ── A TELA: o que a aba Atividade diz ────────────────────────────────
await page.getByRole("tab", { name: "Atividade" }).click();
const cartao = page
.locator("div")
.filter({ has: page.getByText(RULE_NAME, { exact: true }) })
.last();
const cartao = cardDe(page.getByText(RULE_NAME, { exact: true }).first());
let apareceu = false;
for (let tentativa = 0; tentativa < 12; tentativa++) {
@@ -354,8 +354,24 @@ describe("send_whatsapp_message — execute (Task 11)", () => {
template: "Oi {{contact.name}}",
});
expect(result.status).toBe("success");
expect(result.detail?.queued_reason).toBe("waha_not_configured");
// ⚠️ ESTA ASSERÇÃO DIZIA `success`, E ERA ELA QUE CODIFICAVA O DEFEITO.
//
// O próprio caso já sabia que a mensagem NÃO tinha saído — ele afirmava
// `queued_reason: "waha_not_configured"` na linha seguinte. Ou seja: o
// invariante congelava "a automação chama de sucesso uma mensagem que
// ficou na fila", que é exatamente o que um usuário relatou em 2026-08-24
// (aba Atividade com ✓ verde, cliente sem receber nada).
//
// `postponed` é o desfecho honesto para `queued`: não saiu AINDA, e pode
// sair — o watchdog resgata `sent_via='ai'` em `queued` quando o canal
// volta. `failed` seria a mentira oposta, e faria quem lê desistir de uma
// mensagem que está a caminho.
//
// O que a mudança NÃO afrouxa: a mensagem continua sendo criada, com o
// corpo renderizado, e o motivo continua sendo cobrado — abaixo, na chave
// `reason`, que é onde `desfechoDoEnvio` o publica.
expect(result.status).toBe("postponed");
expect(result.detail?.reason).toBe("waha_not_configured");
const messageId = String(result.detail?.message_id);
expect(messageId).toBeTruthy();
@@ -146,21 +146,33 @@ describe("webhook_lead_captures — isolamento e gate de papel", () => {
it("ninguém com sessão consegue ESCREVER: não há policy de insert", () => {
// A escrita é do service role (a rota pública de captação), que bypassa RLS.
// Um `authenticated` que consiga inserir aqui forjaria histórico de origem.
const erro = sql(`
// Um `authenticated` que consiga inserir aqui forjaria histórico de origem —
// e origem é o que este histórico existe para responder.
//
// A prova é por CONTAGEM, não por capturar a mensagem do erro: `raise
// notice` sai em stderr, e o `execFileSync` acima lê só stdout. A primeira
// versão deste caso fazia isso e passava a impressão de medir — o teste
// reprovou por instrumento cego, não por RLS frouxa. Contar a linha depois
// não tem esse ponto cego: ou ela existe, ou não.
sql(`
set role authenticated;
select set_config('request.jwt.claims', '{"sub":"${MANAGER_A}"}', false);
do $$
begin
insert into public.webhook_lead_captures (organization_id, source_name, outcome)
values ('${ORG_A}', 'forjada', 'criado');
raise notice 'INSERIU';
exception when insufficient_privilege or others then
raise notice 'RECUSADO';
values ('${ORG_A}', 'forjada pela sessão', 'criado');
exception when others then
null; -- a recusa é o esperado; quem decide é a contagem abaixo
end
$$;
select 1;
`);
expect(erro).toContain("RECUSADO");
// Conta como superusuário (reset role): se a linha entrou, ela está lá,
// mesmo que a sessão que a inseriu não conseguisse relê-la.
const forjadas = sql(`
reset role;
select count(*) from public.webhook_lead_captures where source_name = 'forjada pela sessão';
`);
expect(forjadas.split("\n").pop()).toBe("0");
});
});
@@ -379,12 +379,28 @@ function literaisDoUnionType(arquivo: string, simbolo: string): string[] {
// Prosa em português tem ponto e vírgula. O extrator é que não podia depender
// de a prosa não ter.
const semComentarios = fonte.replace(/\/\*[\s\S]*?\*\//g, " ").replace(/\/\/[^\n]*/g, " ");
const decl = new RegExp(`type\\s+${simbolo}\\s*=([^;]*);`, "s").exec(semComentarios);
// DUAS FORMAS, e as duas são vocabulário legítimo neste repo:
//
// type X = "a" | "b"; ← union puro
// const X = ["a", "b"] as const; ← tupla congelada
//
// A segunda existe porque o Zod precisa do ARRAY em runtime (`z.enum(X)`), e
// escrever o union ao lado seria a terceira lista — exatamente o que este
// invariante existe para proibir. O extrator lia só a primeira e mandava
// "ENSINE O EXTRATOR"; esta é a lição aprendida, e não uma exceção aberta:
// as duas formas caem no MESMO caminho de comparação abaixo.
const decl =
new RegExp(`type\\s+${simbolo}\\s*=([^;]*);`, "s").exec(semComentarios) ??
new RegExp(`const\\s+${simbolo}\\s*=\\s*(\\[[^\\]]*\\])\\s*as\\s+const`, "s").exec(
semComentarios,
);
if (!decl) {
throw new Error(
`extrator de vocabulário: não achei \`type ${simbolo} = ...;\` em ${arquivo}. ` +
`Se o tipo virou \`const ... as const\` ou mudou de nome, ENSINE O EXTRATOR — ` +
`deixar isto falhar em silêncio devolveria lista vazia e o par passaria sem ler nada.`,
`extrator de vocabulário: não achei \`type ${simbolo} = ...;\` nem ` +
`\`const ${simbolo} = [...] as const\` em ${arquivo}. Se o símbolo mudou de nome ou ` +
`de forma, ENSINE O EXTRATOR — deixar isto falhar em silêncio devolveria lista vazia ` +
`e o par passaria sem ler nada.`,
);
}