fix(atendimento): progressão de fronteira deixa de ser lida como conflito

Dois lugares tratavam "o mundo avançou de nada para algo" como "o mundo mudou
sob mim". É a mesma forma da guarda de colisão de fusão que este PR já
consertou: o caminho ORDINÁRIO lido como conflito.

1 · `assertCurrentServiceBoundary` comparava `demanda_id` por igualdade crua,
    mas a 0222 só incrementa `service_revision` quando TROCA de demanda — ir de
    "nenhuma demanda" para a primeira é o MESMO atendimento, por decisão do
    schema. O TypeScript discordava do SQL.

    O custo não era um teste: o gatilho de silêncio captura a fronteira de um
    contato CALADO (que por definição não tem demanda aberta) e o nó
    `ai_classify` espera o inbound do lead. Era essa resposta que abria a
    primeira demanda e vencia o acompanhamento que ela acabara de acordar — o
    nó ficava morto por construção, em produção.

2 · O CAS de `fn_service_begin` comparava a fronteira atual contra o literal
    `{"absent":true}`, que difere sempre. Um lead criado e depois movido de
    etapa gera dois eventos observados como `absent`: resolver o primeiro cria
    a conversa e o segundo morria com 40001 — que `serviceForEvent` engole como
    `stale_origin`. O follow-up de etapa não nascia, sem erro em lugar nenhum.

Nos dois casos o que se recusa continua sendo o atendimento OUTRO: demanda
fechada, conversa terminal, e trocar de demanda ou reabrir (que incrementam a
revisão). O estado sucessor admitido é exatamente um.

Também neste commit:

· `followup-builder`: a 6ª opção de gatilho não é decorativa. `appointment_no_show`
  tem motor ponta a ponta — `fn_appointment_change` emite
  `appointment.outcome_confirmed`, `gatilho-presenca.handler.ts` consome e está
  REGISTRADO em `register-handlers.ts`, e chama `fn_appointment_recover`, que
  insere em `followup_enrollments`. A lista da spec é que estava velha.

· `roteamento-por-canal`: a guarda `assertNoForeignRoutingDue` exigia fila
  global vazia, e nenhum outro ponto da suíte drena essa fila — era
  insatisfazível por construção. Medido nas duas pontas antes de trocar: mesma
  recusa com 152 specs antes e com 18. Agora ADIA o que não é da org (empurra
  `next_attempt_at`), em vez de recusar.

· gatilhos de etapa e de caso: `status:"ok"` descarta o `detail` no dreno, e era
  justamente ali que morava `origem_obsoleta=`. Com `matched && enrolled > 0`,
  o motivo de o follow-up NÃO nascer passa a chegar à linha do event_log.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Rafael Melgaço
