Skip to content

Commit 42d1a20

Browse files
melgarafaelclaude
andcommitted
test(worker): a linha outbound do ai-response-worker cabe na constraint do banco
Follow-up do #126, de @maugarciasa. O conserto e dele; esta e a rede que faltava. O defeito que ele achou (sent_via: "bot", valor fora de messages_sent_via_check) passou despercebido porque NADA cobria persistAndDispatch. Medido com controle positivo: `grep persistAndDispatch tests/` sai vazio (exit 1) enquanto `grep processMessageReceived tests/` acha 7 ocorrencias em 2 arquivos — o instrumento estava vivo, a cobertura e que nao existia. Ele declarou isso no proprio PR em vez de fingir cobertura, e sugeriu follow-up para manter o PR focado. Concordei; isto e o follow-up. O QUE O TESTE GUARDA: que a linha montada por persistAndDispatch CABE na constraint, nos DOIS call sites — o dispatch normal e o rascunho de handoff G3 com skipDispatch, alcancado variando confidence_threshold. O QUE O TIRA DE TAUTOLOGIA: o vocabulario permitido e EXTRAIDO de supabase/baseline.sql em tempo de execucao, nao redigitado no teste. Se ele repetisse a lista, poderia concordar com o worker e discordar do banco — que e exatamente o modo de falha que deixou o "bot" passar. E o baseline e o schema que o self-hoster de fato aplica, nao as migrations. DISCRIMINANCIA, medida nos DOIS sentidos: - devolvendo `sent_via: "bot"` no worker -> 2 casos vermelhos - removendo 'ai' do vocabulario no baseline.sql -> 3 casos vermelhos A segunda e a que prova que o teste le o schema de verdade em vez de fingir. Nos dois casos o arquivo foi restaurado byte a byte (git diff --stat vazio). A suite que ja existia (ai-response-worker-model-routing + ai-response-bot-veto) passa 6/6 COM o bug presente — era cega porque o stub dela aceita qualquer insert. Gates, exit medido direto: typecheck 0 | lint 0 erros | lint:channels 0 | test:unit 224 arquivos / 1895 testes (base sem o arquivo: 223 / 1891 — medido movendo o arquivo para fora, porque o reporter nao imprime arquivo que passa e "verde" nao prova que ele rodou). NAO MEDIDO: test:db (nao toca schema); build; e2e. E a CLASSE do defeito — nao varri o repo atras de outros literais TypeScript gravados em coluna com CHECK; lib/database.types.ts tipa essas colunas como `string`, entao o TS nao pega nenhum deles. Isso merece varredura propria, nao cabe aqui. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C4NxbuaBH2rNizak9HkDpd
1 parent 687716a commit 42d1a20

1 file changed

Lines changed: 333 additions & 0 deletions

File tree

