feat(B180): gate fail-closed de leader nas tools eleitorais legadas do Sollinha (getTopDeputies/getMunicipalityVotes)

ElectionDataGate compartilhado (3 call sites) + refactor da B177 para o helper.
This commit is contained in:
Francisco Solla 2026-08-09 14:50:45 -03:00
parent 2076234913
commit ff53e15e7e
7 changed files with 237 additions and 12 deletions

View file

@ -10,6 +10,8 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente
**Recently resolved (2026-08-09):** **C93** (Issue #437) — botão "Link de import" da agenda destravado **sem filtros** (sucessor direto do C16): com zero filtros ativos, staff gera o link do feed **completo dentro do escopo de leitura do criador** — coordinator/candidate = tudo; advisor = portfólio dos municípios que administra — com fail-closed/revogação de C16 intactos. O servidor já cobria o caso (vazio = escopo do criador em `buildFeedWhere`/`resolveFeedCreatorAccess`; descrição do campo `filterMunicipality`); a entrega removeu o gate `hasFilters → disabled` de `CalendarFeedButton` (prop morto `state` incluído), manteve `hasFilters` só no "Limpar filtros" e neutralizou a copy do diálogo ("este recorte" → "a agenda"). Testes: 3 int de conteúdo sem filtro (coordinator = A+B; advisor = só o portfólio, nada fora vaza; advisor sem município → feed vazio fail-closed) + unit component (botão habilitado com zero filtros e fluxo nomear→criar). Sem migration, access, Consent ou schema. Intenção [`c93-gerar-link-import-sem-filtros.md`](plans/c93-gerar-link-import-sem-filtros.md); impl [`c93-gerar-link-import-sem-filtros-impl.md`](plans/c93-gerar-link-import-sem-filtros-impl.md).
**Recently resolved (2026-08-09):** **B180** (Issue #474) — as tools eleitorais legadas do Sollinha (`getTopDeputies`, `getMunicipalityVotes`) ganharam o **gate fail-closed de leader lockdown** que a B177 introduziu só na tool nova: `assertCanReadElectionData(ctx.user)` no topo do `execute` → `{ error: 'Leitura de dados eleitorais negada.' }` antes de qualquer query (o route `api/ai-chat` expõe todas as tools a todos os papéis). O padrão chegou a **3 call sites** → extraído um helper compartilhado `electionDataGate(ctx)` (`src/utilities/ai/tools/electionDataGate.ts`, `true | { error }`), e `getLeadingMunicipalities` refatorado para usá-lo (mensagem com fonte única). Unit tests novos (`electionToolsLockdown.unit.spec.ts`, 11): helper (leader nega/staff passa), as 3 tools com leader + payload stub intocado (fail-closed antes de query, inclusive no path SQL de terceiro candidato) e staff-proceeds por papel (coordinator/advisor/candidate). Sem migration/Consent/UI/prompt. Intenção [`sollinha-tools-eleitorais-leader-lockdown.md`](plans/sollinha-tools-eleitorais-leader-lockdown.md); impl [`sollinha-tools-eleitorais-leader-lockdown-impl.md`](plans/sollinha-tools-eleitorais-leader-lockdown-impl.md).
**Recently resolved (2026-08-09):** **B176** (Issue #458) — `/campanha/municipios` ganhou três dimensões de filtro na omnibox — **Dobradinha** (nome do contato), **Liderança** (nome do contato) e **Partido** (sigla) — com recortes "Sem dobradinha"/"Sem liderança"/"Sem partido" (precedente "Sem nível"). Params aditivos `stateDeputy`/`leadership`/`party` na URL congelada (sentinelas `sem_dobradinha`/`sem_lideranca`, reuso de `sem_partido`); `buildMunicipalityListWhere` ganhou um catálogo request-scoped fail-closed (`municipalityRelationSets.ts`, lazy: metade liderança só com filtro ativo) que vira as leituras reversa (`leadership.municipalities`) e cruzada (`stateDeputy.party`) em `where` — sem migration, paginação nativa mantida; facets estendidas (`stateDeputyIDs`/`leadershipOptions`/`parties`) e mapa compartilha o catálogo. **Coluna Dobradinhas liberada ao Assessor** (staff-wide): servidor já estava scoped (B37/B157 `reloadStaffActor` + `canUpdateMunicipality`), a mudança foi só de gate de UI (lista/mobile/picker) + catálogo full-staff para a célula (decisão do gate humano 2026-08-09). Extrato do par "sentinela + `exists:false`" em `campaignListUrl` (`NO_PARTY_FILTER_VALUE`, `splitAbsenceFilterValues`, `collapseListWhereOrBranches`) com 5 call sites. E2E: assessor vê/edita a coluna no portfólio; filtros pelas três dimensões; teste de coluna-restrita invertido. Intenção [`filtros-municipios-dobradinha-lideranca.md`](plans/filtros-municipios-dobradinha-lideranca.md); impl [`filtros-municipios-dobradinha-lideranca-impl.md`](plans/filtros-municipios-dobradinha-lideranca-impl.md).
**Recently resolved (2026-08-09):** **B174 reverso** (sem Issue — pedido direto) — a Sollinha (`/campanha/api/ai-chat`) voltou do `@ai-sdk/deepinfra` para o **`@ai-sdk/deepseek`** (API oficial `api.deepseek.com`): o cache-hit do V4-Flash direto custa **$0.0028/M** contra **$0.018/M** na Deep Infra (~6,4× mais barato), e a Sollinha tem prefixo estável (system prompt + tools + histórico) — o hit ratio alto derruba o custo efetivo mesmo com miss ($0.14 vs $0.09) e output ($0.28 vs $0.18) mais caros. Mesmo modelo (`deepseek-v4-flash`), mesmas tools; `@ai-sdk/deepseek` **pinado em `3.0.19`** (não `^3.0.19`, que flutuaria a 3.0.26 e puxaria a ferroviária `provider@4.0.7` duplicada — o lock ficou na `4.0.4/5.0.18` do `ai@7.0.47`, verificado no lockfile). **STT fica na Deep Infra** (B173): `whisper-large-v3` a $0.00045/min de áudio é o mais barato verificado (Groq $0.0019, OpenAI $0.006); `.env.example` agora documenta **as duas chaves** — `DEEPSEEK_API_KEY` (LLM) e `DEEPINFRA_API_KEY` (STT). Aviso da DeepSeek: aumento de preço anunciado ("em breve"). Sem migration, access, UI ou teste novo; smoke de deploy = 2–3 perguntas forçando tool calling real antes de confiar.

View file

@ -0,0 +1,94 @@
# Impl: B180 — Sollinha: travar tools eleitorais legadas (getTopDeputies/getMunicipalityVotes) contra leader
Status: aprovado
Atualizado em: 2026-08-09
Issue: #474
Intenção: docs/plans/sollinha-tools-eleitorais-leader-lockdown.md
Appetite restante: ~0,5 dia eng (herdado); sem migration / Consent / collection / UI
## Registro de execução (2026-08-09)
- **Entrega:** `electionDataGate(ctx)` novo (`src/utilities/ai/tools/electionDataGate.ts`, `true | { error }` — try/catch do `assertCanReadElectionData` + mensagem pt-BR com fonte única); gate no topo do `execute` das 2 tools legadas (`getTopDeputies`, `getMunicipalityVotes`); `getLeadingMunicipalityCities` (B177) refatorado do inline para o helper. Nenhuma outra mudança (payload queries, bypasses justificados, prompt e registro intactos).
- **Divergência da hipótese da intenção:** nenhuma nos aceites; a decisão de extrair o helper (regra 3+ = extrair, já sinalizada na intenção) foi executada como recomendado no plano.
- **Cobertura por tests:** `tests/unit/electionToolsLockdown.unit.spec.ts` (11 testes) — helper (leader nega com o shape do chat / staff passa), as 3 tools com `ctx.user` leader + payload stub que **lança se consultado** (prova fail-closed antes de qualquer query; `getLeadingMunicipalities` com candidato não-campanha para forçar o path SQL), staff-proceeds parametrizado por coordinator/advisor/candidate com `find → { docs: [] }` (município real resolve pelo catálogo; shape vazio normal).
- **Simplify:** fixes do review aplicados — comentários de bypass das 2 tools legadas corrigidos (diziam "collection-level canReadElectionData guard", mas `overrideAccess: true` ignora a collection; o gate real agora é o `electionDataGate` no tool level); lockdown de `getLeadingMunicipalities` testado no path de payload (não só no artefato); staff-proceeds cobre os 3 papéis staff (não só coordinator). Rejeitado criar gate genérico HOF para outros domínios — só eleitorais têm lockdown aqui.
- **Triage de débitos (gate humano 2026-08-09):** S1 `resolveFederalDeputy`/`resolveFromVoteRows` (exportados, `overrideAccess: true`) não têm assert próprio — único caller é o execute gateado, sem vazamento atual. **Deferido com gatilho: se um novo caller de `resolveFederalDeputy` nascer fora de um tool gateado** (ou a família eleitoral ganhar tool nova), adicionar `assertCanReadElectionData` na resolução (espelho do loader da B177) — threadando `user` na assinatura. S2–S5 descartados (cheap_polish; score ≤2).
## Explicitamente fora (triage)
- `resolveFederalDeputy`/`resolveFromVoteRows` sem assert próprio — deferido com gatilho (S1 acima).
- Mensagem de erro duplicada entre o throw do assert e o gate — duas superfícies distintas (falha server vs feedback de chat); spec pina o shape do chat.
- Boilerplate de cast do spec, stub só de `find`, staff-test do execute da B177 — polish de teste, sem valor funcional.
- `searchEntities`/outras tools não-eleitorais para leader (intenção: fora de escopo).
- Flake `testDatabaseLease.int.spec.ts` (S2, triage B177).
- **Deferidos (registrados aqui, sem Issue):** nada novo; flake `testDatabaseLease` permanece fora de escopo (S2, triage B177).
## Leitura da intenção
- **Outcome:** `getTopDeputies` e `getMunicipalityVotes` fazem `assertCanReadElectionData(ctx.user)` no topo do `execute` (fail-closed, mesmo padrão da B177); nenhum outro comportamento muda; sem migration/Consent/collection/UI.
- **O que NÃO negociar:** leader lockdown (liderança não conversa sobre municípios/dados eleitorais no Sollinha); staff idêntico; sem mudança no system prompt.
- **O que reavaliar:** a sugestão de helper compartilhado — "2 call sites = inline; 3+ = extrair; aqui já são 3 com a nova" — é decisão desta engenharia (ver abaixo).
## Abordagem recomendada
```mermaid
flowchart LR
R[api/ai-chat<br/>buildAITools ctx] --> T1[getTopDeputies]
R --> T2[getMunicipalityVotes]
R --> T3[getLeadingMunicipalities]
T1 --> G[electionDataGate ctx<br/>novo helper compartilhado]
T2 --> G
T3 --> G
G -- leader/deny --> E[{ error: 'Leitura de dados eleitorais negada.' }]
G -- staff --> Q[payload.find<br/>comportamento atual intacto]
```
**Opções consideradas:**
- **A — Inline nas 2 tools legadas:** replicar o try/catch da B177 em `getTopDeputies` e `getMunicipalityVotes` (3 cópias no total, uma por tool).
- **B — Helper compartilhado `electionDataGate(ctx)`** em `src/utilities/ai/tools/electionDataGate.ts` (retorna `true | { error }`), usado pelas 3 tools — incluindo refatorar `getLeadingMunicipalities` para usá-lo no lugar do try/catch inline da B177.
- **C — Gate só na collection (tirar `overrideAccess`):** descartada — a Local API sem `req.user` trata como anônimo e as tools chamam `payload.find` sem contexto autenticado; `canReadElectionData` nega anônimo, então remover o bypass quebraria **staff** também (e o comentário existente já justifica o bypass: "access gated at the tool level").
**Recomendação: B.** A intenção fixa a regra 2=inline/3+=extrair e o padrão chega a 3 call sites; o texto de erro é um contrato fail-closed do chat e merece uma única fonte de verdade. `electionDataGate` é um módulo raso mas de profundidade real: centraliza o try/catch + a mensagem e encapsula o conhecimento "deny no chat = mensagem pt-BR fixa" que hoje vazaria em 3 tools. **Rejeitada:** A (3 cópias da mensagem; drift futuro no texto), C (quebra staff — a Local API das tools não carrega usuário autenticado).
### Componentes / mudanças
- **`electionDataGate`** (`src/utilities/ai/tools/electionDataGate.ts`, novo): `(ctx: AIToolContext): true | { error: string }` — `try { assertCanReadElectionData(ctx.user); return true } catch { return { error: 'Leitura de dados eleitorais negada.' } }`. Sem `server-only` (não importa payload/db-postgres em runtime — só `campaignAccess` + tipo; o guard de convenção só exige `server-only` para import de payload/next/db-postgres). Sem migration.
- **`getTopDeputies`** (`src/utilities/ai/tools/getTopDeputies.ts`): primeira instrução do `execute`: `const gate = electionDataGate(ctx); if (gate !== true) return gate`. O resto intacto (bypass justificado continua no `payload.find`).
- **`getMunicipalityVotes`** (`src/utilities/ai/tools/getMunicipalityVotes.ts`): idem — gate no topo do `execute`, antes de qualquer resolução de município.
- **`getLeadingMunicipalities`** (`src/utilities/ai/tools/getLeadingMunicipalities.ts`): substituir o try/catch inline (linhas 342–346) pelo helper; sem outras mudanças.
- **Access / Consent:** reusa `assertCanReadElectionData` (única redação da regra, `src/utilities/access/elections.ts`); nenhuma chave de Consent.
- **UI:** Impeccable A — N/A; resposta de erro já é o shape `{ error }` do chat.
### Dados → forma
N/A — sem dados novos na UI; a mensagem de deny já existe em produção na B177.
## Fases verificáveis
1. **Tracer / server** — helper + gates nas 2 tools legadas + refactor da B177; unit tests novos (`tests/unit/electionToolsLockdown.unit.spec.ts`):
- helper: leader → `{ error }`, coordinator → `true`;
- as 3 tools com `ctx.user` leader → `{ error: 'Leitura de dados eleitorais negada.' }` e payload stub **não tocado** (fail-closed antes de qualquer query);
- staff-proceeds: `getTopDeputies`/`getMunicipalityVotes` com coordinator + payload stub (`find` → `{ docs: [] }`) retornam o shape vazio normal — prova que staff não mudou (nome real de município, e.g. "Feira de Santana", para os resolves puros).
2. **Gates** — `pnpm gate:fast` na iteração; entrega com `pnpm push`.
3. **Docs** — entrada curta em `docs/CHANGELOG-AGENTS.md` + este impl plan commitado.
## Rabbit holes / Não escopo (engenharia)
- Não criar "chat gate" genérico (HOF `withX`) para outras domains — só as eleitorais têm lockdown aqui; `calculate`/`searchEntities` etc. continuam como estão (intenção: fora de escopo).
- Não tocar no system prompt, nem em `buildAITools`/route (nenhuma mudança de registro).
- Não mexer em `searchEntities`/outras tools não-eleitorais (explicitamente fora da intenção).
- Não introduzir `server-only` nas tools legadas (não importam payload/db-postgres; o guard de convenção não pede).
## Riscos e mitigação
- **Refactor da B177 (merged ontem):** padrão idêntico, troca mecânica; mitigado por unit test do lockdown nas 3 tools + staff-proceeds.
- **Drift de mensagem:** a string de erro passa a ter uma única fonte (o helper); os testes pinam o texto nas 3 tools.
- **Gate antes da resolução de input:** leader recebe deny mesmo com município inválido — intencional (fail-closed, sem leak de comportamento).
- **Flake de CI `testDatabaseLease.int.spec.ts`:** fora de escopo, descartado no triage da B177 (S2); reavaliar se recorrer.
## Aceite de engenharia
- [ ] Aceite de produto da intenção coberto (2 tools legadas gateadas; leader nega; staff idêntico; sem migration/Consent/UI/prompt)
- [ ] Invariantes AGENTS/engineering-standards (access por `assertCanReadElectionData` existente; copy pt-BR; identificadores em inglês)
- [ ] Testes previstos: unit (lockdown nas 3 tools + helper + staff-proceeds) — onde o path de acesso muda

View file

@ -0,0 +1,20 @@
import type { AIToolContext } from '@/lib/ai/types'
import { assertCanReadElectionData } from '@/utilities/campaignAccess'
/**
* Fail-closed election-data gate for the AI chat tools. Returns `true` for
* staff actors and the chat-shaped denial for everyone else (leader lockdown:
* "liderança não tem essa conversa"). The error object is the exact shape
* tools return, so call sites just early-return it — one source of truth for
* the message across the three election tools.
*/
export const electionDataGate = (
ctx: AIToolContext,
): true | { error: 'Leitura de dados eleitorais negada.' } => {
try {
assertCanReadElectionData(ctx.user)
return true
} catch {
return { error: 'Leitura de dados eleitorais negada.' }
}
}

View file

@ -20,6 +20,7 @@ import {
type LeadingMunicipalityRow,
} from '@/lib/leadingMunicipalities'
import { municipalityCatalogEntriesForCity } from '@/lib/municipalityCatalog'
import { electionDataGate } from '@/utilities/ai/tools/electionDataGate'
import { assertCanReadElectionData } from '@/utilities/campaignAccess'
import { drizzleResultRows } from '@/utilities/drizzleBulk'
@ -339,11 +340,8 @@ export const getLeadingMunicipalities = (ctx: AIToolContext) =>
}),
execute: async ({ candidate, year, topN }) => {
// Leader lockdown: municipal ranking conversations are staff-only.
try {
assertCanReadElectionData(ctx.user)
} catch {
return { error: 'Leitura de dados eleitorais negada.' }
}
const gate = electionDataGate(ctx)
if (gate !== true) return gate
if (!VOTE_YEARS.includes(year)) {
return {

View file

@ -11,6 +11,7 @@ import {
} from '@/lib/electionResults'
import { municipalityCatalogEntriesForCity } from '@/lib/municipalityCatalog'
import { resolveMunicipalityName } from '@/lib/municipalityNameAliases'
import { electionDataGate } from '@/utilities/ai/tools/electionDataGate'
import { municipalityElectionGeographyForSlug } from '@/utilities/municipality/municipalityElectionGeography'
const VOTE_YEARS = [ELECTION_YEAR_2014, ELECTION_YEAR_2018, ELECTION_YEAR_2022] as const
@ -31,6 +32,10 @@ export const getMunicipalityVotes = (ctx: AIToolContext) =>
municipality: z.string().describe('Municipality name or slug.'),
}),
execute: async ({ municipality }) => {
// Leader lockdown: municipal election conversations are staff-only.
const gate = electionDataGate(ctx)
if (gate !== true) return gate
const { payload } = ctx
// Resolve to catalog entry
@ -80,9 +85,7 @@ async function queryVotes(
limit: 0,
pagination: false,
select: { votes: true },
// Bypass: election data is public TSE results; access gated by
// the collection-level canReadElectionData guard. Query scope
// was resolved against the authenticated user's municipality.
// Bypass: public TSE vote rows; access gated by electionDataGate at the tool level.
overrideAccess: true,
}),
payload.find({
@ -100,8 +103,7 @@ async function queryVotes(
limit: 0,
pagination: false,
select: { votosValidos: true },
// Bypass: election data is public TSE results; access gated by
// the collection-level canReadElectionData guard.
// Bypass: public TSE tally rows; access gated by electionDataGate at the tool level.
overrideAccess: true,
}),
])

View file

@ -5,6 +5,7 @@ import type { AIToolContext } from '@/lib/ai/types'
import { ELECTION_YEAR_2022, FEDERAL_DEPUTY_OFFICE } from '@/lib/electionResults'
import { municipalityCatalogEntriesForCity } from '@/lib/municipalityCatalog'
import { resolveMunicipalityName } from '@/lib/municipalityNameAliases'
import { electionDataGate } from '@/utilities/ai/tools/electionDataGate'
import { municipalityElectionGeographyForSlug } from '@/utilities/municipality/municipalityElectionGeography'
type RankedDeputy = {
@ -32,6 +33,10 @@ export const getTopDeputies = (ctx: AIToolContext) =>
limit: z.number().optional().default(5).describe('Number of deputies to return (max 20).'),
}),
execute: async ({ municipality, year, limit }) => {
// Leader lockdown: municipal election conversations are staff-only.
const gate = electionDataGate(ctx)
if (gate !== true) return gate
const { payload } = ctx
const canonical = resolveMunicipalityName(municipality) ?? municipality
const entries = municipalityCatalogEntriesForCity(canonical)
@ -63,8 +68,7 @@ export const getTopDeputies = (ctx: AIToolContext) =>
limit: 0,
pagination: false,
select: { candidateNumber: true, candidateName: true, party: true, votes: true },
// Bypass: election data is public TSE results; access gated by
// the collection-level canReadElectionData guard.
// Bypass: public TSE vote rows; access gated by electionDataGate at the tool level.
overrideAccess: true,
sort: '-votes',
})