2026-09-07 14:56:10 -03:00
co-authored by Claude Opus 5
parent 09c102548c
commit 7afb285724
9 changed files with 387 additions and 63 deletions
+30 -2
View File
@@ -17,6 +17,35 @@ export class StaleServiceBoundaryError extends Error {
this.name = "StaleServiceBoundaryError";
}
}
/**
* ABRIR A PRIMEIRA DEMANDA NÃO É ATENDIMENTO NOVO — e quem diz isso é o schema.
*
* A migration 0222 só incrementa `service_revision` quando TROCA de demanda
* (`current_demanda_id is not null and current_demanda_id <> d.id`); ir de
* "nenhuma demanda" para a primeira mantém a revisão de propósito, porque é o
* mesmo atendimento. Comparar `demanda_id` por igualdade crua fazia o
* TypeScript discordar do SQL sobre o que é "o mesmo atendimento".
*
* O custo disso não era só um teste vermelho: o gatilho de silêncio captura a
* fronteira de um contato CALADO — que, por definição, não tem demanda aberta —
* e o nó `ai_classify` fica em `waiting_reply` esperando o inbound do lead. Era
* exatamente essa resposta que abria a primeira demanda e, pelo predicado
* estrito, vencia o acompanhamento que ela acabara de acordar. O nó ficava
* morto por construção, em produção e não só no e2e.
*
* O que continua vetando quando `expected.demanda_id` é nulo: a demanda fechar
* (`current.demanda_fechada_em`), a conversa virar terminal (`status`), e
* TROCAR de demanda ou reabrir — os dois incrementam `service_revision`. E o
* estado sucessor admitido é exatamente UM: uma segunda demanda na mesma
* revisão é impossível pela regra da 0222 acima.
*/
function demandaTrocou(expected: ServiceBoundary, current: CurrentServiceBoundary): boolean {
if (expected.demanda_id === null) return false;
return (
expected.demanda_id !== current.demanda_id ||
expected.demanda_revision !== current.demanda_revision
);
}
export function assertCurrentServiceBoundary(
expected: ServiceBoundary | null,
current: CurrentServiceBoundary | null,
@@ -32,8 +61,7 @@ export function assertCurrentServiceBoundary(
expected.contact_id !== current.contact_id ||
expected.conversation_id !== current.conversation_id ||
expected.service_revision !== current.service_revision ||
expected.demanda_id !== current.demanda_id ||
expected.demanda_revision !== current.demanda_revision
demandaTrocou(expected, current)
) {
throw new StaleServiceBoundaryError();
}
+7 -1
View File
@@ -38,7 +38,13 @@ export const followupGatilhoCasoHandler: EventHandler = {
);
return {
consumer_key: FOLLOWUP_GATILHO_CASO_HANDLER_KEY,
status: summary.matched ? "ok" : "skipped",
// `ok` DESCARTA o `detail` (lib/event-log/drain.ts): só o de `skipped`
// sobrevive na linha do event_log. Enquanto o único desfecho mudo era
// "nenhum fluxo armado" isso custava pouco; com o portão de origem
// (`origem_obsoleta`) o motivo de o follow-up NÃO nascer virou
// invisível — e um e2e vermelho passou a não dizer por quê. Casar
// `matched` com `enrolled > 0` faz o contador chegar à linha.
status: summary.matched && summary.enrolled > 0 ? "ok" : "skipped",
// Cada contador aqui existe porque, sem ele, um desfecho vira
// indistinguível de outro. `vencidos` e `sem_contato` são os dois casos
// em que o gatilho ESTAVA armado e mesmo assim ninguém foi enrollado —
+7 -1
View File
@@ -31,7 +31,13 @@ export const followupGatilhoEtapaHandler: EventHandler = {
);
return {
consumer_key: FOLLOWUP_GATILHO_ETAPA_HANDLER_KEY,
status: summary.matched ? "ok" : "skipped",
// `ok` DESCARTA o `detail` (lib/event-log/drain.ts): só o de `skipped`
// sobrevive na linha do event_log. Enquanto o único desfecho mudo era
// "nenhum fluxo armado" isso custava pouco; com o portão de origem
// (`origem_obsoleta`) o motivo de o follow-up NÃO nascer virou
// invisível — e um e2e vermelho passou a não dizer por quê. Casar
// `matched` com `enrolled > 0` faz o contador chegar à linha.
status: summary.matched && summary.enrolled > 0 ? "ok" : "skipped",
// `sem_contato` vai no detail de propósito: é o desfecho em que o
// gatilho estava armado e mesmo assim ninguém foi enrollado. Sem ele,
// esse caso seria indistinguível de "nenhum fluxo armado".
+17 -1
View File
@@ -19309,7 +19309,23 @@ begin
raise exception 'service_scope_mismatch' using errcode='23503'; end if;
if c.id is null then
if p_observed->>'absent' is distinct from 'true' then raise exception 'service_stale' using errcode='40001'; end if;
elsif public.fn_service_boundary(p_org,c.id) is distinct from p_observed then
-- "NAO HAVIA CONVERSA / HA AGORA" E PROGRESSAO, NAO CONFLITO.
--
-- Este CAS existe para impedir que trabalho antigo aja sobre um atendimento
-- que MUDOU debaixo dele. Quando a observacao disse `absent`, nao havia
-- atendimento nenhum em voo — nada podia ter mudado sob o chamador, e a
-- conversa que apareceu e a PRIMEIRA. Comparar a fronteira de agora contra
-- o literal `{"absent":true}` difere sempre, entao o ramo abaixo levantava
-- `service_stale` no caminho ORDINARIO: lead criado e depois movido de
-- etapa gera dois eventos observados como `absent`; resolver o primeiro cria
-- a conversa e o segundo morria — e `serviceForEvent` engole o 40001 como
-- `stale_origin`, entao o follow-up simplesmente nao nascia, calado.
--
-- Irma da regra em `lib/atendimento/fronteira.ts`: ali, abrir a PRIMEIRA
-- demanda tambem nao vence a fronteira. Nos dois casos o que se recusa e o
-- atendimento OUTRO, nunca o atendimento que acabou de comecar.
elsif p_observed->>'absent' is distinct from 'true'
and public.fn_service_boundary(p_org,c.id) is distinct from p_observed then
raise exception 'service_stale' using errcode='40001';
end if;
end if;
@@ -301,7 +301,23 @@ begin
raise exception 'service_scope_mismatch' using errcode='23503'; end if;
if c.id is null then
if p_observed->>'absent' is distinct from 'true' then raise exception 'service_stale' using errcode='40001'; end if;
elsif public.fn_service_boundary(p_org,c.id) is distinct from p_observed then
-- "NAO HAVIA CONVERSA / HA AGORA" E PROGRESSAO, NAO CONFLITO.
--
-- Este CAS existe para impedir que trabalho antigo aja sobre um atendimento
-- que MUDOU debaixo dele. Quando a observacao disse `absent`, nao havia
-- atendimento nenhum em voo — nada podia ter mudado sob o chamador, e a
-- conversa que apareceu e a PRIMEIRA. Comparar a fronteira de agora contra
-- o literal `{"absent":true}` difere sempre, entao o ramo abaixo levantava
-- `service_stale` no caminho ORDINARIO: lead criado e depois movido de
-- etapa gera dois eventos observados como `absent`; resolver o primeiro cria
-- a conversa e o segundo morria — e `serviceForEvent` engole o 40001 como
-- `stale_origin`, entao o follow-up simplesmente nao nascia, calado.
--
-- Irma da regra em `lib/atendimento/fronteira.ts`: ali, abrir a PRIMEIRA
-- demanda tambem nao vence a fronteira. Nos dois casos o que se recusa e o
-- atendimento OUTRO, nunca o atendimento que acabou de comecar.
elsif p_observed->>'absent' is distinct from 'true'
and public.fn_service_boundary(p_org,c.id) is distinct from p_observed then
raise exception 'service_stale' using errcode='40001';
end if;
end if;
+164 -43
View File
@@ -123,7 +123,10 @@ test.describe("followup flows — lista + criação (Task 6.1)", () => {
const card = page.locator("li", { hasText: flowName });
await expect(card).toBeVisible();
await expect(card.getByText("Rascunho", { exact: true })).toBeVisible();
await page.screenshot({ path: "test-results/followup-6.1-04-flow-in-list.png", fullPage: true });
await page.screenshot({
path: "test-results/followup-6.1-04-flow-in-list.png",
fullPage: true,
});
});
test("viewer não vê o botão de criar fluxo (RBAC)", async ({ page }) => {
@@ -160,7 +163,9 @@ async function connectHandles(
? `.react-flow__node[data-id="${sourceNodeId}"] .react-flow__handle.source[data-handleid="${sourceHandleId}"]`
: `.react-flow__node[data-id="${sourceNodeId}"] .react-flow__handle.source`;
const source = page.locator(sourceSel).first();
const target = page.locator(`.react-flow__node[data-id="${targetNodeId}"] .react-flow__handle.target`);
const target = page.locator(
`.react-flow__node[data-id="${targetNodeId}"] .react-flow__handle.target`,
);
const sBox = await source.boundingBox();
const tBox = await target.boundingBox();
if (!sBox || !tBox) throw new Error(`handle não encontrado: ${sourceNodeId} -> ${targetNodeId}`);
@@ -210,7 +215,10 @@ test.describe("followup flow builder — canvas visual (Task 6.2)", () => {
await expect(page.getByTestId("node-palette")).toBeVisible();
// React Flow's own pane element — proves the dynamically-imported canvas actually mounted.
await expect(page.locator(".react-flow")).toBeVisible();
await page.screenshot({ path: "test-results/followup-6.2-01-canvas-empty.png", fullPage: true });
await page.screenshot({
path: "test-results/followup-6.2-01-canvas-empty.png",
fullPage: true,
});
});
test("adiciona os 4 nós via paleta e conecta trigger→wait→action→end", async ({ page }) => {
@@ -246,9 +254,15 @@ test.describe("followup flow builder — canvas visual (Task 6.2)", () => {
const zoomOut = page.locator(".react-flow__controls-zoomout");
for (let i = 0; i < 5; i++) await zoomOut.click();
const triggerId = await page.locator('.react-flow__node[data-id^="trigger-"]').getAttribute("data-id");
const waitId = await page.locator('.react-flow__node[data-id^="wait-"]').getAttribute("data-id");
const actionId = await page.locator('.react-flow__node[data-id^="action-"]').getAttribute("data-id");
const triggerId = await page
.locator('.react-flow__node[data-id^="trigger-"]')
.getAttribute("data-id");
const waitId = await page
.locator('.react-flow__node[data-id^="wait-"]')
.getAttribute("data-id");
const actionId = await page
.locator('.react-flow__node[data-id^="action-"]')
.getAttribute("data-id");
const endId = await page.locator('.react-flow__node[data-id^="end-"]').getAttribute("data-id");
if (!triggerId || !waitId || !actionId || !endId) throw new Error("node ids ausentes");
@@ -260,7 +274,9 @@ test.describe("followup flow builder — canvas visual (Task 6.2)", () => {
await page.screenshot({ path: "test-results/followup-6.2-02-connected.png", fullPage: true });
});
test("clica no nó Aguardar e configura 10min; clica no nó Ação e configura o prompt_hint", async ({ page }) => {
test("clica no nó Aguardar e configura 10min; clica no nó Ação e configura o prompt_hint", async ({
page,
}) => {
await login(page, creds.users.manager!.email);
await page.goto("/app/ai/followups");
@@ -287,15 +303,23 @@ test.describe("followup flow builder — canvas visual (Task 6.2)", () => {
// Subtitle on the card derives straight from committed config — proves the
// panel wrote through to the live FlowGraph state, not just local form state.
await expect(page.locator('[data-testid^="node-card-wait-"]')).toContainText("10 min");
await page.screenshot({ path: "test-results/followup-6.2-03-wait-configured.png", fullPage: true });
await page.screenshot({
path: "test-results/followup-6.2-03-wait-configured.png",
fullPage: true,
});
// Action node → prompt_hint.
await page.locator('[data-testid^="node-card-action-"]').click();
const promptHint = panel.getByLabel("Instrução para a IA");
await promptHint.fill("Reforce o benefício e pergunte se ainda tem interesse.");
await promptHint.blur();
await expect(page.locator('[data-testid^="node-card-action-"]')).toContainText("Reforce o benefício");
await page.screenshot({ path: "test-results/followup-6.2-04-action-configured.png", fullPage: true });
await expect(page.locator('[data-testid^="node-card-action-"]')).toContainText(
"Reforce o benefício",
);
await page.screenshot({
path: "test-results/followup-6.2-04-action-configured.png",
fullPage: true,
});
});
/**
@@ -330,9 +354,15 @@ test.describe("followup flow builder — canvas visual (Task 6.2)", () => {
const zoomOut = page.locator(".react-flow__controls-zoomout");
for (let i = 0; i < 5; i++) await zoomOut.click();
const triggerId = await page.locator('.react-flow__node[data-id^="trigger-"]').getAttribute("data-id");
const waitId = await page.locator('.react-flow__node[data-id^="wait-"]').getAttribute("data-id");
const actionId = await page.locator('.react-flow__node[data-id^="action-"]').getAttribute("data-id");
const triggerId = await page
.locator('.react-flow__node[data-id^="trigger-"]')
.getAttribute("data-id");
const waitId = await page
.locator('.react-flow__node[data-id^="wait-"]')
.getAttribute("data-id");
const actionId = await page
.locator('.react-flow__node[data-id^="action-"]')
.getAttribute("data-id");
const endId = await page.locator('.react-flow__node[data-id^="end-"]').getAttribute("data-id");
if (!triggerId || !waitId || !actionId || !endId) throw new Error("node ids ausentes");
@@ -349,14 +379,21 @@ test.describe("followup flow builder — canvas visual (Task 6.2)", () => {
await expect(page.locator(`[data-testid="node-card-${waitId}"]`)).toContainText("10 min");
await page.locator(`[data-testid="node-card-${actionId}"]`).click();
await panel.getByLabel("Instrução para a IA").fill("Reforce o benefício e pergunte se ainda tem interesse.");
await panel
.getByLabel("Instrução para a IA")
.fill("Reforce o benefício e pergunte se ainda tem interesse.");
await panel.getByLabel("Instrução para a IA").blur();
await expect(page.locator(`[data-testid="node-card-${actionId}"]`)).toContainText("Reforce o benefício");
await expect(page.locator(`[data-testid="node-card-${actionId}"]`)).toContainText(
"Reforce o benefício",
);
// Close the config panel — it's a docked aside that narrows the canvas and
// can occlude nodes, which would break the next handle-to-handle drag.
await page.locator(".react-flow__pane").click({ position: { x: 20, y: 20 } });
await expect(page.getByTestId("node-config-sheet")).toHaveCount(0);
await page.screenshot({ path: "test-results/followup-6.2-05-built-incomplete.png", fullPage: true });
await page.screenshot({
path: "test-results/followup-6.2-05-built-incomplete.png",
fullPage: true,
});
// 4. Publish INCOMPLETE — expect 422 anchored to the offending nodes.
await page.getByTestId("publish-button").click();
@@ -364,7 +401,10 @@ test.describe("followup flow builder — canvas visual (Task 6.2)", () => {
await expect(page.locator(`[data-testid="node-error-${waitId}"]`)).toBeVisible();
await expect(page.locator(`[data-testid="node-error-${actionId}"]`)).toBeVisible();
await expect(page.locator(`[data-testid="node-error-${endId}"]`)).toBeVisible();
await page.screenshot({ path: "test-results/followup-6.2-06-publish-422-anchored.png", fullPage: true });
await page.screenshot({
path: "test-results/followup-6.2-06-publish-422-anchored.png",
fullPage: true,
});
// 5. Fix: connect action→end.
await connectHandles(page, actionId, endId);
@@ -386,7 +426,8 @@ test.describe("followup flow builder — canvas visual (Task 6.2)", () => {
// mid-transition frame. Wait for it to settle before the "before" capture.
await page.waitForTimeout(400);
const positionsBefore: Record<string, { x: number; y: number; width: number; height: number }> = {};
const positionsBefore: Record<string, { x: number; y: number; width: number; height: number }> =
{};
for (const id of [triggerId, waitId, actionId, endId]) {
const box = await page.locator(`.react-flow__node[data-id="${id}"]`).boundingBox();
if (!box) throw new Error(`nó ${id} sem bounding box antes do reload`);
@@ -401,7 +442,9 @@ test.describe("followup flow builder — canvas visual (Task 6.2)", () => {
await expect(page.locator(".react-flow__node")).toHaveCount(4);
await expect(page.locator(".react-flow__edge")).toHaveCount(3);
await expect(page.locator(`[data-testid="node-card-${waitId}"]`)).toContainText("10 min");
await expect(page.locator(`[data-testid="node-card-${actionId}"]`)).toContainText("Reforce o benefício");
await expect(page.locator(`[data-testid="node-card-${actionId}"]`)).toContainText(
"Reforce o benefício",
);
// Same settle wait as the "before" capture — the post-reload fitView (on
// mount) needs the same grace period before its transform is comparable.
await page.waitForTimeout(400);
@@ -414,7 +457,10 @@ test.describe("followup flow builder — canvas visual (Task 6.2)", () => {
expect(Math.abs(box.x - before.x)).toBeLessThanOrEqual(TOLERANCE_PX);
expect(Math.abs(box.y - before.y)).toBeLessThanOrEqual(TOLERANCE_PX);
}
await page.screenshot({ path: "test-results/followup-6.2-08-reloaded-persisted.png", fullPage: true });
await page.screenshot({
path: "test-results/followup-6.2-08-reloaded-persisted.png",
fullPage: true,
});
// 8. Rollback disabled — only 1 version exists (this is the first publish).
await expect(page.getByTestId("rollback-button")).toBeDisabled();
@@ -435,7 +481,12 @@ test.describe("followup flow builder — editor de condição de aresta / ai_cla
* is what a real user would do before wiring a non-trivial flow; this
* mirrors that instead of fighting the demo grid.
*/
async function moveNodeTo(page: Page, nodeId: string, targetX: number, targetY: number): Promise<void> {
async function moveNodeTo(
page: Page,
nodeId: string,
targetX: number,
targetY: number,
): Promise<void> {
const card = page.locator(`[data-testid="node-card-${nodeId}"]`);
const box = await card.boundingBox();
if (!box) throw new Error(`nó ${nodeId} sem bounding box`);
@@ -504,8 +555,12 @@ test.describe("followup flow builder — editor de condição de aresta / ai_cla
await page.getByTestId("palette-add-end").click();
await page.getByTestId("palette-add-end").click();
const triggerId = await page.locator('.react-flow__node[data-id^="trigger-"]').getAttribute("data-id");
const classifyId = await page.locator('.react-flow__node[data-id^="ai_classify-"]').getAttribute("data-id");
const triggerId = await page
.locator('.react-flow__node[data-id^="trigger-"]')
.getAttribute("data-id");
const classifyId = await page
.locator('.react-flow__node[data-id^="ai_classify-"]')
.getAttribute("data-id");
const [action1Id, action2Id] = await nodeIdsByPrefix(page, "action");
const [end1Id, end2Id] = await nodeIdsByPrefix(page, "end");
if (!triggerId || !classifyId || !action1Id || !action2Id || !end1Id || !end2Id) {
@@ -537,14 +592,20 @@ test.describe("followup flow builder — editor de condição de aresta / ai_cla
const panel = page.getByTestId("node-config-panel");
await panel.getByLabel("Classes (separadas por vírgula)").fill("positivo, objecao");
await panel.getByLabel("Classes (separadas por vírgula)").blur();
await expect(page.locator(`[data-testid="node-card-${classifyId}"]`)).toContainText("2 classes");
await expect(page.locator(`[data-testid="node-card-${classifyId}"]`)).toContainText(
"2 classes",
);
// 3. Configure the 2 action nodes' prompt_hint.
await page.locator(`[data-testid="node-card-${action1Id}"]`).click();
await panel.getByLabel("Instrução para a IA").fill("Envie uma oferta especial reforçando o interesse.");
await panel
.getByLabel("Instrução para a IA")
.fill("Envie uma oferta especial reforçando o interesse.");
await panel.getByLabel("Instrução para a IA").blur();
await page.locator(`[data-testid="node-card-${action2Id}"]`).click();
await panel.getByLabel("Instrução para a IA").fill("Pergunte com empatia qual é a objeção específica.");
await panel
.getByLabel("Instrução para a IA")
.fill("Pergunte com empatia qual é a objeção específica.");
await panel.getByLabel("Instrução para a IA").blur();
// Close the config panel — docked aside narrows the canvas, would break the drags below.
@@ -576,9 +637,14 @@ test.describe("followup flow builder — editor de condição de aresta / ai_cla
// Exactly one node carries an error — proves the failure is scoped to the classify
// node's edge coverage, not some unrelated structural problem in the graph.
await expect(page.locator('[data-testid^="node-error-"]')).toHaveCount(1);
const classifyErrorText = await page.locator(`[data-testid="node-error-${classifyId}"]`).textContent();
const classifyErrorText = await page
.locator(`[data-testid="node-error-${classifyId}"]`)
.textContent();
expect(classifyErrorText).toMatch(/class_match|no_reply/i);
await page.screenshot({ path: "test-results/followup-6.3-02-publish-422-all-always.png", fullPage: true });
await page.screenshot({
path: "test-results/followup-6.3-02-publish-422-all-always.png",
fullPage: true,
});
// 6. Fix it: use the new EdgeConfigPanel to set each classify-outgoing edge's condition.
await setEdgeCondition(page, "edge-2", "positivo");
@@ -587,11 +653,16 @@ test.describe("followup flow builder — editor de condição de aresta / ai_cla
// edge-5 is already the "always" fallback by default — open it and confirm rather
// than change it, proving the option is genuinely selected, not just left untouched.
await clickEdge(page, "edge-5");
await expect(page.getByTestId("edge-config-panel").getByRole("combobox")).toContainText("Sempre");
await expect(page.getByTestId("edge-config-panel").getByRole("combobox")).toContainText(
"Sempre",
);
await page.locator(".react-flow__pane").click({ position: { x: 20, y: 20 } });
await expect(page.getByTestId("edge-config-sheet")).toHaveCount(0);
await page.screenshot({ path: "test-results/followup-6.3-03-edges-configured.png", fullPage: true });
await page.screenshot({
path: "test-results/followup-6.3-03-edges-configured.png",
fullPage: true,
});
// 7. Publish for real — expect SUCCESS this time, where the identical graph shape
// with all-`always` edges failed above.
@@ -607,7 +678,10 @@ test.describe("followup flow builder — editor de condição de aresta / ai_cla
await expect(page.getByTestId("rf__edge-edge-5")).toContainText("Sempre");
await page.locator(".react-flow__controls-fitview").click();
await page.waitForTimeout(400);
await page.screenshot({ path: "test-results/followup-6.3-04-published-branching.png", fullPage: true });
await page.screenshot({
path: "test-results/followup-6.3-04-published-branching.png",
fullPage: true,
});
});
});
@@ -626,7 +700,7 @@ test.describe("followup flow builder — editor de condição de aresta / ai_cla
test.describe("followup flow selector no editor do agente (Task 7.2)", () => {
test.beforeAll(() => {
execFileSync("npx", ["tsx", "scripts/seed-e2e-followup-agent.ts"], { stdio: "inherit" });
// O seed ESCREVE em .e2e-creds.json, e `creds` foi lido no carregamento do
// O seed ESCREVE em .e2e-creds.json, e `creds` foi lido no carregamento do
// módulo — sem reler, o objeto em memória nunca vê o bloco que o seed
// acabou de gravar. Foi por isto que esta spec ficou fora do CI: a mensagem
// "o seed não grava X" descrevia o sintoma, e o seed gravava certo desde
@@ -647,22 +721,40 @@ test.describe("followup flow selector no editor do agente (Task 7.2)", () => {
// --- 1. publica um fluxo mínimo trigger→end via API ---
const stamp = Date.now();
const flowName = `E2E Seletor ${stamp}`;
const createFlowRes = await page.request.post("/api/v1/ai/followup-flows", { data: { name: flowName } });
const createFlowRes = await page.request.post("/api/v1/ai/followup-flows", {
data: { name: flowName },
});
expect(createFlowRes.status()).toBe(201);
const { data: flow } = (await createFlowRes.json()) as { data: { id: string } };
const graph = {
nodes: [
{ id: "trigger-1", type: "trigger", label: "Início", position: { x: 0, y: 0 }, config: {} },
{ id: "end-1", type: "end", label: "Fim", position: { x: 0, y: 200 }, config: { outcome: "exhausted" } },
{
id: "end-1",
type: "end",
label: "Fim",
position: { x: 0, y: 200 },
config: { outcome: "exhausted" },
},
],
edges: [
{
id: "edge-1",
source: "trigger-1",
target: "end-1",
priority: 0,
condition: { type: "always" },
},
],
edges: [{ id: "edge-1", source: "trigger-1", target: "end-1", priority: 0, condition: { type: "always" } }],
};
const patchRes = await page.request.patch(`/api/v1/ai/followup-flows/${flow.id}`, {
data: { draft_graph: graph },
});
expect(patchRes.status()).toBe(200);
const publishRes = await page.request.post(`/api/v1/ai/followup-flows/${flow.id}/publish`, { data: {} });
const publishRes = await page.request.post(`/api/v1/ai/followup-flows/${flow.id}/publish`, {
data: {},
});
expect(publishRes.status()).toBe(200);
// --- 2. cria um mcp_agent + v1 draft via API, usando as fixtures seedadas ---
@@ -682,7 +774,10 @@ test.describe("followup flow selector no editor do agente (Task 7.2)", () => {
});
expect(createAgentRes.status()).toBe(201);
const { data: created } = (await createAgentRes.json()) as {
data: { agent: { id: string }; version: { id: string; followup: { enabled: boolean; flow_pointer_ids: string[] } } };
data: {
agent: { id: string };
version: { id: string; followup: { enabled: boolean; flow_pointer_ids: string[] } };
};
};
const agentId = created.agent.id;
const versionId = created.version.id;
@@ -760,7 +855,10 @@ test.describe("followup flow builder — controle de gatilho na PublishBar (Task
await triggerButton.click();
const panel = page.getByTestId("trigger-config-panel");
await expect(panel).toBeVisible();
await page.screenshot({ path: "e2e-artifacts/followup-8.5-01-trigger-panel-manual.png", fullPage: true });
await page.screenshot({
path: "e2e-artifacts/followup-8.5-01-trigger-panel-manual.png",
fullPage: true,
});
// Só o que tem motor de enrollment é oferecido. Eram 2 até a frente de
// gatilhos entregar o produtor de `stage_change`
@@ -779,12 +877,27 @@ test.describe("followup flow builder — controle de gatilho na PublishBar (Task
// coisas — e é o que o operador de fato vê.
const kindSelect = panel.getByRole("combobox");
await kindSelect.click();
const OFERECIDOS = ["Manual", "Silêncio", "Etapa do funil", "Agente pediu ajuda", "Automação (Webhooks)"];
// `appointment_no_show` entrou com motor completo, verificado ponta a ponta:
// `fn_appointment_change` (migration 0224) emite `appointment.outcome_confirmed`
// quando a equipe confirma a falta; `lib/followup/gatilho-presenca.handler.ts`
// consome — e está REGISTRADO em `lib/event-log/register-handlers.ts`, que é
// onde "tem consumidor" deixa de ser promessa; ele chama `fn_appointment_recover`,
// que insere em `followup_enrollments`. O publish já o aceita em `KINDS_COM_MOTOR`.
const OFERECIDOS = [
"Manual",
"Silêncio",
"Etapa do funil",
"Falta confirmada pela equipe",
"Agente pediu ajuda",
"Automação (Webhooks)",
];
for (const nome of OFERECIDOS) {
await expect(page.getByRole("option", { name: nome, exact: true })).toBeVisible();
}
await expect(page.getByRole("option")).toHaveCount(OFERECIDOS.length);
await expect(page.getByRole("option", { name: /conversation_end|fim do atendimento/i })).toHaveCount(0);
await expect(
page.getByRole("option", { name: /conversation_end|fim do atendimento/i }),
).toHaveCount(0);
await page.getByRole("option", { name: "Silêncio", exact: true }).click();
await expect(panel.getByLabel("Minutos de silêncio")).toBeVisible();
@@ -794,16 +907,24 @@ test.describe("followup flow builder — controle de gatilho na PublishBar (Task
const saveButton = panel.getByTestId("trigger-config-save");
await expect(saveButton).toBeEnabled();
await page.screenshot({ path: "e2e-artifacts/followup-8.5-02-trigger-silence-filled.png", fullPage: true });
await page.screenshot({
path: "e2e-artifacts/followup-8.5-02-trigger-silence-filled.png",
fullPage: true,
});
await saveButton.click();
await expect(page.getByText("Gatilho atualizado.")).toBeVisible();
await expect(triggerButton).toHaveText("Gatilho: Silêncio (45 min)");
await page.screenshot({ path: "e2e-artifacts/followup-8.5-03-trigger-saved.png", fullPage: true });
await page.screenshot({
path: "e2e-artifacts/followup-8.5-03-trigger-saved.png",
fullPage: true,
});
// Reload — o valor persistido (PATCH round-trip) sobrevive, não é só estado local.
await page.reload();
await expect(page.getByTestId("trigger-config-button")).toHaveText("Gatilho: Silêncio (45 min)");
await expect(page.getByTestId("trigger-config-button")).toHaveText(
"Gatilho: Silêncio (45 min)",
);
// Prova via API (não só UI): GET devolve o trigger_config exato que foi salvo.
const getRes = await page.request.get(`/api/v1/ai/followup-flows/${flow.id}`);
+30 -14
View File
@@ -59,24 +59,38 @@ async function routingEventCount(org: string, status?: string): Promise<number>
return count ?? 0;
}
async function assertNoForeignRoutingDue(org: string): Promise<void> {
const now = new Date().toISOString();
const { data, error } = await db
/**
* O cron de roteamento é GLOBAL: drena a fila da instalação inteira, e as
* asserções abaixo contam o LOTE. Esta função RECUSAVA rodar quando existisse
* item vencido de outra org — o que parecia proteger o teste e o tornava
* IMPOSSÍVEL de passar: `trg_conversation_routing_requested` (migration 0040)
* dispara em TODA conversa nova sem dono, e nenhum outro ponto da suíte drena
* essa fila (`grep -rln "cron/routing-worker" tests/e2e/` devolve só este
* arquivo). A fila só acumula; exigir que ela esteja vazia é exigir que
* nenhuma outra spec tenha criado conversa.
*
* Medido nas duas pontas antes de trocar: a MESMA recusa com 152 specs antes
* (run 34072172013, quando tudo era PARTE_2) e com 18 antes (run 34145244454,
* PARTE_3). Vizinhanças opostas, desfecho idêntico — não é contaminação de
* vizinha, é premissa falsa da guarda.
*
* Em vez de recusar, ADIAMOS o que não é nosso: `next_attempt_at` no futuro sai
* do claim do worker (`lib/routing/worker.ts`) sem apagar linha nenhuma, e o
* lote passa a ser só o desta org — que é exatamente o que as asserções medem.
*/
async function deferForeignRoutingDue(org: string): Promise<void> {
const agora = new Date();
const { error } = await db
.from("event_log")
.select("id")
.update({ next_attempt_at: new Date(agora.getTime() + 3_600_000).toISOString() })
.eq("event_type", "conversation.routing_requested")
.eq("status", "pending")
.neq("organization_id", org)
.or(`next_attempt_at.is.null,next_attempt_at.lte.${now}`)
.limit(1);
.or(`next_attempt_at.is.null,next_attempt_at.lte.${agora.toISOString()}`);
if (error) throw error;
if (data?.length) throw new Error("routing_queue_foreign_due_refusing_global_cron");
}
async function createUser(
name: string,
password: string,
): Promise<{ id: string; email: string }> {
async function createUser(name: string, password: string): Promise<{ id: string; email: string }> {
const email = `routing-${randomUUID()}@invariant.test`;
const { data, error } = await db.auth.admin.createUser({
email,
@@ -263,7 +277,7 @@ test("configura responsáveis por canal e o cron distribui sem misturar números
const northConversation = await conversation(north, "Cliente Canal Norte", "+5511999000011");
const southConversation = await conversation(south, "Cliente Canal Sul", "+5511999000022");
await expect.poll(() => routingEventCount(org)).toBe(2);
await assertNoForeignRoutingDue(org);
await deferForeignRoutingDue(org);
const firstDrain = await drain(page);
expect(firstDrain.errors).toEqual([]);
@@ -299,7 +313,9 @@ test("configura responsáveis por canal e o cron distribui sem misturar números
});
await notice.getByRole("link", { name: "Abrir conversa" }).click();
await expect(page).toHaveURL(new RegExp(`/app/inbox/${southConversation}$`));
await expect(page.getByText("Mensagem de Cliente Canal Sul", { exact: true }).first()).toBeVisible();
await expect(
page.getByText("Mensagem de Cliente Canal Sul", { exact: true }).first(),
).toBeVisible();
await page.goto("/app/settings/atendimento");
const resetSouth = page.getByRole("group", { name: "Canal Sul" });
@@ -308,7 +324,7 @@ test("configura responsáveis por canal e o cron distribui sem misturar números
"Usa todos os atendentes elegíveis da organização.",
);
await expect.poll(() => routingEventCount(org, "pending")).toBe(1);
await assertNoForeignRoutingDue(org);
await deferForeignRoutingDue(org);
const secondDrain = await drain(page);
expect(secondDrain.errors).toEqual([]);
@@ -0,0 +1,94 @@
import { randomUUID } from "node:crypto";
import pg from "pg";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { seedGov, GOV_ORG, GOV_SESSION } from "./gov-helpers";
/**
* "NÃO HAVIA ATENDIMENTO / HÁ AGORA" É PROGRESSÃO, NÃO CONFLITO.
*
* O CAS de `fn_service_begin` existe para impedir que trabalho antigo aja sobre
* um atendimento que mudou debaixo dele. Quando a observação diz `absent`, não
* havia atendimento em voo — nada podia ter mudado sob o chamador. Mesmo assim
* o código comparava a fronteira de agora contra o literal `{"absent":true}`,
* que difere sempre, e levantava `service_stale` no caminho ORDINÁRIO.
*
* O custo era invisível: um lead criado e depois movido de etapa gera dois
* eventos observados como `absent`; resolver o primeiro cria a conversa e o
* segundo morria com 40001 — que `serviceForEvent` engole como `stale_origin`.
* O follow-up de etapa simplesmente não nascia, sem erro em lugar nenhum.
*
* Este arquivo guarda os dois lados: a progressão passa, e o atendimento OUTRO
* continua sendo recusado.
*/
const pool = new pg.Pool({
connectionString: `postgresql://postgres:postgres@127.0.0.1:${process.env.TEST_DB_PORT ?? 54329}/postgres`,
max: 3,
});
beforeAll(() => {
seedGov();
});
afterAll(async () => {
await pool.end();
});
describe("fronteira: progressão não é conflito", () => {
it("segunda origem observada como ausente aceita a conversa que a primeira criou", async () => {
const contato = randomUUID();
await pool.query(
"insert into contacts(id,organization_id,display_name) values($1,$2,'Progressão')",
[contato, GOV_ORG],
);
const ausente = JSON.stringify({ organization_id: GOV_ORG, contact_id: contato, absent: true });
// 1ª resolução: não há conversa, a observação diz `absent` — cria.
const primeira = await pool.query("select public.fn_service_begin($1,$2,$3,$4::jsonb) as b", [
GOV_ORG,
contato,
GOV_SESSION,
ausente,
]);
expect(
primeira.rows[0].b?.conversation_id,
"a primeira origem abre o atendimento",
).toBeTruthy();
// 2ª resolução com a MESMA observação: a conversa agora existe. Isto é o
// caminho ordinário (lead criado e movido), e era ele que morria.
const segunda = await pool.query("select public.fn_service_begin($1,$2,$3,$4::jsonb) as b", [
GOV_ORG,
contato,
GOV_SESSION,
ausente,
]);
expect(
segunda.rows[0].b?.conversation_id,
"a segunda origem tem de reaproveitar o mesmo atendimento, não morrer em service_stale",
).toBe(primeira.rows[0].b.conversation_id);
});
it("observação que descreve OUTRO atendimento continua sendo recusada", async () => {
const contato = randomUUID();
await pool.query(
"insert into contacts(id,organization_id,display_name) values($1,$2,'Outro')",
[contato, GOV_ORG],
);
const ausente = JSON.stringify({ organization_id: GOV_ORG, contact_id: contato, absent: true });
const criada = await pool.query("select public.fn_service_begin($1,$2,$3,$4::jsonb) as b", [
GOV_ORG,
contato,
GOV_SESSION,
ausente,
]);
// Uma fronteira concreta que NÃO é a vigente: revisão adiantada. O
// afrouxamento vale só para a partida `absent`; isto tem de continuar 40001.
const forjada = JSON.stringify({ ...criada.rows[0].b, service_revision: 99 });
await expect(
pool.query("select public.fn_service_begin($1,$2,$3,$4::jsonb)", [
GOV_ORG,
contato,
GOV_SESSION,
forjada,
]),
).rejects.toMatchObject({ code: "40001" });
});
});
+21
View File
@@ -42,6 +42,27 @@ describe("fronteira imutável de atendimento", () => {
}
expect(() => assertCurrentServiceBoundary(null, boundary)).toThrow("service_boundary_stale");
});
it("a resposta do lead abre a 1ª demanda e NÃO vence o acompanhamento; fechar, trocar ou virar revisão vencem", () => {
// O gatilho de silêncio captura a fronteira de um contato CALADO, que por
// definição não tem demanda aberta. A resposta dele abre a primeira demanda
// e a 0222 mantém `service_revision` de propósito — é o mesmo atendimento.
// Enquanto o predicado comparava `null !== uuid`, essa resposta vencia o
// acompanhamento que ela mesma acordou, e o nó `ai_classify` (que existe
// para consumi-la) ficava morto por construção.
const semDemanda = { ...boundary, demanda_id: null, demanda_revision: null };
expect(() => assertCurrentServiceBoundary(semDemanda, boundary)).not.toThrow();
// O afrouxamento é só para o `null` de partida: tudo que indica atendimento
// OUTRO continua vetando.
for (const changed of [
{ demanda_fechada_em: "2026-01-01" },
{ service_revision: 2 },
{ status: "closed" },
]) {
expect(() => assertCurrentServiceBoundary(semDemanda, { ...boundary, ...changed })).toThrow(
"service_boundary_stale",
);
}
});
it("fechamento entre geração e tool impede o transporte HTTP real; reabertura também", async () => {
let received = 0;
const server = createServer((_req, res) => {