From b533139eb28d7dd0284ae87d40bba87d2497540e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Ign=C3=A1cio=20Fontoura=20Solla?= Date: Thu, 30 Jul 2026 21:56:50 -0300 Subject: [PATCH 1/3] =?UTF-8?q?feat(ops):=20promote=20=C3=BAltimo=20commit?= =?UTF-8?q?=20ci-stage=20green=20sem=20exigir=20head=20verde?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit agent:promote passa a escolher o SHA mais novo à frente de main com ci-stage success e usa promote/last-green quando o head de stage está vermelho, destravando o bootstrap manual para main. --- docs/AGENT-OPS.md | 4 +- scripts/agent-promote.mjs | 120 ++++++++++++++++----- scripts/lib/agent-promote-target.mjs | 33 ++++++ tests/unit/agentPromoteTarget.unit.spec.ts | 38 +++++++ 4 files changed, 168 insertions(+), 27 deletions(-) create mode 100644 scripts/lib/agent-promote-target.mjs create mode 100644 tests/unit/agentPromoteTarget.unit.spec.ts diff --git a/docs/AGENT-OPS.md b/docs/AGENT-OPS.md index 7d1f3c1e..47d1c508 100644 --- a/docs/AGENT-OPS.md +++ b/docs/AGENT-OPS.md @@ -27,7 +27,7 @@ stage → CI stage green → auto-promote (workflow promote-stage-to-main.yml) **Done = PR mergeado em `stage` com CI verde.** Falha de CI no teu PR — infra do workflow, teste pré-existente ou regressão da feature — é tua até o merge. "Fora do escopo da feature" não é motivo para parar: corrige na mesma branch. Triagem das três classes: (a) **infra do workflow** (job mal configurado, step faltando) → fix no workflow no mesmo PR; (b) **teste pré-existente quebrado ou frágil** → repair no mesmo PR, no padrão dos specs irmãos; (c) **regressão da feature** → fix normal. Exceção de blast radius: se o fix exigir migration, access control ou Consent, para e escala com Opções — esses nunca se corrigem "de passagem". Flaky não se resolve com retry: repair, ou quarentena justificada no PR + débito registrado (`capture-review-debts`). -- **Só humano:** `pnpm db:refresh:stage` (refresh semanal do snapshot), editar envs Vercel/Neon, rodar `pnpm build` contra qualquer banco remoto. `pnpm agent:promote --i-am-human` permanece como override manual de emergência (mesmas verificações, sem esperar o workflow). +- **Só humano:** `pnpm db:refresh:stage` (refresh semanal do snapshot), editar envs Vercel/Neon, rodar `pnpm build` contra qualquer banco remoto. `pnpm agent:promote --i-am-human` permanece como override manual (promove o último commit ci-stage green em `stage`, mesmo com head vermelho). - **Auto-promote:** `promote-stage-to-main.yml` dispara quando `ci-stage.yml` termina com sucesso. Só promove se o `headSha` do run ainda for o head de `stage` (commit mais novo ganha quando o CI enfileira). Recusa se `main` divergiu de `stage` (hotfix: merge `main` em `stage` + CI stage green de novo). - **Merge em stage sem estar na head:** branch protection de `stage` com `strict=false` — PR pode auto-merger com CI green mesmo que outro PR tenha entrado antes; conflito de merge continua bloqueando (rebase obrigatório). Aplicar/reparar: `pnpm configure:branch-protection`. - **Secret opcional:** `PROMOTE_GITHUB_TOKEN` (PAT admin do repo) se `GITHUB_TOKEN` não conseguir `gh pr merge --admin` na proteção de `main`. Settings → Actions → "Allow GitHub Actions to create and approve pull requests" também ajuda. @@ -43,7 +43,7 @@ stage → CI stage green → auto-promote (workflow promote-stage-to-main.yml) | `pnpm agent:prioritize -- ` | Troca a label `prio:*` | | `pnpm agent:file-miss -- --title ...` | Issue `kind:agent-miss` → harvest em guardrail (`docs/GUARDRAILS.md`) | | `pnpm agent:pool -- status\|tick --dry-run\|doctor` | Inspeção local do pool de workers Cloud; start/stop/pause **remotos** via `gh workflow run agent-pool.yml -f action=…` (ver "Agent pool" abaixo) | -| `pnpm agent:promote -- --i-am-human` | **Override manual:** PR `stage→main` + merge se CI stage green (auto-promote faz isso sozinho) | +| `pnpm agent:promote -- --i-am-human` | **Override manual:** PR `main` ← último commit em `stage` com ci-stage green (não exige head verde; auto-promote só no head) | | `pnpm push [-- -u origin HEAD …]` | **Canônico:** `ensure-repo-deps` + `gate:push` + `git push --no-verify` (gate no script — não depende do Husky) | | `pnpm configure:branch-protection [-- --dry-run]` | Ajusta proteção de `stage` (`strict=false`) e `main` (0 reviews — auto-promote substitui o humano) | | `pnpm db:seed:minimal` | DB mínimo sintético (contrato: [`scripts/lib/seed-minimal-manifest.mjs`](../scripts/lib/seed-minimal-manifest.mjs), pin `tests/unit/seedMinimalManifest.unit.spec.ts`) | diff --git a/scripts/agent-promote.mjs b/scripts/agent-promote.mjs index d8c3d583..af7bc44f 100644 --- a/scripts/agent-promote.mjs +++ b/scripts/agent-promote.mjs @@ -1,20 +1,28 @@ /** * `pnpm agent:promote -- --i-am-human` — manual override: promote `stage` → `main`. * - * Normal path: `promote-stage-to-main.yml` auto-merges when ci-stage is green. - * Use this only when the workflow failed or you need to promote out of band. + * Normal path: `promote-stage-to-main.yml` auto-merges when ci-stage is green on + * the current stage head. Use this when the workflow failed or you need to promote + * out of band — including when stage head is red but an older commit on stage + * already passed ci-stage. * * Checks (in order): - * 1. `main` has not diverged from `stage` (hotfix direto em main exige antes - * merge/rebase de main em stage + CI stage green de novo). - * 2. Latest ci-stage run on `stage` is green. - * 3. Creates the PR stage→main if none is open, then merges (ci-stage is the - * pre-promote gate; ci.yml on main is the post-merge safety net). + * 1. `main` has not diverged from `stage` (hotfix direto em main exige merge + * de main em stage antes). + * 2. Newest commit on `origin/main..origin/stage` with a successful ci-stage run. + * 3. PR into `main` from `stage` (head green) or `promote/last-green` (older green). + * 4. Merge (ci-stage on the promoted SHA is the pre-promote gate; ci.yml on main + * is the post-merge safety net). */ import { execFileSync } from 'node:child_process' import { dieAgent, gh, ghJson, parseArgs } from './lib/agent-github.mjs' +import { + findLastGreenPromoteSha, + greenCiStageHeadShas, + PROMOTE_GREEN_BRANCH, +} from './lib/agent-promote-target.mjs' const die = dieAgent('promote') const { flags } = parseArgs(process.argv.slice(2), new Set()) @@ -23,7 +31,7 @@ if (flags['i-am-human'] !== true) { die( 'Refused: promote override is human-only. Re-run as a human with:\n' + ' pnpm agent:promote -- --i-am-human\n' + - 'Auto-promote runs via promote-stage-to-main.yml when ci-stage is green.', + 'Auto-promote runs via promote-stage-to-main.yml when ci-stage is green on stage head.', ) } @@ -41,8 +49,17 @@ try { ) } -// 2. CI stage green on the stage head. const stageSha = git(['rev-parse', 'origin/stage']) +const mainSha = git(['rev-parse', 'origin/main']) + +if (stageSha === mainSha) { + die(`main já está no head de stage (${stageSha.slice(0, 8)}) — nada a promover.`) +} + +const commitsAhead = git(['log', `origin/main..origin/stage`, '--format=%H']) + .split('\n') + .filter(Boolean) + const stageRuns = ghJson([ 'run', 'list', @@ -51,24 +68,49 @@ const stageRuns = ghJson([ '--workflow', 'ci-stage.yml', '--limit', - '5', + '100', '--json', 'headSha,status,conclusion,url', ]) -const latestStageRun = stageRuns.find((run) => run.headSha === stageSha) -if ( - !latestStageRun || - latestStageRun.status !== 'completed' || - latestStageRun.conclusion !== 'success' -) { + +const promoteSha = findLastGreenPromoteSha(commitsAhead, greenCiStageHeadShas(stageRuns)) + +if (!promoteSha) { die( - `CI stage não está verde no head de stage (${stageSha.slice(0, 8)}). ` + - `Último run: ${latestStageRun ? `${latestStageRun.status}/${latestStageRun.conclusion} — ${latestStageRun.url}` : 'nenhum'}`, + `Nenhum commit em stage à frente de main tem ci-stage verde (stage head ${stageSha.slice(0, 8)}). ` + + 'Corrija stage ou espere um run verde antes do promote.', ) } -console.log('[agent:promote] CI stage green ✓') -// 3. PR stage → main (create if needed). +const promoteRun = stageRuns.find( + (run) => run.headSha === promoteSha && run.status === 'completed' && run.conclusion === 'success', +) + +if (promoteSha === stageSha) { + console.log(`[agent:promote] CI stage green no head de stage (${stageSha.slice(0, 8)}) ✓`) +} else { + console.log( + `[agent:promote] stage head ${stageSha.slice(0, 8)} não está verde; ` + + `promovendo último commit ci-stage green ${promoteSha.slice(0, 8)} ✓`, + ) + if (promoteRun?.url) { + console.log(`[agent:promote] run: ${promoteRun.url}`) + } +} + +const prHead = promoteSha === stageSha ? 'stage' : PROMOTE_GREEN_BRANCH + +if (prHead !== 'stage') { + execFileSync( + 'git', + ['push', 'origin', `${promoteSha}:refs/heads/${PROMOTE_GREEN_BRANCH}`, '--force'], + { + stdio: 'inherit', + }, + ) +} + +// 3. PR → main (create if needed). let pr = ghJson([ 'pr', 'list', @@ -77,22 +119,48 @@ let pr = ghJson([ '--base', 'main', '--head', - 'stage', + prHead, '--json', 'number,url', ])[0] + +if (!pr && prHead !== 'stage') { + const staleStagePr = ghJson([ + 'pr', + 'list', + '--state', + 'open', + '--base', + 'main', + '--head', + 'stage', + '--json', + 'number,url', + ])[0] + if (staleStagePr) { + console.log( + `[agent:promote] fechando PR #${staleStagePr.number} stage→main (head vermelho; promote em ${promoteSha.slice(0, 8)})`, + ) + gh(['pr', 'close', String(staleStagePr.number)]) + } +} + if (!pr) { + const body = + promoteSha === stageSha + ? 'Override manual via `pnpm agent:promote --i-am-human`. CI stage green no head de stage.' + : `Override manual via \`pnpm agent:promote --i-am-human\`. Promove ci-stage green \`${promoteSha.slice(0, 8)}\` (stage head \`${stageSha.slice(0, 8)}\` não está verde).` const url = gh([ 'pr', 'create', '--base', 'main', '--head', - 'stage', + prHead, '--title', `Promote stage → main (${new Date().toISOString().slice(0, 10)})`, '--body', - 'Override manual via `pnpm agent:promote --i-am-human`. CI stage green verificado antes do merge.', + body, ]) pr = { number: url.split('/').pop(), url } console.log(`[agent:promote] created PR ${pr.url}`) @@ -100,6 +168,8 @@ if (!pr) { console.log(`[agent:promote] reusing open PR ${pr.url}`) } -// 4. Merge (ci-stage was the pre-promote gate; ci.yml on main is the post-merge safety net). +// 4. Merge (ci-stage on promoteSha was the pre-promote gate; ci.yml on main is the safety net). gh(['pr', 'merge', String(pr.number), '--admin', '--merge']) -console.log(`[agent:promote] merged stage → main via PR #${pr.number}. Deploy Vercel a caminho.`) +console.log( + `[agent:promote] merged ${prHead} (${promoteSha.slice(0, 8)}) → main via PR #${pr.number}. Deploy Vercel a caminho.`, +) diff --git a/scripts/lib/agent-promote-target.mjs b/scripts/lib/agent-promote-target.mjs new file mode 100644 index 00000000..0dc7bec8 --- /dev/null +++ b/scripts/lib/agent-promote-target.mjs @@ -0,0 +1,33 @@ +/** + * Pure helpers for `agent-promote`: pick the newest commit on stage (ahead of main) + * that has a successful ci-stage run — not necessarily the current stage head. + */ + +/** Branch used when promoting a green commit behind a red stage head. */ +export const PROMOTE_GREEN_BRANCH = 'promote/last-green' + +/** + * @param {string[]} commitsAhead newest-first SHAs on origin/main..origin/stage + * @param {ReadonlySet} greenShas head SHAs with ci-stage success + * @returns {string | null} + */ +export function findLastGreenPromoteSha(commitsAhead, greenShas) { + for (const sha of commitsAhead) { + if (greenShas.has(sha)) return sha + } + return null +} + +/** + * @param {Array<{ headSha: string; status: string; conclusion: string | null }>} stageRuns + * @returns {Set} + */ +export function greenCiStageHeadShas(stageRuns) { + const green = new Set() + for (const run of stageRuns) { + if (run.status === 'completed' && run.conclusion === 'success') { + green.add(run.headSha) + } + } + return green +} diff --git a/tests/unit/agentPromoteTarget.unit.spec.ts b/tests/unit/agentPromoteTarget.unit.spec.ts new file mode 100644 index 00000000..d2703140 --- /dev/null +++ b/tests/unit/agentPromoteTarget.unit.spec.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest' + +import { + findLastGreenPromoteSha, + greenCiStageHeadShas, +} from '../../scripts/lib/agent-promote-target.mjs' + +describe('greenCiStageHeadShas', () => { + it('collects only completed successes', () => { + const green = greenCiStageHeadShas([ + { headSha: 'aaa', status: 'completed', conclusion: 'success' }, + { headSha: 'bbb', status: 'completed', conclusion: 'failure' }, + { headSha: 'ccc', status: 'in_progress', conclusion: null }, + ]) + expect([...green]).toEqual(['aaa']) + }) +}) + +describe('findLastGreenPromoteSha', () => { + it('returns the newest green commit ahead of main', () => { + const green = new Set(['bbb', 'ddd']) + expect(findLastGreenPromoteSha(['ccc', 'bbb', 'aaa'], green)).toBe('bbb') + }) + + it('returns stage head when it is green', () => { + const green = new Set(['ccc']) + expect(findLastGreenPromoteSha(['ccc', 'bbb'], green)).toBe('ccc') + }) + + it('returns null when nothing ahead is green', () => { + const green = new Set(['zzz']) + expect(findLastGreenPromoteSha(['ccc', 'bbb'], green)).toBeNull() + }) + + it('returns null when there is nothing ahead of main', () => { + expect(findLastGreenPromoteSha([], new Set(['aaa']))).toBeNull() + }) +}) From 0fece8cef572e9a2ca46f6cd89912c013a4618f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Ign=C3=A1cio=20Fontoura=20Solla?= Date: Thu, 30 Jul 2026 22:07:40 -0300 Subject: [PATCH 2/3] =?UTF-8?q?fix(test):=20isola=20munic=C3=ADpios=20nos?= =?UTF-8?q?=20homeSearch=20specs=20para=20evitar=20deadlock?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Specs de portfolio deixam de mutar cairu/feira compartilhados e passam a usar getMunicipality() alocado; testDatabaseLease ignora updatedAt volátil. --- tests/int/homeSearchActivities.int.spec.ts | 18 +++++++++--------- tests/int/homeSearchAdvisors.int.spec.ts | 8 ++++---- tests/int/homeSearchDemands.int.spec.ts | 14 +++++++------- tests/int/homeSearchLeaderships.int.spec.ts | 14 +++++++------- tests/int/homeSearchSuggestions.int.spec.ts | 4 ++-- tests/int/testDatabaseLease.int.spec.ts | 12 ++++++++++-- 6 files changed, 39 insertions(+), 31 deletions(-) diff --git a/tests/int/homeSearchActivities.int.spec.ts b/tests/int/homeSearchActivities.int.spec.ts index ea9812c4..db3341df 100644 --- a/tests/int/homeSearchActivities.int.spec.ts +++ b/tests/int/homeSearchActivities.int.spec.ts @@ -43,7 +43,7 @@ describe('searchHomeActivities (B51)', () => { it('matches activities by word-start on the title for staff', async () => { const fixtures = campaignFixtures() const coordinator = await fixtures.createCampaignUser('coordinator') - const municipality = await fixtures.getMunicipality('cairu') + const municipality = await fixtures.getMunicipality() const title = fixtures.value('Caminhada Zeca Centro') const activity = await createActivityRecord( payload, @@ -56,13 +56,13 @@ describe('searchHomeActivities (B51)', () => { const hit = result.find((row) => row.id === activity.id) expect(hit?.title).toBe(title) expect(hit?.slug).toBe(activity.slug) - expect(hit?.secondary).toContain('Cairu') + expect(hit?.secondary).toContain(municipality.name) }) it('does not match mid-word without word-start boundary', async () => { const fixtures = campaignFixtures() const coordinator = await fixtures.createCampaignUser('coordinator') - const municipality = await fixtures.getMunicipality('cairu') + const municipality = await fixtures.getMunicipality() const title = fixtures.value('Feira Comunitária') const activity = await createActivityRecord( payload, @@ -80,22 +80,22 @@ describe('searchHomeActivities (B51)', () => { const advisor = await fixtures.createCampaignUser('advisor') const coordinator = await fixtures.createCampaignUser('coordinator') - const administered = await fixtures.getMunicipality('cairu') - const other = await fixtures.getMunicipality('feira-de-santana') + const administered = await fixtures.getMunicipality() + const other = await fixtures.getMunicipality() await fixtures.assignMunicipalityAdvisors(administered.id, [advisor.id]) const scopeToken = fixtures.value('scope-atividade') const inScope = await createActivityRecord( payload, coordinator, - validActivityInput(administered.id, `Caminhada ${scopeToken} Cairu`), + validActivityInput(administered.id, `Caminhada ${scopeToken} ${administered.name}`), ) fixtures.own('activity', inScope.id) const outOfScope = await createActivityRecord( payload, coordinator, - validActivityInput(other.id, `Caminhada ${scopeToken} Feira`), + validActivityInput(other.id, `Caminhada ${scopeToken} ${other.name}`), ) fixtures.own('activity', outOfScope.id) @@ -111,7 +111,7 @@ describe('searchHomeActivities (B51)', () => { it('includes drafts without startAt with Data a definir secondary', async () => { const fixtures = campaignFixtures() const coordinator = await fixtures.createCampaignUser('coordinator') - const municipality = await fixtures.getMunicipality('cairu') + const municipality = await fixtures.getMunicipality() const title = fixtures.value('Rascunho sem data') const activity = await createActivityRecord(payload, coordinator, { title, @@ -124,7 +124,7 @@ describe('searchHomeActivities (B51)', () => { const result = await searchHomeActivities(payload, coordinator, 'Rascunho') const hit = result.find((row) => row.id === activity.id) expect(hit?.secondary).toContain('Data a definir') - expect(hit?.secondary).toContain('Cairu') + expect(hit?.secondary).toContain(municipality.name) }) it('rejects leaders from home search', async () => { diff --git a/tests/int/homeSearchAdvisors.int.spec.ts b/tests/int/homeSearchAdvisors.int.spec.ts index 165a1e43..1de931f2 100644 --- a/tests/int/homeSearchAdvisors.int.spec.ts +++ b/tests/int/homeSearchAdvisors.int.spec.ts @@ -52,10 +52,10 @@ describe('searchHomeAdvisors (B50)', () => { const fixtures = campaignFixtures() const coordinator = await fixtures.createCampaignUser('coordinator') const advisor = await fixtures.createCampaignUser('advisor', { name: 'Carteira Zeca' }) - const cairu = await fixtures.getMunicipality('cairu') - const feira = await fixtures.getMunicipality('feira-de-santana') - await fixtures.assignMunicipalityAdvisors(cairu.id, [advisor.id]) - await fixtures.assignMunicipalityAdvisors(feira.id, [advisor.id]) + const first = await fixtures.getMunicipality() + const second = await fixtures.getMunicipality() + await fixtures.assignMunicipalityAdvisors(first.id, [advisor.id]) + await fixtures.assignMunicipalityAdvisors(second.id, [advisor.id]) const result = await searchHomeAdvisors(payload, coordinator, 'Carteira') const hit = result.find((row) => row.id === advisor.id) diff --git a/tests/int/homeSearchDemands.int.spec.ts b/tests/int/homeSearchDemands.int.spec.ts index 95877046..8d54c848 100644 --- a/tests/int/homeSearchDemands.int.spec.ts +++ b/tests/int/homeSearchDemands.int.spec.ts @@ -33,7 +33,7 @@ describe('searchHomeDemands (B53)', () => { it('matches demands by word-start on the title for staff', async () => { const fixtures = campaignFixtures() const coordinator = await fixtures.createCampaignUser('coordinator') - const municipality = await fixtures.getMunicipality('cairu') + const municipality = await fixtures.getMunicipality() const title = fixtures.value('Material Zeca Centro') const demand = await fixtures.createCampaignDemand({ municipality, @@ -45,14 +45,14 @@ describe('searchHomeDemands (B53)', () => { const hit = result.find((row) => row.id === demand.id) expect(hit?.title).toBe(title) expect(hit?.slug).toBe(demand.slug) - expect(hit?.secondary).toContain('Cairu') + expect(hit?.secondary).toContain(municipality.name) expect(hit?.secondary).toContain('Aberta') }) it('does not match mid-word without word-start boundary', async () => { const fixtures = campaignFixtures() const coordinator = await fixtures.createCampaignUser('coordinator') - const municipality = await fixtures.getMunicipality('cairu') + const municipality = await fixtures.getMunicipality() const title = fixtures.value('Transporte Comunitário') const demand = await fixtures.createCampaignDemand({ municipality, @@ -69,19 +69,19 @@ describe('searchHomeDemands (B53)', () => { const advisor = await fixtures.createCampaignUser('advisor') const coordinator = await fixtures.createCampaignUser('coordinator') - const administered = await fixtures.getMunicipality('cairu') - const other = await fixtures.getMunicipality('feira-de-santana') + const administered = await fixtures.getMunicipality() + const other = await fixtures.getMunicipality() await fixtures.assignMunicipalityAdvisors(administered.id, [advisor.id]) const scopeToken = fixtures.value('scope-demanda') const inScope = await fixtures.createCampaignDemand({ municipality: administered, - title: `Material ${scopeToken} Cairu`, + title: `Material ${scopeToken} ${administered.name}`, createdBy: coordinator, }) const outOfScope = await fixtures.createCampaignDemand({ municipality: other, - title: `Material ${scopeToken} Feira`, + title: `Material ${scopeToken} ${other.name}`, createdBy: coordinator, }) diff --git a/tests/int/homeSearchLeaderships.int.spec.ts b/tests/int/homeSearchLeaderships.int.spec.ts index 0a80c878..ffd54d23 100644 --- a/tests/int/homeSearchLeaderships.int.spec.ts +++ b/tests/int/homeSearchLeaderships.int.spec.ts @@ -33,7 +33,7 @@ describe('searchHomeLeaderships (B49)', () => { it('matches leaderships by word-start on contact name', async () => { const fixtures = campaignFixtures() const coordinator = await fixtures.createCampaignUser('coordinator') - const municipality = await fixtures.getMunicipality('cairu') + const municipality = await fixtures.getMunicipality() const contact = await fixtures.createContact({ name: 'Zeca Pagodinho' }) const leadership = await fixtures.createLeadership({ contact: contact.id, @@ -43,13 +43,13 @@ describe('searchHomeLeaderships (B49)', () => { const result = await searchHomeLeaderships(payload, coordinator, 'Zeca') const hit = result.find((row) => row.id === leadership.id) expect(hit?.name).toBe('Zeca Pagodinho') - expect(hit?.municipalitiesSummary).toContain('Cairu') + expect(hit?.municipalitiesSummary).toContain(municipality.name) }) it('does not match mid-word without word-start boundary', async () => { const fixtures = campaignFixtures() const coordinator = await fixtures.createCampaignUser('coordinator') - const municipality = await fixtures.getMunicipality('cairu') + const municipality = await fixtures.getMunicipality() const contact = await fixtures.createContact({ name: 'Maria Silva' }) await fixtures.createLeadership({ contact: contact.id, @@ -65,15 +65,15 @@ describe('searchHomeLeaderships (B49)', () => { const advisor = await fixtures.createCampaignUser('advisor') const coordinator = await fixtures.createCampaignUser('coordinator') - const administered = await fixtures.getMunicipality('cairu') - const other = await fixtures.getMunicipality('feira-de-santana') + const administered = await fixtures.getMunicipality() + const other = await fixtures.getMunicipality() await fixtures.assignMunicipalityAdvisors(administered.id, [advisor.id]) // Unique token per run: the minimal seed ships leaderships whose contact // names collide with fixed queries like 'Lider' (word-start match). const scopeToken = fixtures.value('scope-lideranca') - const inScope = await fixtures.createContact({ name: `${scopeToken} Cairu` }) - const outOfScope = await fixtures.createContact({ name: `${scopeToken} Feira` }) + const inScope = await fixtures.createContact({ name: `${scopeToken} ${administered.name}` }) + const outOfScope = await fixtures.createContact({ name: `${scopeToken} ${other.name}` }) const scopedLeadership = await fixtures.createLeadership({ contact: inScope.id, municipalities: [administered.id], diff --git a/tests/int/homeSearchSuggestions.int.spec.ts b/tests/int/homeSearchSuggestions.int.spec.ts index 4beac880..7416d68a 100644 --- a/tests/int/homeSearchSuggestions.int.spec.ts +++ b/tests/int/homeSearchSuggestions.int.spec.ts @@ -39,11 +39,11 @@ describe('loadHomeSearchSuggestions (B68)', () => { it('scopes advisor suggestions to administered municipalities', async () => { const fixtures = campaignFixtures() const advisor = await fixtures.createCampaignUser('advisor') - const administered = await fixtures.getMunicipality('cairu') + const administered = await fixtures.getMunicipality() await fixtures.assignMunicipalityAdvisors(administered.id, [advisor.id]) const result = await loadHomeSearchSuggestions(payload, advisor) - expect(result.municipalities.every((hit) => hit.slug === 'cairu')).toBe(true) + expect(result.municipalities.every((hit) => hit.slug === administered.slug)).toBe(true) }) it('rejects leaders from home search suggestions', async () => { diff --git a/tests/int/testDatabaseLease.int.spec.ts b/tests/int/testDatabaseLease.int.spec.ts index c9bfb94e..27b292b1 100644 --- a/tests/int/testDatabaseLease.int.spec.ts +++ b/tests/int/testDatabaseLease.int.spec.ts @@ -214,7 +214,11 @@ describe('test database lease', () => { }) const after = await withInviteConsent(payload, async (consent) => consent) - expect(after).toEqual(before) + expect(after).toMatchObject({ + id: before.id, + key: before.key, + text: before.text, + }) }) it('restores the configured consent and releases after a callback assertion failure', async () => { @@ -353,7 +357,11 @@ describe('test database lease', () => { expect(failure).toBeInstanceOf(AggregateError) expect((failure as AggregateError).errors).toEqual([releaseFailure, rollbackFailure]) const after = await withInviteConsent(payload, async (consent) => consent) - expect(after).toEqual(before) + expect(after).toMatchObject({ + id: before.id, + key: before.key, + text: before.text, + }) const subsequent = await acquireTestDatabaseLease(payload, CAMPAIGN_INVITE_CONSENT_LEASE_KEY) await subsequent.release() From 773e4426eb9e3c8d325a2615502fb2a49427ba51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Ign=C3=A1cio=20Fontoura=20Solla?= Date: Thu, 30 Jul 2026 22:28:50 -0300 Subject: [PATCH 3/3] =?UTF-8?q?fix(test):=20queries=20distintas=20em=20spe?= =?UTF-8?q?cs=20de=20portfolio=20de=20munic=C3=ADpio?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nova Soure vs Nova Canaã compartilham o primeiro token; distinctWordStartQuery escolhe palavra que casa só com o alvo e evita falso positivo no assessor. --- tests/helpers/distinctWordStartQuery.ts | 27 +++++++++++++++++++ .../int/homeSearchMunicipalities.int.spec.ts | 5 ++-- .../searchStaffMunicipalityHits.int.spec.ts | 5 ++-- .../unit/distinctWordStartQuery.unit.spec.ts | 14 ++++++++++ 4 files changed, 47 insertions(+), 4 deletions(-) create mode 100644 tests/helpers/distinctWordStartQuery.ts create mode 100644 tests/unit/distinctWordStartQuery.unit.spec.ts diff --git a/tests/helpers/distinctWordStartQuery.ts b/tests/helpers/distinctWordStartQuery.ts new file mode 100644 index 00000000..984eab40 --- /dev/null +++ b/tests/helpers/distinctWordStartQuery.ts @@ -0,0 +1,27 @@ +import { homeSearchQueryIsActive } from '@/lib/campaignHomeSearchContract' +import { normalizeHomeSearchName } from '@/lib/homeSearchMunicipalityMatch' +import { matchesNormalizedAtWordStart } from '@/lib/wordStartFilter' + +/** + * Picks a word-start query that matches `targetName` but not `excludeName`. + * Portfolio scoping specs allocate two municipalities per test; sharing the + * first token (e.g. Nova Soure vs Nova Canaã) makes advisor assertions flaky. + */ +export function distinctWordStartQuery(targetName: string, excludeName: string): string { + const normalizedTarget = normalizeHomeSearchName(targetName) + const normalizedExclude = normalizeHomeSearchName(excludeName) + + for (const word of targetName.split(/\s+/)) { + const query = word.trim() + if (!homeSearchQueryIsActive(normalizeHomeSearchName(query))) continue + const normalizedQuery = normalizeHomeSearchName(query) + if ( + matchesNormalizedAtWordStart(normalizedTarget, normalizedQuery) && + !matchesNormalizedAtWordStart(normalizedExclude, normalizedQuery) + ) { + return query + } + } + + throw new Error(`No word-start query matches "${targetName}" without matching "${excludeName}"`) +} diff --git a/tests/int/homeSearchMunicipalities.int.spec.ts b/tests/int/homeSearchMunicipalities.int.spec.ts index 77cd69b5..cbbbb447 100644 --- a/tests/int/homeSearchMunicipalities.int.spec.ts +++ b/tests/int/homeSearchMunicipalities.int.spec.ts @@ -9,6 +9,7 @@ import config from '@/payload.config' import { searchHomeMunicipalities } from '@/utilities/homeSearch/searchHomeMunicipalities' import { installCampaignFixtures } from '../helpers/campaignFixtures' +import { distinctWordStartQuery } from '../helpers/distinctWordStartQuery' let payload: Payload const campaignFixtures = installCampaignFixtures({ @@ -51,8 +52,8 @@ describe('searchHomeMunicipalities (B48)', () => { const other = await fixtures.getMunicipality() await fixtures.assignMunicipalityAdvisors(administered.id, [advisor.id]) - const otherQuery = other.name.split(/\s+/)[0]! - const administeredQuery = administered.name.split(/\s+/)[0]! + const otherQuery = distinctWordStartQuery(other.name, administered.name) + const administeredQuery = distinctWordStartQuery(administered.name, other.name) const advisorResult = await searchHomeMunicipalities(payload, advisor, otherQuery) expect(advisorResult.municipalities.map((hit) => hit.slug)).toEqual([]) diff --git a/tests/int/searchStaffMunicipalityHits.int.spec.ts b/tests/int/searchStaffMunicipalityHits.int.spec.ts index 9e12e7dc..7a6fcc20 100644 --- a/tests/int/searchStaffMunicipalityHits.int.spec.ts +++ b/tests/int/searchStaffMunicipalityHits.int.spec.ts @@ -9,6 +9,7 @@ import config from '@/payload.config' import { searchStaffMunicipalityHits } from '@/utilities/homeSearch/searchStaffMunicipalityHits' import { installCampaignFixtures } from '../helpers/campaignFixtures' +import { distinctWordStartQuery } from '../helpers/distinctWordStartQuery' let payload: Payload const campaignFixtures = installCampaignFixtures({ @@ -50,8 +51,8 @@ describe('searchStaffMunicipalityHits (B60)', () => { const other = await fixtures.getMunicipality() await fixtures.assignMunicipalityAdvisors(administered.id, [advisor.id]) - const otherQuery = other.name.split(/\s+/)[0]! - const administeredQuery = administered.name.split(/\s+/)[0]! + const otherQuery = distinctWordStartQuery(other.name, administered.name) + const administeredQuery = distinctWordStartQuery(administered.name, other.name) const advisorResult = await searchStaffMunicipalityHits(payload, advisor, otherQuery) expect(advisorResult.map((hit) => hit.slug)).toEqual([]) diff --git a/tests/unit/distinctWordStartQuery.unit.spec.ts b/tests/unit/distinctWordStartQuery.unit.spec.ts new file mode 100644 index 00000000..d90b6450 --- /dev/null +++ b/tests/unit/distinctWordStartQuery.unit.spec.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest' + +import { distinctWordStartQuery } from '../helpers/distinctWordStartQuery' + +describe('distinctWordStartQuery', () => { + it('returns a word that matches only the target name', () => { + expect(distinctWordStartQuery('Nova Canaã', 'Nova Soure')).toBe('Canaã') + expect(distinctWordStartQuery('Nova Soure', 'Nova Canaã')).toBe('Soure') + }) + + it('falls back to the first word when names do not share a prefix token', () => { + expect(distinctWordStartQuery('Cairu', 'Feira de Santana')).toBe('Cairu') + }) +})