View file

@ -0,0 +1,105 @@
import type { Payload } from 'payload'
import { describe, expect, it } from 'vitest'
import type { AIToolContext } from '@/lib/ai/types'
import type { CampaignUser } from '@/payload-types'
import { electionDataGate } from '@/utilities/ai/tools/electionDataGate'
import { getLeadingMunicipalities } from '@/utilities/ai/tools/getLeadingMunicipalities'
import { getMunicipalityVotes } from '@/utilities/ai/tools/getMunicipalityVotes'
import { getTopDeputies } from '@/utilities/ai/tools/getTopDeputies'
import { stub } from '../helpers/stub'
const denied = { error: 'Leitura de dados eleitorais negada.' }
/** A payload whose find throws — any query after a failed gate is a bug. */
const untouchablePayload = stub<Payload>({
find: () => {
throw new Error('gate must fail closed before any payload query')
},
})
const ctxFor = (user: CampaignUser, payload: Payload): AIToolContext => ({ user, payload })
const leader = stub<CampaignUser>({ collection: 'campaignUser', role: 'leader' })
const coordinator = stub<CampaignUser>({ collection: 'campaignUser', role: 'coordinator' })
const emptyDocsPayload = stub<Payload>({
find: async () => ({
docs: [],
hasNextPage: false,
hasPrevPage: false,
limit: 0,
nextPage: null,
pagingCounter: 0,
prevPage: null,
totalDocs: 0,
totalPages: 0,
}),
})
type ExecutableTool = {
execute: (args: unknown, options?: unknown) => Promise<unknown>
}
describe('electionDataGate (B180)', () => {
it('denies a leader with the chat-shaped error', () => {
expect(electionDataGate(ctxFor(leader, untouchablePayload))).toEqual(denied)
})
it('lets staff pass', () => {
expect(electionDataGate(ctxFor(coordinator, untouchablePayload))).toBe(true)
})
})
describe('election tools leader lockdown (B180)', () => {
it('getTopDeputies fails closed before any payload query', async () => {
const execute = (
getTopDeputies(ctxFor(leader, untouchablePayload)) as unknown as ExecutableTool
).execute
expect(execute).toBeTypeOf('function')
await expect(execute!({ municipality: 'Feira de Santana' })).resolves.toEqual(denied)
})
it('getMunicipalityVotes fails closed before any payload query', async () => {
const execute = (
getMunicipalityVotes(ctxFor(leader, untouchablePayload)) as unknown as ExecutableTool
).execute
expect(execute).toBeTypeOf('function')
await expect(execute!({ municipality: 'Feira de Santana' })).resolves.toEqual(denied)
})
it('getLeadingMunicipalities fails closed before any payload query', async () => {
const execute = (
getLeadingMunicipalities(ctxFor(leader, untouchablePayload)) as unknown as ExecutableTool
).execute
expect(execute).toBeTypeOf('function')
// A non-campaign candidate forces the SQL/payload path — the default
// candidate reads the committed artifact and never touches payload.
await expect(execute!({ candidate: '5000' })).resolves.toEqual(denied)
})
})
describe('election tools staff behavior unchanged (B180)', () => {
const staffRoles = ['coordinator', 'advisor', 'candidate'] as const
const staffUser = (role: (typeof staffRoles)[number]) =>
stub<CampaignUser>({ collection: 'campaignUser', role })
for (const role of staffRoles) {
it(`getTopDeputies still resolves a municipality for ${role}`, async () => {
const execute = (
getTopDeputies(ctxFor(staffUser(role), emptyDocsPayload)) as unknown as ExecutableTool
).execute
const result = await execute!({ municipality: 'Feira de Santana' })
expect(result).toMatchObject({ municipality: 'Feira de Santana', topDeputies: [] })
})
it(`getMunicipalityVotes still resolves a municipality for ${role}`, async () => {
const execute = (
getMunicipalityVotes(ctxFor(staffUser(role), emptyDocsPayload)) as unknown as ExecutableTool
).execute
const result = await execute!({ municipality: 'Feira de Santana' })
expect(result).toMatchObject({ municipality: 'Feira de Santana' })
})
}
})