mirror of
https://github.com/melgarafael/DeskcommCRM.git
synced 2026-10-02 09:34:46 +08:00
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:
co-authored by
Claude Opus 5
parent
09c102548c
commit
7afb285724
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 —
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user