Lines changed: 333 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,333 @@
1+
/**
2+
* O que este teste protege: **a resposta da IA tem que CABER no banco.**
3+
*
4+
* O worker monta a linha outbound em `persistAndDispatch` e a grava em
5+
* `public.messages`, que tem a constraint `messages_sent_via_check` limitando
6+
* `sent_via` a um vocabulário fechado. Um valor fora dele não é rejeitado pelo
7+
* TypeScript (`lib/database.types.ts` tipa a coluna como `string`) nem por
8+
* nenhum gate do CI — o insert só falha em runtime, contra o Postgres, com:
9+
*
10+
* outbound_insert_failed: new row for relation "messages" violates
11+
* check constraint "messages_sent_via_check"
12+
*
13+
* Foi exatamente o que aconteceu: a main gravava `sent_via: "bot"`, valor que
14+
* nunca esteve na constraint. Como o insert é o ÚLTIMO passo do pipeline, o
15+
* LLM já foi chamado e pago quando a linha estoura; o evento volta para
16+
* `event_log` e é retentado até morrer em `dead` (5 tentativas), queimando uma
17+
* invocação do provider por tentativa. Para o usuário, o bot simplesmente não
18+
* responde.
19+
*
20+
* Por que este arquivo é necessário mesmo com o `ai-response-worker-model-routing.test.ts`
21+
* verde: o stub daquele teste aceita QUALQUER insert. Ele exercita o caminho
22+
* inteiro (`sent_to_dispatch` só existe depois que `persistAndDispatch`
23+
* retorna) e ainda assim passava com o valor inválido — o mock não tinha a
24+
* constraint. Aqui o stub valida contra o vocabulário **lido do
25+
* `supabase/baseline.sql`**, que é o schema que o self-hoster realmente aplica.
26+
* Ler do schema em vez de repetir a lista no teste é o que impede o teste de
27+
* concordar com o worker e os dois estarem errados juntos.
28+
*/
29+
import { readFileSync } from "node:fs";
30+
import path from "node:path";
31+
32+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
33+
34+
// Env do self-host padrão: só a chave da Anthropic (o que o install.sh exige).
35+
// Sem chave de embedding o RAG é pulado, então a confiança é 0 — é isso que
36+
// deixa o gatilho G3 controlável só pelo `confidence_threshold` do agente.
37+
const envMock: Record<string, string> = {
38+
ANTHROPIC_API_KEY: "sk-ant-teste",
39+
AI_GATEWAY_API_KEY: "",
40+
AI_GATEWAY_BASE_URL: "",
41+
OPENROUTER_API_KEY: "",
42+
OPENROUTER_BASE_URL: "",
43+
OPENAI_API_KEY: "",
44+
};
45+
vi.mock("@/lib/env", () => ({
46+
get env() {
47+
return envMock;
48+
},
49+
}));
50+
vi.mock("@/lib/supabase/admin", () => ({ createAdminClient: vi.fn() }));
51+
vi.mock("@/lib/logger", () => ({
52+
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
53+
}));
54+
55+
import { processMessageReceived } from "@/workers/ai-response-worker";
56+
import { createAdminClient } from "@/lib/supabase/admin";
57+
import type { EventRow } from "@/lib/event-log/dispatcher";
58+
59+
const ORG_ID = "22222222-2222-4222-8222-222222222222";
60+
const CONV_ID = "44444444-4444-4444-8444-444444444444";
61+
const MSG_ID = "55555555-5555-4555-8555-555555555555";
62+
const CONTACT_ID = "66666666-6666-4666-8666-666666666666";
63+
const SESSION_ID = "77777777-7777-4777-8777-777777777777";
64+
const AGENT_ID = "88888888-8888-4888-8888-888888888888";
65+
const OUTBOUND_ID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
66+
67+
// Corpo neutro: qualquer gatilho de handoff (G1 "falar com humano", G4
68+
// "advogado") desviaria o fluxo ANTES do LLM e o insert nunca aconteceria.
69+
const INBOUND_BODY = "bom dia, qual o prazo de entrega?";
70+
71+
/**
72+
* Vocabulário de `sent_via` extraído do `supabase/baseline.sql` — o mesmo
73+
* arquivo que o `install.sh`/`update.sh` aplicam na VPS do cliente.
74+
*/
75+
function vocabularioDoSchema(): string[] {
76+
const baseline = readFileSync(
77+
path.resolve(process.cwd(), "supabase/baseline.sql"),
78+
"utf8",
79+
);
80+
const check = /CONSTRAINT "messages_sent_via_check" CHECK \(\("sent_via" = ANY \(ARRAY\[([^\]]+)\]/
81+
.exec(baseline);
82+
if (!check?.[1]) {
83+
throw new Error(
84+
"não achei messages_sent_via_check no supabase/baseline.sql — " +
85+
"se a constraint mudou de forma, conserte esta extração em vez de apagar o teste",
86+
);
87+
}
88+
return [...check[1].matchAll(/'([^']+)'/g)].map((m) => m[1] as string);
89+
}
90+
91+
const SENT_VIA_PERMITIDOS = vocabularioDoSchema();
92+
93+
interface LinhaInserida {
94+
table: string;
95+
row: Record<string, unknown>;
96+
}
97+
98+
/**
99+
* Stub do admin client que, ao contrário do usado no teste de roteamento de
100+
* modelo, APLICA a constraint de `sent_via` em `messages` — devolvendo o mesmo
101+
* formato de erro do PostgREST (código 23514) que o worker recebe do banco.
102+
*/
103+
function makeAdminStub(confidenceThreshold: number) {
104+
const inserted: LinhaInserida[] = [];
105+
106+
const from = (table: string) => {
107+
const single: Record<string, unknown> | null =
108+
table === "conversations"
109+
? {
110+
id: CONV_ID,
111+
organization_id: ORG_ID,
112+
contact_id: CONTACT_ID,
113+
channel_session_id: SESSION_ID,
114+
last_inbound_at: new Date().toISOString(),
115+
bot_silenced_until: null,
116+
last_handoff_at: null,
117+
assignee_kind: "ai",
118+
contacts: {
119+
id: CONTACT_ID,
120+
display_name: null, // sem PII em teste (LGPD)
121+
locale: "pt-BR",
122+
is_blocked: false,
123+
force_human: false,
124+
},
125+
}
126+
: table === "messages"
127+
? { id: MSG_ID, body: INBOUND_BODY, direction: "inbound", organization_id: ORG_ID }
128+
: table === "ai_agents"
129+
? {
130+
id: AGENT_ID,
131+
organization_id: ORG_ID,
132+
model: "anthropic/claude-sonnet-4-6",
133+
system_prompt: "Você é um atendente.",
134+
config: { confidence_threshold: confidenceThreshold },
135+
guardrails: {},
136+
active_kb_version_id: "99999999-9999-4999-8999-999999999999",
137+
is_active: true,
138+
is_default: true,
139+
}
140+
: null; // ai_budgets sem linha = sem throttle; crm_leads sem lead
141+
142+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
143+
const terminais: any = {
144+
maybeSingle: () => Promise.resolve({ data: single, error: null }),
145+
single: () => Promise.resolve({ data: single, error: null }),
146+
insert: (row: Record<string, unknown>) => {
147+
inserted.push({ table, row });
148+
149+
// É AQUI que este teste difere do irmão: o banco de verdade recusa um
150+
// `sent_via` fora do vocabulário, então o stub recusa também.
151+
const sentVia = row["sent_via"];
152+
const violaCheck =
153+
table === "messages" &&
154+
typeof sentVia === "string" &&
155+
!SENT_VIA_PERMITIDOS.includes(sentVia);
156+
const erro = violaCheck
157+
? {
158+
code: "23514",
159+
message:
160+
'new row for relation "messages" violates check constraint "messages_sent_via_check"',
161+
}
162+
: null;
163+
164+
return {
165+
select: () => ({
166+
single: () =>
167+
Promise.resolve(
168+
erro ? { data: null, error: erro } : { data: { id: OUTBOUND_ID }, error: null },
169+
),
170+
}),
171+
then: (resolve: (v: unknown) => unknown) =>
172+
Promise.resolve({ data: null, error: erro }).then(resolve),
173+
};
174+
},
175+
then: (resolve: (v: unknown) => unknown) =>
176+
Promise.resolve({
177+
data:
178+
table === "messages"
179+
? [
180+
{
181+
id: MSG_ID,
182+
body: INBOUND_BODY,
183+
direction: "inbound",
184+
created_at: new Date().toISOString(),
185+
},
186+
]
187+
: [],
188+
error: null,
189+
}).then(resolve),
190+
};
191+
192+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
193+
const chain: any = new Proxy(terminais, {
194+
get: (alvo, prop) => (prop in alvo ? alvo[prop as keyof typeof alvo] : () => chain),
195+
});
196+
return chain;
197+
};
198+
199+
const rpc = () => Promise.resolve({ data: [], error: null });
200+
201+
return { stub: { from, rpc }, inserted };
202+
}
203+
204+
const eventRow = {
205+
organization_id: ORG_ID,
206+
entity_id: MSG_ID,
207+
payload: { message_id: MSG_ID, conversation_id: CONV_ID },
208+
} as unknown as EventRow;
209+
210+
let fetchOriginal: typeof globalThis.fetch;
211+
212+
/** Instala o stub e devolve o registro de inserts para asserção. */
213+
function prepararWorker(confidenceThreshold: number): LinhaInserida[] {
214+
const { stub, inserted } = makeAdminStub(confidenceThreshold);
215+
vi.mocked(createAdminClient).mockReturnValue(
216+
stub as unknown as ReturnType<typeof createAdminClient>,
217+
);
218+
return inserted;
219+
}
220+
221+
function mensagemOutbound(inserted: LinhaInserida[]): Record<string, unknown> {
222+
const linhas = inserted.filter(
223+
(i) => i.table === "messages" && i.row["direction"] === "outbound",
224+
);
225+
// Anti-vacuidade: se o pipeline desviou antes do insert, não há o que
226+
// asseverar e o teste passaria à toa.
227+
expect(
228+
linhas.length,
229+
`nenhum insert outbound em messages — o pipeline não chegou a persistAndDispatch; ` +
230+
`inserts vistos: ${JSON.stringify(inserted.map((i) => i.table))}`,
231+
).toBe(1);
232+
return linhas[0]!.row;
233+
}
234+
235+
beforeEach(() => {
236+
vi.clearAllMocks();
237+
fetchOriginal = globalThis.fetch;
238+
globalThis.fetch = (async () =>
239+
new Response(
240+
JSON.stringify({
241+
id: "msg_stub",
242+
type: "message",
243+
role: "assistant",
244+
model: "claude-sonnet-4-6",
245+
content: [{ type: "text", text: "Nosso prazo é de 3 dias úteis." }],
246+
stop_reason: "end_turn",
247+
usage: { input_tokens: 10, output_tokens: 8 },
248+
}),
249+
{ status: 200, headers: { "content-type": "application/json" } },
250+
)) as typeof globalThis.fetch;
251+
});
252+
253+
afterEach(() => {
254+
globalThis.fetch = fetchOriginal;
255+
});
256+
257+
describe("ai-response-worker — a linha outbound cabe na constraint de sent_via", () => {
258+
it("o vocabulário veio mesmo do baseline (controle do instrumento)", () => {
259+
// Sem esta guarda, uma regex que parasse de casar devolveria lista vazia e
260+
// TODA asserção de pertinência abaixo passaria a ser trivialmente falsa —
261+
// ou, pior, um `includes` sobre lista vazia reprovaria por motivo errado.
262+
expect(SENT_VIA_PERMITIDOS.length).toBeGreaterThan(1);
263+
expect(SENT_VIA_PERMITIDOS).toContain("ai");
264+
expect(SENT_VIA_PERMITIDOS).not.toContain("bot");
265+
});
266+
267+
it("caminho normal: persiste com sent_via aceito pelo banco e despacha", async () => {
268+
const inserted = prepararWorker(0); // threshold 0 ⇒ G3 não dispara
269+
const result = await processMessageReceived(eventRow);
270+
271+
const row = mensagemOutbound(inserted);
272+
expect(
273+
SENT_VIA_PERMITIDOS,
274+
`sent_via=${JSON.stringify(row["sent_via"])} não está em messages_sent_via_check`,
275+
).toContain(row["sent_via"]);
276+
277+
expect(
278+
result.status,
279+
`reason: ${result.reason ?? "-"} | detail: ${result.detail ?? "(vazio)"}`,
280+
).toBe("sent_to_dispatch");
281+
expect(result.outbound_message_id).toBe(OUTBOUND_ID);
282+
});
283+
284+
it("caminho de handoff G3: o rascunho do bot também cabe na constraint", async () => {
285+
// O segundo call site de persistAndDispatch (skipDispatch: true). Sem RAG a
286+
// confiança é 0, então threshold 0.5 força o desvio por baixa confiança.
287+
const inserted = prepararWorker(0.5);
288+
const result = await processMessageReceived(eventRow);
289+
290+
const row = mensagemOutbound(inserted);
291+
expect(
292+
SENT_VIA_PERMITIDOS,
293+
`sent_via=${JSON.stringify(row["sent_via"])} não está em messages_sent_via_check`,
294+
).toContain(row["sent_via"]);
295+
296+
expect(result.status).toBe("skipped");
297+
expect(result.reason).toBe("handoff_g3_low_confidence");
298+
});
299+
300+
it("o defeito, explicitado: sent_via fora do vocabulário derruba o envio", async () => {
301+
// Prova que a asserção acima tem dente — que o `sent_to_dispatch` do
302+
// primeiro caso vem de o valor ser válido, e não de o stub aceitar tudo.
303+
const { stub, inserted } = makeAdminStub(0);
304+
const comValorInvalido = {
305+
...stub,
306+
from: (table: string) => {
307+
const chain = stub.from(table);
308+
if (table !== "messages") return chain;
309+
return new Proxy(chain, {
310+
get: (alvo, prop) =>
311+
prop === "insert"
312+
? (row: Record<string, unknown>) =>
313+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
314+
(alvo as any).insert({ ...row, sent_via: "bot" })
315+
: // eslint-disable-next-line @typescript-eslint/no-explicit-any
316+
(alvo as any)[prop],
317+
});
318+
},
319+
};
320+
vi.mocked(createAdminClient).mockReturnValue(
321+
comValorInvalido as unknown as ReturnType<typeof createAdminClient>,
322+
);
323+
324+
const result = await processMessageReceived(eventRow);
325+
326+
expect(mensagemOutbound(inserted)["sent_via"]).toBe("bot");
327+
// O worker transforma o erro do banco em throw; o handler do dispatcher
328+
// captura e devolve status "error", que o drain converte em retentativa.
329+
expect(result.status).toBe("error");
330+
expect(result.detail).toMatch(/outbound_insert_failed/);
331+
expect(result.detail).toMatch(/messages_sent_via_check/);
332+
});
333+
});

0 commit comments

Comments
 (0)