mirror of
https://github.com/melgarafael/DeskcommCRM.git
synced 2026-10-02 01:28:34 +08:00
fix(db): duas entidades que nao se conseguia apagar — organizacao e agente
Achados operando: ao remover as fixtures de E2E da producao, os dois erros
apareceram um depois do outro, cada um abortando a transacao inteira. Sao da
mesma familia — uma escrita AUTOMATICA (trigger de audit, SET NULL de FK)
reagindo ao DELETE e violando regra que vale para o estado normal, nao para a
remocao.
DEFEITO 1 · apagar uma ORGANIZACAO falhava. O cascade apaga os filhos e o
trigger de audit de cada um insere em api_audit_log com o organization_id de uma
org que ja nao existe. Agora o audit e pulado no DELETE quando a org sumiu — nao
se perde auditoria, porque essa linha seria apagada pelo cascade em seguida. A
checagem fica SO no ramo DELETE: um `exists` no INSERT/UPDATE cobraria um SELECT
em todo hot path de escrita para cobrir um caso que nao ocorre la.
DEFEITO 2 · apagar um AGENTE que ja atendeu falhava. A FK e ON DELETE SET NULL e
o CHECK exige owner_agent_id quando owner_kind='ai'; o SET NULL zerava um lado e
deixava o outro. Agora um BEFORE DELETE desfaz a atribuicao INTEIRA antes de a FK
agir, e o lead fica sem dono em vez de meio-atribuido. O CHECK NAO foi afrouxado:
tolerar 'ai' sem agente trocaria erro barulhento por dado incoerente em silencio
— ha teste cobrando isso.
TRES CATRACAS DO REPO ME PEGARAM AQUI:
- a varredura de hardening: minha funcao nova nasceu SECURITY DEFINER
executavel por `authenticated` e podendo escrever; meu revoke cobria
`public` e `anon` e faltou a terceira origem. Revogada — seguro porque o
unico call site e o trigger, e o Postgres nao exige EXECUTE do usuario para
invocar funcao de trigger;
- o hook de sequencia de migration: 0113 ja existe na main (e em 6 branches).
Renumerada para 0115, que e o proximo livre em TODAS as branches locais;
- e um caso MEU passava pelo motivo errado: o teste do CHECK montava o insert
por subquery e caia em `stage_id` nulo — teria seguido verde se alguem
afrouxasse o CHECK. Agora usa ids diretos.
Schema em tripla: migration 0115 + apendice no baseline + MANIFEST.
Evidencia observada: test:db verde (70 arquivos, 470 passed); typecheck limpo.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WKf64hXUbFatzofJHZREr7
This commit is contained in:
co-authored by
Claude Opus 5
parent
ac53eb61c3
commit
9f98784aaf
@@ -9177,3 +9177,129 @@ comment on column ai_agent_versions.operator_model is
|
||||
'Modelo do papel Operador. NULL = herda o modelo do agente.';
|
||||
|
||||
notify pgrst, 'reload schema';
|
||||
-- 0115 — duas entidades que não se conseguia apagar.
|
||||
--
|
||||
-- Achados ao remover as fixtures de E2E da produção em 2026-08-06. Os dois são
|
||||
-- da mesma família: uma escrita AUTOMÁTICA (trigger/FK) reagindo ao DELETE e
|
||||
-- violando uma regra que vale para o estado normal, mas não para a remoção.
|
||||
--
|
||||
-- ═══ DEFEITO 1 · não era possível apagar uma ORGANIZAÇÃO ═══
|
||||
--
|
||||
-- ERROR: insert or update on table "api_audit_log" violates foreign key
|
||||
-- constraint "api_audit_log_organization_id_fkey"
|
||||
-- DETAIL: Key (organization_id)=(…) is not present in table "organizations".
|
||||
--
|
||||
-- O cascade apaga os filhos, o trigger de audit de cada um insere em
|
||||
-- `api_audit_log` com o `organization_id` — e a organização já não existe. Só
|
||||
-- funcionava apagando os filhos à mão ANTES, com o pai vivo.
|
||||
--
|
||||
-- Conserto: no DELETE, o audit é pulado quando a organização já não existe. Não
|
||||
-- se perde auditoria: a linha que ele escreveria seria apagada pelo cascade da
|
||||
-- própria organização um instante depois. E a checagem fica SÓ no ramo DELETE —
|
||||
-- pôr um `exists` no INSERT/UPDATE cobraria um SELECT em todo hot path de
|
||||
-- escrita para proteger de um caso que não acontece lá.
|
||||
--
|
||||
-- ═══ DEFEITO 2 · não era possível apagar um AGENTE que já atendeu ═══
|
||||
--
|
||||
-- ERROR: new row for relation "crm_leads" violates check constraint
|
||||
-- "crm_leads_owner_kind_coherence"
|
||||
--
|
||||
-- `crm_leads_owner_agent_id_fkey` é ON DELETE SET NULL; o CHECK exige
|
||||
-- `owner_agent_id not null` quando `owner_kind = 'ai'`. O SET NULL zera um lado
|
||||
-- e deixa o outro — estado que a constraint proíbe, com razão.
|
||||
--
|
||||
-- Conserto: um BEFORE DELETE em `ai_agents` desfaz a atribuição INTEIRA (os dois
|
||||
-- campos), antes de a FK agir. O lead fica sem dono (`owner_kind is null`, que o
|
||||
-- CHECK aceita) em vez de ficar num estado meio-atribuído.
|
||||
--
|
||||
-- Não se enfraquece o CHECK para tolerar `'ai'` sem agente: ele descreve um
|
||||
-- invariante verdadeiro, e afrouxá-lo para acomodar uma operação rara trocaria
|
||||
-- um erro barulhento por dados incoerentes em silêncio.
|
||||
|
||||
-- ── 1 · o audit não persegue uma organização que está sendo removida ────────
|
||||
create or replace function public.fn_audit_log_row() returns trigger
|
||||
language plpgsql security definer
|
||||
set search_path to 'public'
|
||||
as $$
|
||||
declare
|
||||
v_action text;
|
||||
v_org uuid;
|
||||
begin
|
||||
if tg_op = 'INSERT' then
|
||||
v_action := tg_table_name || '.created';
|
||||
v_org := new.organization_id;
|
||||
elsif tg_op = 'UPDATE' then
|
||||
v_action := tg_table_name || '.updated';
|
||||
v_org := new.organization_id;
|
||||
elsif tg_op = 'DELETE' then
|
||||
v_action := tg_table_name || '.deleted';
|
||||
v_org := old.organization_id;
|
||||
|
||||
-- A organização está indo embora (cascade em curso). Registrar a exclusão
|
||||
-- de um filho num tenant que deixa de existir não tem consumidor: a linha
|
||||
-- seria apagada pelo cascade em seguida — e tentar escrevê-la aborta a
|
||||
-- transação inteira, que era o defeito.
|
||||
--
|
||||
-- SÓ no ramo DELETE: um `exists` no INSERT/UPDATE cobraria um SELECT em
|
||||
-- todo hot path de escrita para cobrir um caso que não ocorre lá.
|
||||
if v_org is not null and not exists (select 1 from public.organizations where id = v_org) then
|
||||
return old;
|
||||
end if;
|
||||
end if;
|
||||
|
||||
insert into public.api_audit_log (organization_id, actor_user_id, action, resource_type, resource_id, metadata)
|
||||
values (
|
||||
v_org,
|
||||
auth.uid(),
|
||||
v_action,
|
||||
tg_table_name,
|
||||
coalesce(new.id, old.id),
|
||||
case when tg_op = 'UPDATE'
|
||||
then jsonb_build_object('changed_fields', '[diff suppressed in v0.1]')
|
||||
else '{}'::jsonb
|
||||
end
|
||||
);
|
||||
|
||||
return coalesce(new, old);
|
||||
end $$;
|
||||
|
||||
-- ── 2 · apagar um agente desfaz a atribuição inteira, não metade dela ───────
|
||||
create or replace function public.fn_liberar_leads_do_agente() returns trigger
|
||||
language plpgsql security definer
|
||||
set search_path to 'public'
|
||||
as $$
|
||||
begin
|
||||
-- ANTES de a FK aplicar seu SET NULL. Zera os DOIS campos: deixar
|
||||
-- `owner_kind = 'ai'` com o agente nulo é exatamente o estado que
|
||||
-- `crm_leads_owner_kind_coherence` proíbe.
|
||||
update public.crm_leads
|
||||
set owner_agent_id = null,
|
||||
owner_kind = null
|
||||
where owner_agent_id = old.id;
|
||||
return old;
|
||||
end $$;
|
||||
|
||||
-- As TRÊS origens de EXECUTE (CLAUDE.md, doutrina de migrations):
|
||||
-- `public` — o grant que o Postgres dá a toda função ao criá-la;
|
||||
-- `anon` — o ALTER DEFAULT PRIVILEGES do baseline, que alcança toda
|
||||
-- função criada depois dele;
|
||||
-- `authenticated` — idem, e é o que a varredura de hardening cobra.
|
||||
--
|
||||
-- Revogar de todas é seguro AQUI porque o único call site é o TRIGGER, e o
|
||||
-- Postgres não exige EXECUTE do usuário para invocar função de trigger. Nenhuma
|
||||
-- sessão chama esta função diretamente.
|
||||
revoke execute on function public.fn_liberar_leads_do_agente() from public, anon, authenticated;
|
||||
grant execute on function public.fn_liberar_leads_do_agente() to service_role;
|
||||
|
||||
drop trigger if exists trg_liberar_leads_do_agente on public.ai_agents;
|
||||
create trigger trg_liberar_leads_do_agente
|
||||
before delete on public.ai_agents
|
||||
for each row execute function public.fn_liberar_leads_do_agente();
|
||||
|
||||
comment on function public.fn_liberar_leads_do_agente() is
|
||||
'Migration 0115: desfaz a atribuição de leads antes de o agente ser apagado. '
|
||||
'Sem isto o SET NULL da FK zera owner_agent_id e deixa owner_kind=''ai'', '
|
||||
'violando crm_leads_owner_kind_coherence — e um agente que já atendeu alguém '
|
||||
'não podia ser removido.';
|
||||
|
||||
notify pgrst, 'reload schema';
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
-- 0115 — duas entidades que não se conseguia apagar.
|
||||
--
|
||||
-- Achados ao remover as fixtures de E2E da produção em 2026-08-06. Os dois são
|
||||
-- da mesma família: uma escrita AUTOMÁTICA (trigger/FK) reagindo ao DELETE e
|
||||
-- violando uma regra que vale para o estado normal, mas não para a remoção.
|
||||
--
|
||||
-- ═══ DEFEITO 1 · não era possível apagar uma ORGANIZAÇÃO ═══
|
||||
--
|
||||
-- ERROR: insert or update on table "api_audit_log" violates foreign key
|
||||
-- constraint "api_audit_log_organization_id_fkey"
|
||||
-- DETAIL: Key (organization_id)=(…) is not present in table "organizations".
|
||||
--
|
||||
-- O cascade apaga os filhos, o trigger de audit de cada um insere em
|
||||
-- `api_audit_log` com o `organization_id` — e a organização já não existe. Só
|
||||
-- funcionava apagando os filhos à mão ANTES, com o pai vivo.
|
||||
--
|
||||
-- Conserto: no DELETE, o audit é pulado quando a organização já não existe. Não
|
||||
-- se perde auditoria: a linha que ele escreveria seria apagada pelo cascade da
|
||||
-- própria organização um instante depois. E a checagem fica SÓ no ramo DELETE —
|
||||
-- pôr um `exists` no INSERT/UPDATE cobraria um SELECT em todo hot path de
|
||||
-- escrita para proteger de um caso que não acontece lá.
|
||||
--
|
||||
-- ═══ DEFEITO 2 · não era possível apagar um AGENTE que já atendeu ═══
|
||||
--
|
||||
-- ERROR: new row for relation "crm_leads" violates check constraint
|
||||
-- "crm_leads_owner_kind_coherence"
|
||||
--
|
||||
-- `crm_leads_owner_agent_id_fkey` é ON DELETE SET NULL; o CHECK exige
|
||||
-- `owner_agent_id not null` quando `owner_kind = 'ai'`. O SET NULL zera um lado
|
||||
-- e deixa o outro — estado que a constraint proíbe, com razão.
|
||||
--
|
||||
-- Conserto: um BEFORE DELETE em `ai_agents` desfaz a atribuição INTEIRA (os dois
|
||||
-- campos), antes de a FK agir. O lead fica sem dono (`owner_kind is null`, que o
|
||||
-- CHECK aceita) em vez de ficar num estado meio-atribuído.
|
||||
--
|
||||
-- Não se enfraquece o CHECK para tolerar `'ai'` sem agente: ele descreve um
|
||||
-- invariante verdadeiro, e afrouxá-lo para acomodar uma operação rara trocaria
|
||||
-- um erro barulhento por dados incoerentes em silêncio.
|
||||
|
||||
-- ── 1 · o audit não persegue uma organização que está sendo removida ────────
|
||||
create or replace function public.fn_audit_log_row() returns trigger
|
||||
language plpgsql security definer
|
||||
set search_path to 'public'
|
||||
as $$
|
||||
declare
|
||||
v_action text;
|
||||
v_org uuid;
|
||||
begin
|
||||
if tg_op = 'INSERT' then
|
||||
v_action := tg_table_name || '.created';
|
||||
v_org := new.organization_id;
|
||||
elsif tg_op = 'UPDATE' then
|
||||
v_action := tg_table_name || '.updated';
|
||||
v_org := new.organization_id;
|
||||
elsif tg_op = 'DELETE' then
|
||||
v_action := tg_table_name || '.deleted';
|
||||
v_org := old.organization_id;
|
||||
|
||||
-- A organização está indo embora (cascade em curso). Registrar a exclusão
|
||||
-- de um filho num tenant que deixa de existir não tem consumidor: a linha
|
||||
-- seria apagada pelo cascade em seguida — e tentar escrevê-la aborta a
|
||||
-- transação inteira, que era o defeito.
|
||||
--
|
||||
-- SÓ no ramo DELETE: um `exists` no INSERT/UPDATE cobraria um SELECT em
|
||||
-- todo hot path de escrita para cobrir um caso que não ocorre lá.
|
||||
if v_org is not null and not exists (select 1 from public.organizations where id = v_org) then
|
||||
return old;
|
||||
end if;
|
||||
end if;
|
||||
|
||||
insert into public.api_audit_log (organization_id, actor_user_id, action, resource_type, resource_id, metadata)
|
||||
values (
|
||||
v_org,
|
||||
auth.uid(),
|
||||
v_action,
|
||||
tg_table_name,
|
||||
coalesce(new.id, old.id),
|
||||
case when tg_op = 'UPDATE'
|
||||
then jsonb_build_object('changed_fields', '[diff suppressed in v0.1]')
|
||||
else '{}'::jsonb
|
||||
end
|
||||
);
|
||||
|
||||
return coalesce(new, old);
|
||||
end $$;
|
||||
|
||||
-- ── 2 · apagar um agente desfaz a atribuição inteira, não metade dela ───────
|
||||
create or replace function public.fn_liberar_leads_do_agente() returns trigger
|
||||
language plpgsql security definer
|
||||
set search_path to 'public'
|
||||
as $$
|
||||
begin
|
||||
-- ANTES de a FK aplicar seu SET NULL. Zera os DOIS campos: deixar
|
||||
-- `owner_kind = 'ai'` com o agente nulo é exatamente o estado que
|
||||
-- `crm_leads_owner_kind_coherence` proíbe.
|
||||
update public.crm_leads
|
||||
set owner_agent_id = null,
|
||||
owner_kind = null
|
||||
where owner_agent_id = old.id;
|
||||
return old;
|
||||
end $$;
|
||||
|
||||
-- As TRÊS origens de EXECUTE (CLAUDE.md, doutrina de migrations):
|
||||
-- `public` — o grant que o Postgres dá a toda função ao criá-la;
|
||||
-- `anon` — o ALTER DEFAULT PRIVILEGES do baseline, que alcança toda
|
||||
-- função criada depois dele;
|
||||
-- `authenticated` — idem, e é o que a varredura de hardening cobra.
|
||||
--
|
||||
-- Revogar de todas é seguro AQUI porque o único call site é o TRIGGER, e o
|
||||
-- Postgres não exige EXECUTE do usuário para invocar função de trigger. Nenhuma
|
||||
-- sessão chama esta função diretamente.
|
||||
revoke execute on function public.fn_liberar_leads_do_agente() from public, anon, authenticated;
|
||||
grant execute on function public.fn_liberar_leads_do_agente() to service_role;
|
||||
|
||||
drop trigger if exists trg_liberar_leads_do_agente on public.ai_agents;
|
||||
create trigger trg_liberar_leads_do_agente
|
||||
before delete on public.ai_agents
|
||||
for each row execute function public.fn_liberar_leads_do_agente();
|
||||
|
||||
comment on function public.fn_liberar_leads_do_agente() is
|
||||
'Migration 0115: desfaz a atribuição de leads antes de o agente ser apagado. '
|
||||
'Sem isto o SET NULL da FK zera owner_agent_id e deixa owner_kind=''ai'', '
|
||||
'violando crm_leads_owner_kind_coherence — e um agente que já atendeu alguém '
|
||||
'não podia ser removido.';
|
||||
@@ -151,6 +151,7 @@ aplica.
|
||||
| `20260805200000` | `0110_lead_checkpoints_declaracao` | Spec 16 §5: `lead_checkpoints` ganha `declaracao jsonb` — a fronteira DECLARADA entre FALAR e OPERAR. O Conversador fecha o turno dizendo, em linguagem de negócio, o que a pessoa quer (`intencoes`) e o que foi prometido a ela (`promessas`); nenhum nome de ferramenta, id ou estágio entra, porque o vocabulário no contexto de quem fala é o defeito medido (30% de vazamento com prompt de operador). Viaja na chamada de fechamento que já existe — imposta pelo runtime, custo zero — em vez de numa tool, que o modelo esqueceria justamente no turno que importa. **NULLABLE de propósito:** NULL = não declarou; `{"nada_a_declarar":true}` = avaliou e não havia nada. São estados distintos, e um `not null default` colapsaria os dois, escondendo o esquecimento que o invariante 4 manda mostrar. Sem CHECK de shape — a validação real é o Zod `.strict()` de `lib/agent-engine/agent/declaracao.ts`. Aditiva e idempotente. |
|
||||
| `20260806100000` | `0111_operator_turn` | Spec 16 §3.2: nasce o papel OPERADOR — `job_queue.kind` aceita `operator_turn`, e `ai_agent_versions` ganha `operator_enabled` (default **false**) + `operator_model` (NULL = herda). O disparo é imposto pelo runtime ao fim do turno do Conversador, nunca por decisão do modelo: um Conversador que 'chama' o Operador devolve o problema inteiro — volta a depender de o modelo lembrar, e o turno em que ele não achasse necessário seria um lead parado no funil, em silêncio. **São DOIS CHECKs e o segundo é o que quebra se esquecido:** `job_queue` exige coerência entre kind e `contact_id`, e `operator_turn` TEM contato — sem estender essa lista todo insert viola a constraint e o Operador nunca roda (o nome dela é anônimo em bancos antigos, daí a busca no catálogo). `operator_enabled` nasce false porque migration não liga sozinha um papel que gasta uma chamada de modelo por turno na chave do self-hoster. Aditiva e idempotente. |
|
||||
| `20260806140000` | `0112_operator_tool_ids` | Spec 16 §6: `ai_agent_versions.operator_tool_ids` — as capacidades do papel OPERADOR, em coluna PRÓPRIA e não reusando `tool_ids`. Três razões que se somam: (1) **a tela não pode mentir** — lista compartilhada faria a seção 'Operador' configurar o que o Conversador executa, ensinando um modelo mental que o motor não implementa; (2) **o teto de 20 se resolve por divisão, não por aumento** — hoje o Conversador carrega até 12 nativas + 20 de catálogo = 32 num prompt só, e o e2e `capacidades-do-agente` está fora do CI porque ligar 'Atender' estoura o teto; (3) **o passo 6 vira migration de DADOS** — tirar as ferramentas de escrita do Conversador passa a ser mover ids entre colunas, com rollback trivial. Default `'{}'`: o papel nasce sem mão, porque herdar as do Conversador em silêncio daria 20 capacidades a quem não escolheu nenhuma. Aditiva e idempotente. |
|
||||
| `20260806180000` | `0115_deletar_org_e_agente` | Duas entidades que **não se conseguia apagar**, achadas ao remover as fixtures de E2E da produção. Mesma família: uma escrita AUTOMÁTICA reagindo ao DELETE e violando regra que vale para o estado normal, não para a remoção. **(1)** Apagar uma ORGANIZAÇÃO falhava — o cascade apaga os filhos e o trigger de audit de cada um insere em `api_audit_log` com o `organization_id` de uma org que já não existe (`api_audit_log_organization_id_fkey`). Agora o audit é pulado no DELETE quando a org já sumiu; nada se perde, porque essa linha seria apagada pelo cascade em seguida. A checagem fica SÓ no ramo DELETE — um `exists` no INSERT/UPDATE cobraria um SELECT em todo hot path de escrita. **(2)** Apagar um AGENTE que já atendeu falhava — `crm_leads_owner_agent_id_fkey` é ON DELETE SET NULL e o CHECK `crm_leads_owner_kind_coherence` exige agente quando `owner_kind='ai'`; o SET NULL zerava um lado e deixava o outro. Agora um BEFORE DELETE desfaz a atribuição INTEIRA (os dois campos) antes de a FK agir. O CHECK **não** foi afrouxado: ele descreve invariante verdadeiro, e tolerar `'ai'` sem agente trocaria erro barulhento por dado incoerente em silêncio. Idempotente (`create or replace` + `drop trigger if exists`). |
|
||||
|
||||
## Reproducibility
|
||||
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import pg from "pg";
|
||||
|
||||
/**
|
||||
* Duas entidades que não se conseguia apagar (migration 0115).
|
||||
*
|
||||
* Achados operando: ao remover as fixtures de E2E da produção, os dois erros
|
||||
* apareceram um depois do outro, cada um abortando a transação inteira.
|
||||
*
|
||||
* São da mesma família — uma escrita AUTOMÁTICA (trigger de audit, SET NULL de
|
||||
* FK) reagindo ao DELETE e violando uma regra que vale para o estado normal, mas
|
||||
* não para a remoção. Por isso vivem no mesmo arquivo: quem mexer num vai ler o
|
||||
* outro.
|
||||
*
|
||||
* Invariante de banco e não teste de unidade porque o que quebrava era
|
||||
* CONSTRAINT e TRIGGER — nenhum dos dois existe fora do Postgres, e nenhum
|
||||
* typecheck os alcança.
|
||||
*/
|
||||
const container = process.env.TEST_DB_CONTAINER;
|
||||
if (!container) {
|
||||
throw new Error("TEST_DB_CONTAINER not set — rode via `pnpm test:db` (scripts/test-db.sh)");
|
||||
}
|
||||
|
||||
const PORT = Number(process.env.TEST_DB_PORT ?? 54329);
|
||||
const pool = new pg.Pool({
|
||||
connectionString: `postgresql://postgres:postgres@127.0.0.1:${PORT}/postgres`,
|
||||
max: 2,
|
||||
});
|
||||
|
||||
const ORG = "de1e7e00-0000-4000-8000-000000000001";
|
||||
const ORG2 = "de1e7e00-0000-4000-8000-000000000002";
|
||||
|
||||
async function criarOrg(id: string, slug: string): Promise<void> {
|
||||
await pool.query(
|
||||
`insert into organizations (id, slug, legal_name, display_name)
|
||||
values ($1, $2, 'Org Delete LTDA', 'Org Delete') on conflict (id) do nothing`,
|
||||
[id, slug],
|
||||
);
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
await criarOrg(ORG, "org-delete-1");
|
||||
await criarOrg(ORG2, "org-delete-2");
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await pool.query("delete from organizations where id = any($1)", [[ORG, ORG2]]);
|
||||
await pool.end();
|
||||
});
|
||||
|
||||
describe("apagar uma ORGANIZAÇÃO", () => {
|
||||
it("funciona mesmo com filhos que emitem audit — era o defeito 1", async () => {
|
||||
// Contato e lead disparam o trigger de audit ao serem apagados pelo cascade.
|
||||
// Antes da 0115, cada um tentava inserir em `api_audit_log` com o
|
||||
// organization_id de uma org que já não existia → FK violation, transação
|
||||
// inteira abortada.
|
||||
const contato = "de1e7e00-0000-4000-8000-0000000000c1";
|
||||
await pool.query(
|
||||
`insert into contacts (id, organization_id, name, phone_number)
|
||||
values ($1, $2, 'Lead do delete', '+5511900000777') on conflict (id) do nothing`,
|
||||
[contato, ORG],
|
||||
);
|
||||
const { rows: antes } = await pool.query<{ n: string }>(
|
||||
"select count(*) as n from contacts where organization_id = $1",
|
||||
[ORG],
|
||||
);
|
||||
expect(Number(antes[0]!.n), "controle: o filho existe antes do delete").toBeGreaterThan(0);
|
||||
|
||||
// O delete DIRETO da organização — sem apagar filhos à mão antes.
|
||||
await expect(pool.query("delete from organizations where id = $1", [ORG])).resolves.toBeDefined();
|
||||
|
||||
const { rows: depois } = await pool.query<{ n: string }>(
|
||||
"select count(*) as n from organizations where id = $1",
|
||||
[ORG],
|
||||
);
|
||||
expect(depois[0]!.n).toBe("0");
|
||||
});
|
||||
});
|
||||
|
||||
describe("apagar um AGENTE que já atendeu", () => {
|
||||
it("funciona, e o lead fica SEM dono em vez de meio-atribuído — era o defeito 2", async () => {
|
||||
const agente = "de1e7e00-0000-4000-8000-0000000000a1";
|
||||
const pipeline = "de1e7e00-0000-4000-8000-0000000000b1";
|
||||
const stage = "de1e7e00-0000-4000-8000-0000000000c2";
|
||||
const lead = "de1e7e00-0000-4000-8000-0000000000d1";
|
||||
|
||||
await pool.query(
|
||||
`insert into ai_agents (id, organization_id, name, kind, system_prompt, model)
|
||||
values ($1, $2, 'Agente que atendeu', 'mcp_agent', 'oi', 'claude-sonnet-4-6')
|
||||
on conflict (id) do nothing`,
|
||||
[agente, ORG2],
|
||||
);
|
||||
await pool.query(
|
||||
`insert into crm_pipelines (id, organization_id, name, slug) values ($1, $2, 'Funil do delete', 'funil-do-delete')
|
||||
on conflict (id) do nothing`,
|
||||
[pipeline, ORG2],
|
||||
);
|
||||
await pool.query(
|
||||
`insert into crm_stages (id, organization_id, pipeline_id, name, slug, position)
|
||||
values ($1, $2, $3, 'Etapa', 'etapa-do-delete', 1) on conflict (id) do nothing`,
|
||||
[stage, ORG2, pipeline],
|
||||
);
|
||||
// O lead ATRIBUÍDO ao agente: `owner_kind='ai'` + `owner_agent_id` — o par
|
||||
// que `crm_leads_owner_kind_coherence` exige que ande junto.
|
||||
await pool.query(
|
||||
`insert into crm_leads (id, organization_id, pipeline_id, stage_id, title, owner_kind, owner_agent_id)
|
||||
values ($1, $2, $3, $4, 'Lead da IA', 'ai', $5) on conflict (id) do nothing`,
|
||||
[lead, ORG2, pipeline, stage, agente],
|
||||
);
|
||||
|
||||
const { rows: antes } = await pool.query<{ owner_kind: string | null; owner_agent_id: string | null }>(
|
||||
"select owner_kind, owner_agent_id from crm_leads where id = $1",
|
||||
[lead],
|
||||
);
|
||||
expect(antes[0]!.owner_kind, "controle: o lead nasce atribuído à IA").toBe("ai");
|
||||
expect(antes[0]!.owner_agent_id).toBe(agente);
|
||||
|
||||
// Antes da 0115: o SET NULL da FK zerava `owner_agent_id` e deixava
|
||||
// `owner_kind='ai'` — estado que o CHECK proíbe. O delete abortava.
|
||||
await expect(pool.query("delete from ai_agents where id = $1", [agente])).resolves.toBeDefined();
|
||||
|
||||
const { rows: depois } = await pool.query<{ owner_kind: string | null; owner_agent_id: string | null }>(
|
||||
"select owner_kind, owner_agent_id from crm_leads where id = $1",
|
||||
[lead],
|
||||
);
|
||||
// O lead SOBREVIVE — apagar o agente não pode levar o negócio junto.
|
||||
expect(depois, "o lead não pode sumir com o agente").toHaveLength(1);
|
||||
// E fica coerente: sem dono nos DOIS campos, não meio-atribuído.
|
||||
expect(depois[0]!.owner_agent_id).toBeNull();
|
||||
expect(depois[0]!.owner_kind).toBeNull();
|
||||
});
|
||||
|
||||
it("o CHECK continua VALENDO — o conserto não afrouxou o invariante", async () => {
|
||||
// A saída preguiçosa seria tolerar `owner_kind='ai'` sem agente. Isso
|
||||
// trocaria um erro barulhento por dado incoerente em silêncio, e este caso
|
||||
// reprova quem tentar.
|
||||
// Ids diretos, não subquery: a versão anterior deste caso caía em
|
||||
// `stage_id` nulo e "passava" pelo motivo errado — teria seguido verde se
|
||||
// alguém afrouxasse o CHECK.
|
||||
await expect(
|
||||
pool.query(
|
||||
`insert into crm_leads (organization_id, pipeline_id, stage_id, title, owner_kind, owner_agent_id)
|
||||
values ($1, $2, $3, 'Lead incoerente', 'ai', null)`,
|
||||
[ORG2, "de1e7e00-0000-4000-8000-0000000000b1", "de1e7e00-0000-4000-8000-0000000000c2"],
|
||||
),
|
||||
).rejects.toThrow(/owner_kind_coherence|violates check constraint/i);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user