Merge pull request #560 from fsolla/B190-sollinha-municipios-sem-dobradinha-dobradinhas-orfas-co
B190 — Sollinha: municípios sem dobradinha + dobradinhas órfãs (cobertura de parcerias)
This commit is contained in:
commit
733b019f2b
9 changed files with 820 additions and 1 deletions
78
docs/plans/sollinha-municipios-sem-dobradinha-impl.md
Normal file
78
docs/plans/sollinha-municipios-sem-dobradinha-impl.md
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
# Impl: Sollinha: municípios sem dobradinha + dobradinhas órfãs (cobertura de parcerias)
|
||||
|
||||
Status: aprovado
|
||||
Atualizado em: 2026-08-10
|
||||
Issue: #527
|
||||
Intenção: docs/plans/sollinha-municipios-sem-dobradinha.md
|
||||
Appetite restante: ~0,5–1 dia eng (herdado)
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** o Sollinha responde, em linguagem natural, (1) quais municípios do escopo do usuário não têm nenhuma dobradinha vinculada — com contagem e itens por município — e (2) quais dobradinhas estão órfãs (sem nenhum município vinculado), com contagem e links. Salvador agrupada por cidade por padrão, com opção de detalhar por ZE. Assessor vê só o próprio escopo; leader recebe acesso negado.
|
||||
- **O que NÃO negociar:** fail-closed para leader (sem dados); leitura relativa ao escopo (assessor = municípios que administra); sem ranking/qualificação de dobradinhas; sem sugestão de deputados-alvo; sem tocar dados eleitorais (lockdown B180); resposta declara que a leitura é sobre o cadastro atual.
|
||||
- **O que reavaliar:** a hipótese de "nova tool registrada no index" está certa; o diabo está em como computar "órfã" respeitando escopo e nos links para agir (a intenção diz "precedente B162").
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[Pergunta] --> B[getPartnershipCoverage]
|
||||
B --> C{mode}
|
||||
C -->|municipios_sem_dobradinha| D[find municipality onde stateDeputies exists:false + RBAC]
|
||||
D --> E[groupByCity / expandZones + links]
|
||||
C -->|dobradinhas_orfas| F[read stateDeputy staff-scoped]
|
||||
F --> G[municipalityIdsByStateDeputyIds → órfãs]
|
||||
G --> H[lista + links]
|
||||
```
|
||||
|
||||
**Opções consideradas:** A (uma tool, dois modos — recomendada) | B (duas tools) | C (sem link de lista filtrada)
|
||||
**Recomendação:** A — a intenção fixou "mesma tool, dois modos de saída" e B185 (irmão, mesmo ciclo) segue o mesmo formato; um registro no `index.ts`, uma descrição de domínio.
|
||||
**Rejeitadas:** B porque o modelo escolheria entre duas tools com o mesmo domínio "cobertura de parcerias" sem ganho de clareza, e a família (B185) já consolidou modos numa tool só; C porque "abrir a lista já filtrada" é o link para agir do coordenador — a URL já suporta `stateDeputy=sem_dobradinha` (B176), só falta expô-la na tool de links.
|
||||
|
||||
### Componentes / mudanças
|
||||
|
||||
- **`getPartnershipCoverage`** (`src/utilities/ai/tools/getPartnershipCoverage.ts`): factory de tool read-only com `mode: 'municipios_sem_dobradinha' | 'dobradinhas_orfas'` e filtros opcionais `region` (território, tolerante a acento) e `city`. Gate fail-closed inline (`isCampaignStaff`), registrada no `index.ts`.
|
||||
- **`src/lib/partnershipCoverage.ts`** (novo, puro): tipagem das linhas, agrupamento por cidade (`groupByCity`), resolução tolerante de região (`normalizeSearchPhrase` × `bahiaIdentityTerritories`), ordenação região→cidade (pt-BR). Unit-testável sem payload.
|
||||
- **Modo municípios:** `payload.find` em `municipality` com `where: { stateDeputies: { exists: false } }`, `overrideAccess: false`, `user: ctx.user` (RBAC já entrega o escopo do assessor e vaza zero para leader), select `name/slug/kind/city/region`. Agrupado por cidade por padrão (Salvador = um item "Salvador" com `unidadesSemDobradinha: X` + lista `zonas`), ou por ZE com `expandZones: true`. Ordena região→cidade. Slug do item: `salvador` (cidade virtual, B178) quando agrupado em Salvador; slug do município caso contrário.
|
||||
- **Modo órfãs:** lê todos os `stateDeputy` (read é staff-wide não-escopado), depois reusa **`municipalityIdsByStateDeputyIds`** (`src/utilities/stateDeputyData.ts`) para obter o mapa deputado→municípios no registro inteiro — órfãs = deputados fora do mapa. O cálculo de órfão é fato de registro, não relativo a escopo: um deputado vinculado a município fora do escopo do assessor NÃO é órfão. O helper já usa o bypass agregado-only (precedente B34, select só `stateDeputies` — vaza apenas IDs que o staff já pode ler).
|
||||
- **Links (B162 + extensão mínima):** per-item via destinos existentes (`municipality` slug, `dobradinha` slug; `salvador` já aceito como city slug). **Novo:** `municipalityList` ganha `stateDeputies?: Array<number | 'sem_dobradinha'>` em `campaignNavigationUrls.ts` + schema de `buildCampaignLinks.ts` → `/campanha/municipios?stateDeputy=sem_dobradinha` (parâmetro já suportado pela URL layer, B176; sem mudar contrato existente).
|
||||
- **Migration:** nenhuma. **Access/Consent:** nenhum novo; gate por papel inline na tool (fail-closed). **UI:** Impeccable A — resposta em texto no chat existente.
|
||||
|
||||
### Dados → forma
|
||||
|
||||
- Lista exaustiva com contagem (cobertura, não top N), critério sempre declarado na resposta ("Sem nenhuma dobradinha vinculada no cadastro atual"); Salvador agregada com drill-down por ZE opcional. Forma em texto/objetos da tool, como a família (`getLeaderships`/`getDobradinhas`); sem % estadual.
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **Schema+server** — `src/lib/partnershipCoverage.ts` puro + `getPartnershipCoverage.ts` + registro no `index.ts`; unit tests do puro (agrupamento, região tolerante, órfãs) e lockdown de leader (padrão `electionToolsLockdown.unit.spec.ts`).
|
||||
2. **Links** — extensão `municipalityList.stateDeputies` em `campaignNavigationUrls.ts` + `buildCampaignLinks.ts` + testes em `campaignNavigationUrls.unit.spec.ts`.
|
||||
3. **Gates** — `pnpm gate:fast` na iteração; entrega com `pnpm push` (PR `--base main`, `Closes #527`, auto-merge).
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- SQL custom para órfãs (drizzle/left join) — o helper existente `municipalityIdsByStateDeputyIds` já entrega o mapa com o bypass agregado-only; N+1 evitado.
|
||||
- Novo `campaignDataGate.ts` compartilhado — B185 está em paralelo e pode criar; gate inline na tool não colide e vira adoção trivial no /simplify se B185 pousar um.
|
||||
- Paginação/`limit` na resposta — cobertura é exaustiva; a tool devolve total + itens ordenados, o modelo resume.
|
||||
- Qualquer leitura eleitoral, ranking ou sugestão de deputado-alvo (anti-goal da intenção; lockdown B180 intacto).
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **Órfã com escopo errado (vazamento/inverdade):** computar órfãs dentro do escopo do assessor mostraria deputados vinculados fora do escopo como órfãos. Mitigação: mapa completo via `municipalityIdsByStateDeputyIds` (bypass agregado-only, precedente B34); assertar no teste de lockdown que leader nunca alcança esse read.
|
||||
- **Região tolerante a acento falha:** "Vale do Jiquiriça" vs "Vale do Jiquiriçá". Mitigação: `normalizeSearchPhrase` dos dois lados contra o catálogo canônico; região não resolvida → `error` com mensagem clara (o modelo re-pergunta).
|
||||
- **Colisão com B185 (paralelo):** B185 não tem commits à frente de main hoje; mantive a tool auto-contida (sem novo arquivo compartilhado). Se B185 pousar `campaignDataGate`, o /simplify adota.
|
||||
- **Extensão do link não passar no schema zod:** `z.union` de inteiro + sentinela `'sem_dobradinha'` precisa de `.optional()` e filtro no builder (positivos + sentinela), espelhando o padrão `coverage`.
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [x] Aceite de produto da intenção ainda coberto (dois modos, contagem, Salvador agrupada/drill-down, links, escopo por papel, leader fail-closed)
|
||||
- [x] Invariantes AGENTS/engineering-standards (identificadores em inglês, pt-BR só em strings de saída; sem migration/Consent; `overrideAccess: false` com user; bypass só no helper B34)
|
||||
- [x] Testes de domínio: unit do puro (agrupamento/órfãs/região) + lockdown leader + teste de link filtrado
|
||||
- [x] `pnpm gate:fast` verde; PR em main com `Closes #527`
|
||||
|
||||
## Triage do /simplify (2026-08-10)
|
||||
|
||||
**Já resolvido no cleanup (não reabrir):** `modo` removido da resposta (eco de `mode` podia falhar schema — `criterio` identifica a leitura); teste de sort com cidade decisiva (Camaçari × Salvador ZE na mesma região); asserts de URL literal em vez de espelhados (`/campanha/municipios?stateDeputy=7&stateDeputy=sem_dobradinha`); stubs de payload com docs populados (agrupado, expandZones, órfãs, região inválida); cast redundante de `zoneNumber`.
|
||||
|
||||
**Explicitamente fora:** `total` conta unidades operacionais, não rows agrupados (aceite pede contagem de municípios; `unidades` por row); schemas zod das tools não têm teste direto (família inteira igual — exercitados em E2E/build); guards micro (whitespace, tie-break de nome); filtros `region`/`city`/`expandZones` ignorados no modo órfã (descrições do schema cobrem; refine seria over-engineering).
|
||||
|
||||
**Adiado com gatilho:** lookup de nomes de contato duplicado entre `getDobradinhas` e `getPartnershipCoverage` (ids → find → Map, ~15 linhas). **Gatilho:** 3º call site (provável B185/B189) → extrair `loadContactNamesByIds(payload, user, ids)` server-side em `src/utilities/` e adotar nas três tools.
|
||||
115
src/lib/partnershipCoverage.ts
Normal file
115
src/lib/partnershipCoverage.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
/**
|
||||
* Pure presentation logic for the Sollinha "cobertura de parcerias" reading
|
||||
* (B190): which municipalities have no linked dobradinha, and which
|
||||
* dobradinhas are orphans (no municipality linked anywhere).
|
||||
*
|
||||
* Pure module — no server/database imports. The chat tool
|
||||
* (`src/utilities/ai/tools/getPartnershipCoverage.ts`) queries the
|
||||
* collections under RBAC and delegates shaping here so the grouping rules are
|
||||
* unit-testable without payload.
|
||||
*/
|
||||
|
||||
import { bahiaIdentityTerritories, type BahiaIdentityTerritory } from '@/lib/bahiaTerritories'
|
||||
import { salvadorCity } from '@/lib/salvadorCity'
|
||||
import { normalizeSearchPhrase } from '@/lib/wordStartFilter'
|
||||
|
||||
export type PartnershipCoverageMode = 'municipalities' | 'orphanDeputies'
|
||||
|
||||
/** One operational municipality without a dobradinha (a ZE of Salvador included). */
|
||||
export type PartnershipCoverageUnit = {
|
||||
nome: string
|
||||
slug: string
|
||||
cidade: string
|
||||
regiao: string
|
||||
/** Zone number for Salvador ZEs; null for whole-municipality units. */
|
||||
zoneNumber: number | null
|
||||
}
|
||||
|
||||
/** Zone rows the model can link to (`/campanha/municipios/<slug>`). */
|
||||
type PartnershipCoverageZone = {
|
||||
nome: string
|
||||
slug: string
|
||||
}
|
||||
|
||||
/** One row of the grouped (default) reading: a city, with its units folded. */
|
||||
export type PartnershipCoverageCityRow = {
|
||||
nome: string
|
||||
/**
|
||||
* City page slug when the city is split into zone units (Salvador →
|
||||
* `salvador`), the single unit's slug otherwise, null when neither exists.
|
||||
*/
|
||||
slug: string | null
|
||||
cidade: string
|
||||
regiao: string
|
||||
/** Number of operational units without a dobradinha in this city. */
|
||||
unidades: number
|
||||
zonas: PartnershipCoverageZone[]
|
||||
}
|
||||
|
||||
export const PARTNERSHIP_COVERAGE_CRITERION = {
|
||||
municipalities: 'Municípios sem nenhuma dobradinha vinculada no cadastro atual.',
|
||||
orphanDeputies: 'Dobradinhas sem nenhum município vinculado no cadastro atual.',
|
||||
} as const satisfies Record<PartnershipCoverageMode, string>
|
||||
|
||||
/**
|
||||
* Resolves a user-spoken identity territory tolerantly ("Vale do Jiquiriça"
|
||||
* matches the canonical "Vale do Jiquiriçá") via accent/case-folded equality
|
||||
* against the 27 canonical names. Returns undefined when nothing matches.
|
||||
*/
|
||||
export const resolveIdentityTerritory = (input: string): BahiaIdentityTerritory | undefined => {
|
||||
const normalized = normalizeSearchPhrase(input)
|
||||
if (!normalized) return undefined
|
||||
return bahiaIdentityTerritories.find(
|
||||
(territory) => normalizeSearchPhrase(territory) === normalized,
|
||||
)
|
||||
}
|
||||
|
||||
const compareUnits = (left: PartnershipCoverageUnit, right: PartnershipCoverageUnit): number =>
|
||||
left.regiao.localeCompare(right.regiao, 'pt-BR') ||
|
||||
left.cidade.localeCompare(right.cidade, 'pt-BR') ||
|
||||
(left.zoneNumber ?? Number.POSITIVE_INFINITY) - (right.zoneNumber ?? Number.POSITIVE_INFINITY) ||
|
||||
left.nome.localeCompare(right.nome, 'pt-BR')
|
||||
|
||||
/**
|
||||
* Coverage is a reading of the operational catalog: region asc, then city
|
||||
* asc, then zone number asc — a stable order the model can echo back.
|
||||
*/
|
||||
export const sortCoverageUnits = (
|
||||
units: readonly PartnershipCoverageUnit[],
|
||||
): PartnershipCoverageUnit[] => [...units].sort(compareUnits)
|
||||
|
||||
/** The city page slug for a split city (Salvador today); null otherwise. */
|
||||
const cityPageSlug = (cidade: string): string | null =>
|
||||
cidade === salvadorCity.city ? salvadorCity.slug : null
|
||||
|
||||
/**
|
||||
* Groups units by city, folding a split city (Salvador's ZEs) into one row
|
||||
* with its zone list. Input is expected sorted by `sortCoverageUnits`, which
|
||||
* the grouping preserves: zones arrive in zone order, cities in region order.
|
||||
*/
|
||||
export const groupCoverageByCity = (
|
||||
units: readonly PartnershipCoverageUnit[],
|
||||
): PartnershipCoverageCityRow[] => {
|
||||
const rows: PartnershipCoverageCityRow[] = []
|
||||
for (const unit of units) {
|
||||
const previous = rows[rows.length - 1]
|
||||
if (previous && previous.cidade === unit.cidade) {
|
||||
previous.unidades += 1
|
||||
previous.zonas.push({ nome: unit.nome, slug: unit.slug })
|
||||
continue
|
||||
}
|
||||
rows.push({
|
||||
nome: unit.cidade,
|
||||
slug: cityPageSlug(unit.cidade) ?? unit.slug,
|
||||
cidade: unit.cidade,
|
||||
regiao: unit.regiao,
|
||||
unidades: 1,
|
||||
zonas: [{ nome: unit.nome, slug: unit.slug }],
|
||||
})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
/** Orphan dobradinhas read alphabetically (pt-BR) — stable for the model. */
|
||||
export const sortOrphanDobradinhas = <T extends { nome: string }>(rows: readonly T[]): T[] =>
|
||||
[...rows].sort((left, right) => left.nome.localeCompare(right.nome, 'pt-BR'))
|
||||
|
|
@ -50,6 +50,7 @@ import {
|
|||
import {
|
||||
buildMunicipalityListHref,
|
||||
municipalityListLevelFilterValues,
|
||||
NO_STATE_DEPUTY_FILTER_VALUE,
|
||||
type MunicipalityListLevelFilterValue,
|
||||
} from '@/utilities/municipality/municipalityListUrl'
|
||||
import type { MunicipalityTerritorialClass } from '@/utilities/municipality/municipalityTerritorialClass'
|
||||
|
|
@ -148,6 +149,8 @@ export type CampaignNavigationLinkRequest =
|
|||
trends?: PoliticalTrendStatus[]
|
||||
classes?: MunicipalityTerritorialClass[]
|
||||
levels?: MunicipalityListLevelFilterValue[]
|
||||
/** B190 — dobradinha ids to filter by, plus the "Sem dobradinha" sentinel. */
|
||||
stateDeputies?: Array<number | typeof NO_STATE_DEPUTY_FILTER_VALUE>
|
||||
})
|
||||
| (BaseLinkFields & {
|
||||
destination: 'leadershipList'
|
||||
|
|
@ -411,6 +414,11 @@ const buildPathForRequest = (
|
|||
const levels = request.levels?.filter((level): level is MunicipalityListLevelFilterValue =>
|
||||
municipalityLevelFilterSet.has(level),
|
||||
)
|
||||
const stateDeputies = request.stateDeputies?.filter((value) =>
|
||||
typeof value === 'number'
|
||||
? positiveInt(value, 'stateDeputy') !== null
|
||||
: value === NO_STATE_DEPUTY_FILTER_VALUE,
|
||||
)
|
||||
const path = buildMunicipalityListHref(
|
||||
{
|
||||
page: 1,
|
||||
|
|
@ -423,6 +431,7 @@ const buildPathForRequest = (
|
|||
...(trends?.length ? { trends } : {}),
|
||||
...(classes?.length ? { classes } : {}),
|
||||
...(levels?.length ? { levels } : {}),
|
||||
...(stateDeputies?.length ? { stateDeputies } : {}),
|
||||
},
|
||||
1,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -16,7 +16,10 @@ import { activityTabs } from '@/utilities/activityUi'
|
|||
import type { CampaignNavigationLinkRequest } from '@/utilities/ai/campaignNavigationUrls'
|
||||
import { buildCampaignNavigationLinks } from '@/utilities/ai/campaignNavigationUrls'
|
||||
import { politicalTrendLabels } from '@/utilities/municipality/municipalityLabels'
|
||||
import { municipalityListLevelFilterValues } from '@/utilities/municipality/municipalityListUrl'
|
||||
import {
|
||||
municipalityListLevelFilterValues,
|
||||
NO_STATE_DEPUTY_FILTER_VALUE,
|
||||
} from '@/utilities/municipality/municipalityListUrl'
|
||||
|
||||
const labelSchema = z
|
||||
.string()
|
||||
|
|
@ -122,6 +125,12 @@ const linkRequestSchema = z.discriminatedUnion('destination', [
|
|||
trends: z.array(z.enum(politicalTrendKeys)).optional(),
|
||||
classes: z.array(z.enum(TERRITORIAL_CLASSES)).optional(),
|
||||
levels: z.array(z.enum(municipalityListLevelFilterValues as [string, ...string[]])).optional(),
|
||||
stateDeputies: z
|
||||
.array(z.union([z.number().int().positive(), z.literal(NO_STATE_DEPUTY_FILTER_VALUE)]))
|
||||
.optional()
|
||||
.describe(
|
||||
'Dobradinha ids to filter the list by, or the sentinel "sem_dobradinha" for municipalities without any dobradinha.',
|
||||
),
|
||||
label: labelSchema,
|
||||
}),
|
||||
z.object({
|
||||
|
|
|
|||
206
src/utilities/ai/tools/getPartnershipCoverage.ts
Normal file
206
src/utilities/ai/tools/getPartnershipCoverage.ts
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
import 'server-only'
|
||||
|
||||
import { tool } from 'ai'
|
||||
import type { Where } from 'payload'
|
||||
import { z } from 'zod'
|
||||
|
||||
import type { AIToolContext } from '@/lib/ai/types'
|
||||
import {
|
||||
groupCoverageByCity,
|
||||
PARTNERSHIP_COVERAGE_CRITERION,
|
||||
resolveIdentityTerritory,
|
||||
sortCoverageUnits,
|
||||
sortOrphanDobradinhas,
|
||||
type PartnershipCoverageUnit,
|
||||
} from '@/lib/partnershipCoverage'
|
||||
import { populatedContactName, relationshipId } from '@/lib/relationship'
|
||||
import { isCampaignStaff } from '@/utilities/campaignAccess'
|
||||
import { municipalityIdsByStateDeputyIds } from '@/utilities/stateDeputyData'
|
||||
|
||||
const COVERAGE_DENIED = { error: 'Leitura de dados da campanha negada.' }
|
||||
|
||||
export const getPartnershipCoverage = (ctx: AIToolContext) =>
|
||||
tool({
|
||||
description:
|
||||
'Returns partnership (dobradinha) coverage for the campaign: which municipalities have NO ' +
|
||||
'linked dobradinha ("quais municípios ainda não têm dobradinha?"), or which dobradinhas are ' +
|
||||
'orphans with no municipality linked ("quais dobradinhas estão sem município?"). ' +
|
||||
'The reading is about the current registry, not about which deputy to approach. ' +
|
||||
'Salvador is grouped by city by default (its 19 zones as one unit); pass expandZones to list zones individually.',
|
||||
inputSchema: z.object({
|
||||
mode: z
|
||||
.enum(['municipalities', 'orphanDeputies'])
|
||||
.describe(
|
||||
'municipalities = municipalities in the user scope without any linked dobradinha; ' +
|
||||
'orphanDeputies = dobradinhas with no municipality linked anywhere in the registry.',
|
||||
),
|
||||
region: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'Optional identity territory filter (e.g. "Vale do Jiquiriçá"). Only for mode municipalities.',
|
||||
),
|
||||
city: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'Optional city filter (e.g. "Salvador" matches its 19 zones). Only for mode municipalities.',
|
||||
),
|
||||
expandZones: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
'List Salvador zone municipalities individually instead of grouping them under the city (default false).',
|
||||
),
|
||||
}),
|
||||
execute: async ({ mode, region, city, expandZones }) => {
|
||||
const { payload, user } = ctx
|
||||
|
||||
// Fail-closed: leaders never reach the coverage reading (the orphan
|
||||
// side reads the whole registry through an aggregate bypass).
|
||||
if (!isCampaignStaff(user)) return COVERAGE_DENIED
|
||||
|
||||
if (mode === 'orphanDeputies') {
|
||||
return loadOrphanDobradinhas(payload, user)
|
||||
}
|
||||
|
||||
const andClauses: Where[] = [{ stateDeputies: { exists: false } }]
|
||||
|
||||
if (region) {
|
||||
const territory = resolveIdentityTerritory(region)
|
||||
if (!territory) {
|
||||
return {
|
||||
error: `Região não reconhecida: "${region}". Use o nome de um território de identidade da Bahia (ex.: "Vale do Jiquiriçá").`,
|
||||
}
|
||||
}
|
||||
andClauses.push({ region: { equals: territory } })
|
||||
}
|
||||
|
||||
if (city) {
|
||||
andClauses.push({ city: { like: city } })
|
||||
}
|
||||
|
||||
const result = await payload.find({
|
||||
collection: 'municipality',
|
||||
where: { and: andClauses },
|
||||
depth: 0,
|
||||
limit: 0,
|
||||
pagination: false,
|
||||
select: { name: true, slug: true, city: true, region: true, zoneNumber: true },
|
||||
overrideAccess: false,
|
||||
user,
|
||||
})
|
||||
|
||||
const units: PartnershipCoverageUnit[] = (result.docs as Array<Record<string, unknown>>).map(
|
||||
(doc) => ({
|
||||
nome: doc.name as string,
|
||||
slug: doc.slug as string,
|
||||
cidade: doc.city as string,
|
||||
regiao: doc.region as string,
|
||||
zoneNumber: typeof doc.zoneNumber === 'number' ? doc.zoneNumber : null,
|
||||
}),
|
||||
)
|
||||
|
||||
const ordered = sortCoverageUnits(units)
|
||||
|
||||
if (ordered.length === 0) {
|
||||
return {
|
||||
message: 'Nenhum município sem dobradinha no escopo atual.',
|
||||
criterio: PARTNERSHIP_COVERAGE_CRITERION.municipalities,
|
||||
}
|
||||
}
|
||||
|
||||
if (expandZones) {
|
||||
return {
|
||||
criterio: PARTNERSHIP_COVERAGE_CRITERION.municipalities,
|
||||
agrupadoPorCidade: false,
|
||||
total: ordered.length,
|
||||
municipios: ordered.map((unit) => ({
|
||||
nome: unit.nome,
|
||||
slug: unit.slug,
|
||||
cidade: unit.cidade,
|
||||
regiao: unit.regiao,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
const rows = groupCoverageByCity(ordered)
|
||||
return {
|
||||
criterio: PARTNERSHIP_COVERAGE_CRITERION.municipalities,
|
||||
agrupadoPorCidade: true,
|
||||
total: ordered.length,
|
||||
municipios: rows,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* Orphans are a registry-level fact, not a scope-relative one: a dobradinha
|
||||
* linked to municipalities outside the advisor's scope is NOT an orphan. The
|
||||
* linked set therefore comes from `municipalityIdsByStateDeputyIds` (aggregate
|
||||
* bypass, B34 precedent — reads only `stateDeputies` ids), and the deputy
|
||||
* rows themselves stay under the staff read access.
|
||||
*/
|
||||
const loadOrphanDobradinhas = async (
|
||||
payload: AIToolContext['payload'],
|
||||
user: AIToolContext['user'],
|
||||
) => {
|
||||
const deputies = await payload.find({
|
||||
collection: 'stateDeputy',
|
||||
where: {},
|
||||
depth: 0,
|
||||
limit: 0,
|
||||
pagination: false,
|
||||
select: { contact: true, slug: true, party: true, notes: true },
|
||||
overrideAccess: false,
|
||||
user,
|
||||
})
|
||||
|
||||
const stateDeputyIDs = deputies.docs.map((doc) => doc.id)
|
||||
const municipalitiesByDeputy = await municipalityIdsByStateDeputyIds(payload, stateDeputyIDs)
|
||||
|
||||
const contactIDs = deputies.docs
|
||||
.map((doc) => relationshipId(doc.contact))
|
||||
.filter((id): id is number => id !== null)
|
||||
const contactNames = new Map<number, string>()
|
||||
if (contactIDs.length > 0) {
|
||||
const contacts = await payload.find({
|
||||
collection: 'contact',
|
||||
where: { id: { in: contactIDs } },
|
||||
depth: 0,
|
||||
limit: 0,
|
||||
pagination: false,
|
||||
select: { name: true },
|
||||
overrideAccess: false,
|
||||
user,
|
||||
})
|
||||
for (const contact of contacts.docs) contactNames.set(contact.id, contact.name)
|
||||
}
|
||||
|
||||
const orphans = sortOrphanDobradinhas(
|
||||
deputies.docs
|
||||
.filter((doc) => !municipalitiesByDeputy.has(doc.id))
|
||||
.map((doc) => ({
|
||||
nome: populatedContactName(
|
||||
doc.contact,
|
||||
contactNames.get(relationshipId(doc.contact) ?? -1) ?? 'Contato',
|
||||
),
|
||||
slug: doc.slug,
|
||||
partido: doc.party ?? null,
|
||||
observacoes: doc.notes ?? null,
|
||||
})),
|
||||
)
|
||||
|
||||
if (orphans.length === 0) {
|
||||
return {
|
||||
message: 'Nenhuma dobradinha órfã cadastrada.',
|
||||
criterio: PARTNERSHIP_COVERAGE_CRITERION.orphanDeputies,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
criterio: PARTNERSHIP_COVERAGE_CRITERION.orphanDeputies,
|
||||
total: orphans.length,
|
||||
dobradinhas: orphans,
|
||||
}
|
||||
}
|
||||
|
|
@ -15,6 +15,7 @@ import { getLeadingMunicipalities } from '@/utilities/ai/tools/getLeadingMunicip
|
|||
import { getMunicipalityOverview } from '@/utilities/ai/tools/getMunicipalityOverview'
|
||||
import { getMunicipalityVotes } from '@/utilities/ai/tools/getMunicipalityVotes'
|
||||
import { getOrganizations } from '@/utilities/ai/tools/getOrganizations'
|
||||
import { getPartnershipCoverage } from '@/utilities/ai/tools/getPartnershipCoverage'
|
||||
import { getPendingLeaderships } from '@/utilities/ai/tools/getPendingLeaderships'
|
||||
import { getTopDeputies } from '@/utilities/ai/tools/getTopDeputies'
|
||||
import { searchEntities } from '@/utilities/ai/tools/searchEntities'
|
||||
|
|
@ -30,5 +31,6 @@ export const buildAITools = (ctx: AIToolContext) => ({
|
|||
getLeaderships: getLeaderships(ctx),
|
||||
getPendingLeaderships: getPendingLeaderships(ctx),
|
||||
getOrganizations: getOrganizations(ctx),
|
||||
getPartnershipCoverage: getPartnershipCoverage(ctx),
|
||||
searchEntities: searchEntities(ctx),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -74,6 +74,31 @@ describe('campaignNavigationUrls', () => {
|
|||
}
|
||||
})
|
||||
|
||||
it('builds the "Sem dobradinha" municipality list filter (B190)', () => {
|
||||
const outcome = buildCampaignNavigationLink(coordinator.role, {
|
||||
destination: 'municipalityList',
|
||||
stateDeputies: ['sem_dobradinha', 7],
|
||||
label: 'Municípios sem dobradinha',
|
||||
})
|
||||
expect(outcome.ok).toBe(true)
|
||||
if (outcome.ok) {
|
||||
// Literal URL — canonical relationship params: ids asc, sentinel last.
|
||||
expect(outcome.path).toBe('/campanha/municipios?stateDeputy=7&stateDeputy=sem_dobradinha')
|
||||
expect(outcome.label).toBe('Municípios sem dobradinha')
|
||||
}
|
||||
})
|
||||
|
||||
it('drops invalid stateDeputy filter values instead of failing the link', () => {
|
||||
const outcome = buildCampaignNavigationLink(coordinator.role, {
|
||||
destination: 'municipalityList',
|
||||
stateDeputies: ['sem_dobradinha', -3],
|
||||
})
|
||||
expect(outcome.ok).toBe(true)
|
||||
if (outcome.ok) {
|
||||
expect(outcome.path).toBe('/campanha/municipios?stateDeputy=sem_dobradinha')
|
||||
}
|
||||
})
|
||||
|
||||
it('blocks staff destinations for leaders', () => {
|
||||
const outcome = buildCampaignNavigationLink(leader.role, {
|
||||
destination: 'municipality',
|
||||
|
|
|
|||
144
tests/unit/partnershipCoverage.unit.spec.ts
Normal file
144
tests/unit/partnershipCoverage.unit.spec.ts
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
groupCoverageByCity,
|
||||
PARTNERSHIP_COVERAGE_CRITERION,
|
||||
resolveIdentityTerritory,
|
||||
sortCoverageUnits,
|
||||
sortOrphanDobradinhas,
|
||||
type PartnershipCoverageUnit,
|
||||
} from '@/lib/partnershipCoverage'
|
||||
|
||||
const unit = (
|
||||
overrides: Partial<PartnershipCoverageUnit> & Pick<PartnershipCoverageUnit, 'nome' | 'slug'>,
|
||||
): PartnershipCoverageUnit => ({
|
||||
cidade: 'Feira de Santana',
|
||||
regiao: 'Portal do Sertão',
|
||||
zoneNumber: null,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('resolveIdentityTerritory', () => {
|
||||
it('resolves a canonical territory name', () => {
|
||||
expect(resolveIdentityTerritory('Vale do Jiquiriçá')).toBe('Vale do Jiquiriçá')
|
||||
})
|
||||
|
||||
it('is tolerant to missing accents and case (B185 family semantics)', () => {
|
||||
expect(resolveIdentityTerritory('vale do jiquirica')).toBe('Vale do Jiquiriçá')
|
||||
})
|
||||
|
||||
it('returns undefined for an unknown name', () => {
|
||||
expect(resolveIdentityTerritory('Planeta Marte')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('sortCoverageUnits', () => {
|
||||
it('orders by region, then city, then zone number', () => {
|
||||
const units = sortCoverageUnits([
|
||||
unit({
|
||||
nome: 'Salvador — ZE 10',
|
||||
slug: 'salvador-ze-10',
|
||||
cidade: 'Salvador',
|
||||
regiao: 'Metropolitano de Salvador',
|
||||
zoneNumber: 10,
|
||||
}),
|
||||
unit({
|
||||
nome: 'Salvador — ZE 2',
|
||||
slug: 'salvador-ze-2',
|
||||
cidade: 'Salvador',
|
||||
regiao: 'Metropolitano de Salvador',
|
||||
zoneNumber: 2,
|
||||
}),
|
||||
unit({
|
||||
nome: 'Camaçari',
|
||||
slug: 'camacari',
|
||||
cidade: 'Camaçari',
|
||||
regiao: 'Metropolitano de Salvador',
|
||||
}),
|
||||
unit({ nome: 'Abaré', slug: 'abare', cidade: 'Abaré', regiao: 'Sertão do São Francisco' }),
|
||||
])
|
||||
|
||||
// Same region: city asc decides (Camaçari before Salvador), then zone asc.
|
||||
expect(units.map((entry) => entry.slug)).toEqual([
|
||||
'camacari',
|
||||
'salvador-ze-2',
|
||||
'salvador-ze-10',
|
||||
'abare',
|
||||
])
|
||||
})
|
||||
|
||||
it('sorts whole municipalities of a city after its zones', () => {
|
||||
const units = sortCoverageUnits([
|
||||
unit({ nome: 'Salvador — ZE 1', slug: 'salvador-ze-1', cidade: 'Salvador', zoneNumber: 1 }),
|
||||
unit({ nome: 'Salvador', slug: 'salvador', cidade: 'Salvador', zoneNumber: null }),
|
||||
])
|
||||
|
||||
expect(units.map((entry) => entry.slug)).toEqual(['salvador-ze-1', 'salvador'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('groupCoverageByCity', () => {
|
||||
it('folds Salvador zones into one city row with the city slug (B178)', () => {
|
||||
const rows = groupCoverageByCity(
|
||||
sortCoverageUnits([
|
||||
unit({ nome: 'Salvador — ZE 1', slug: 'salvador-ze-1', cidade: 'Salvador', zoneNumber: 1 }),
|
||||
unit({ nome: 'Salvador — ZE 3', slug: 'salvador-ze-3', cidade: 'Salvador', zoneNumber: 3 }),
|
||||
]),
|
||||
)
|
||||
|
||||
expect(rows).toHaveLength(1)
|
||||
expect(rows[0]).toMatchObject({
|
||||
nome: 'Salvador',
|
||||
slug: 'salvador',
|
||||
cidade: 'Salvador',
|
||||
unidades: 2,
|
||||
})
|
||||
expect(rows[0]!.zonas.map((zona) => zona.slug)).toEqual(['salvador-ze-1', 'salvador-ze-3'])
|
||||
})
|
||||
|
||||
it('keeps whole-municipality cities as one row with their own slug', () => {
|
||||
const rows = groupCoverageByCity(
|
||||
sortCoverageUnits([unit({ nome: 'Ilhéus', slug: 'ilheus', cidade: 'Ilhéus' })]),
|
||||
)
|
||||
|
||||
expect(rows).toHaveLength(1)
|
||||
expect(rows[0]).toMatchObject({ nome: 'Ilhéus', slug: 'ilheus', unidades: 1 })
|
||||
expect(rows[0]!.zonas).toEqual([{ nome: 'Ilhéus', slug: 'ilheus' }])
|
||||
})
|
||||
|
||||
it('preserves region/city ordering across rows', () => {
|
||||
const rows = groupCoverageByCity(
|
||||
sortCoverageUnits([
|
||||
unit({ nome: 'Abaré', slug: 'abare', cidade: 'Abaré', regiao: 'Sertão do São Francisco' }),
|
||||
unit({ nome: 'Salvador — ZE 1', slug: 'salvador-ze-1', cidade: 'Salvador', zoneNumber: 1 }),
|
||||
unit({
|
||||
nome: 'Camaçari',
|
||||
slug: 'camacari',
|
||||
cidade: 'Camaçari',
|
||||
regiao: 'Metropolitano de Salvador',
|
||||
}),
|
||||
]),
|
||||
)
|
||||
|
||||
expect(rows.map((row) => row.cidade)).toEqual(['Camaçari', 'Salvador', 'Abaré'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('sortOrphanDobradinhas', () => {
|
||||
it('sorts orphans by name in pt-BR', () => {
|
||||
const rows = sortOrphanDobradinhas([
|
||||
{ nome: 'Zé', slug: 'ze' },
|
||||
{ nome: 'Ana', slug: 'ana' },
|
||||
{ nome: 'Álvaro', slug: 'alvaro' },
|
||||
])
|
||||
|
||||
expect(rows.map((row) => row.nome)).toEqual(['Álvaro', 'Ana', 'Zé'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('PARTNERSHIP_COVERAGE_CRITERION', () => {
|
||||
it('declares the registry-scope criterion for both modes', () => {
|
||||
expect(PARTNERSHIP_COVERAGE_CRITERION.municipalities).toMatch(/cadastro atual/)
|
||||
expect(PARTNERSHIP_COVERAGE_CRITERION.orphanDeputies).toMatch(/cadastro atual/)
|
||||
})
|
||||
})
|
||||
231
tests/unit/partnershipCoverageLockdown.unit.spec.ts
Normal file
231
tests/unit/partnershipCoverageLockdown.unit.spec.ts
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
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 { getPartnershipCoverage } from '@/utilities/ai/tools/getPartnershipCoverage'
|
||||
|
||||
import { stub } from '../helpers/stub'
|
||||
|
||||
const denied = { error: 'Leitura de dados da campanha 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' })
|
||||
|
||||
type ExecutableTool = {
|
||||
execute: (args: unknown, options?: unknown) => Promise<unknown>
|
||||
}
|
||||
|
||||
const executeFor = (user: CampaignUser, payload: Payload) => {
|
||||
const execute = (getPartnershipCoverage(ctxFor(user, payload)) as unknown as ExecutableTool)
|
||||
.execute
|
||||
return (args: unknown) => execute(args)
|
||||
}
|
||||
|
||||
const emptyDocsPayload = stub<Payload>({
|
||||
find: async () => ({
|
||||
docs: [],
|
||||
hasNextPage: false,
|
||||
hasPrevPage: false,
|
||||
limit: 0,
|
||||
nextPage: null,
|
||||
pagingCounter: 0,
|
||||
prevPage: null,
|
||||
totalDocs: 0,
|
||||
totalPages: 0,
|
||||
}),
|
||||
})
|
||||
|
||||
/**
|
||||
* Stub payload dispatching by collection: municipality rows (coverage read),
|
||||
* municipality rows carrying only `stateDeputies` (orphan aggregate), and
|
||||
* stateDeputy/contact docs for the orphan mode. The generic `Payload['find']`
|
||||
* cannot express arbitrary populated docs, so the mock is cast at the call
|
||||
* site — the same tradeoff `stub` already makes for completeness.
|
||||
*/
|
||||
const coveragePayload = (overrides: {
|
||||
municipalities?: Array<Record<string, unknown>>
|
||||
municipalityStateDeputies?: number[]
|
||||
stateDeputies?: Array<Record<string, unknown>>
|
||||
contacts?: Array<Record<string, unknown>>
|
||||
}): Payload => {
|
||||
const find = (async ({ collection }: { collection?: string }) => {
|
||||
const docs =
|
||||
collection === 'municipality' && overrides.municipalityStateDeputies
|
||||
? overrides.municipalityStateDeputies.map((id) => ({ id, stateDeputies: [id] }))
|
||||
: collection === 'municipality'
|
||||
? (overrides.municipalities ?? [])
|
||||
: collection === 'stateDeputy'
|
||||
? (overrides.stateDeputies ?? [])
|
||||
: (overrides.contacts ?? [])
|
||||
return {
|
||||
docs,
|
||||
hasNextPage: false,
|
||||
hasPrevPage: false,
|
||||
limit: 0,
|
||||
nextPage: null,
|
||||
pagingCounter: 0,
|
||||
prevPage: null,
|
||||
totalDocs: 0,
|
||||
totalPages: 0,
|
||||
}
|
||||
}) as unknown as Payload['find']
|
||||
|
||||
return stub<Payload>({ find })
|
||||
}
|
||||
|
||||
describe('partnership coverage tools leader lockdown (B190)', () => {
|
||||
it('getPartnershipCoverage (municipalities) fails closed before any payload query', async () => {
|
||||
await expect(
|
||||
executeFor(leader, untouchablePayload)({ mode: 'municipalities' }),
|
||||
).resolves.toEqual(denied)
|
||||
})
|
||||
|
||||
it('getPartnershipCoverage (orphanDeputies) fails closed before any payload query', async () => {
|
||||
await expect(
|
||||
executeFor(leader, untouchablePayload)({ mode: 'orphanDeputies' }),
|
||||
).resolves.toEqual(denied)
|
||||
})
|
||||
|
||||
it('lets staff pass the gate (queries reach payload)', async () => {
|
||||
const result = await executeFor(coordinator, emptyDocsPayload)({ mode: 'municipalities' })
|
||||
expect(result).toMatchObject({ message: 'Nenhum município sem dobradinha no escopo atual.' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('partnership coverage tool staff behavior (B190)', () => {
|
||||
it('returns empty municipalities with the declared criterion', async () => {
|
||||
const result = await executeFor(coordinator, emptyDocsPayload)({ mode: 'municipalities' })
|
||||
expect(result).toMatchObject({
|
||||
message: 'Nenhum município sem dobradinha no escopo atual.',
|
||||
criterio: expect.stringContaining('cadastro atual'),
|
||||
})
|
||||
})
|
||||
|
||||
it('groups Salvador zones under the city by default', async () => {
|
||||
const payload = coveragePayload({
|
||||
municipalities: [
|
||||
{
|
||||
name: 'Salvador — ZE 1',
|
||||
slug: 'salvador-ze-1',
|
||||
city: 'Salvador',
|
||||
region: 'Metropolitano de Salvador',
|
||||
zoneNumber: 1,
|
||||
},
|
||||
{
|
||||
name: 'Salvador — ZE 3',
|
||||
slug: 'salvador-ze-3',
|
||||
city: 'Salvador',
|
||||
region: 'Metropolitano de Salvador',
|
||||
zoneNumber: 3,
|
||||
},
|
||||
{ name: 'Ilhéus', slug: 'ilheus', city: 'Ilhéus', region: 'Litoral Sul', zoneNumber: null },
|
||||
],
|
||||
})
|
||||
|
||||
const result = await executeFor(coordinator, payload)({ mode: 'municipalities' })
|
||||
expect(result).toMatchObject({
|
||||
agrupadoPorCidade: true,
|
||||
total: 3,
|
||||
municipios: [
|
||||
{ nome: 'Ilhéus', slug: 'ilheus', unidades: 1 },
|
||||
{ nome: 'Salvador', slug: 'salvador', unidades: 2 },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('expands Salvador zones when expandZones is true', async () => {
|
||||
const payload = coveragePayload({
|
||||
municipalities: [
|
||||
{
|
||||
name: 'Salvador — ZE 1',
|
||||
slug: 'salvador-ze-1',
|
||||
city: 'Salvador',
|
||||
region: 'Metropolitano de Salvador',
|
||||
zoneNumber: 1,
|
||||
},
|
||||
{
|
||||
name: 'Salvador — ZE 3',
|
||||
slug: 'salvador-ze-3',
|
||||
city: 'Salvador',
|
||||
region: 'Metropolitano de Salvador',
|
||||
zoneNumber: 3,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const result = await executeFor(
|
||||
coordinator,
|
||||
payload,
|
||||
)({
|
||||
mode: 'municipalities',
|
||||
expandZones: true,
|
||||
})
|
||||
expect(result).toMatchObject({
|
||||
agrupadoPorCidade: false,
|
||||
total: 2,
|
||||
municipios: [
|
||||
{ nome: 'Salvador — ZE 1', slug: 'salvador-ze-1' },
|
||||
{ nome: 'Salvador — ZE 3', slug: 'salvador-ze-3' },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects an unrecognized region with a helpful error', async () => {
|
||||
const result = await executeFor(
|
||||
coordinator,
|
||||
emptyDocsPayload,
|
||||
)({
|
||||
mode: 'municipalities',
|
||||
region: 'Planeta Marte',
|
||||
})
|
||||
expect(result).toMatchObject({ error: expect.stringContaining('Região não reconhecida') })
|
||||
})
|
||||
|
||||
it('returns orphans only for deputies with no municipality anywhere', async () => {
|
||||
const payload = coveragePayload({
|
||||
// Municipality 1 links deputy 10; municipality 2 links deputies 10 and 20.
|
||||
municipalityStateDeputies: [10, 20],
|
||||
stateDeputies: [
|
||||
{ id: 10, slug: 'maria', party: 'PT', notes: null, contact: 100 },
|
||||
{ id: 20, slug: 'joao', party: 'PSB', notes: null, contact: 200 },
|
||||
{ id: 30, slug: 'ana', party: 'PCdoB', notes: null, contact: 300 },
|
||||
],
|
||||
contacts: [
|
||||
{ id: 100, name: 'Maria' },
|
||||
{ id: 200, name: 'João' },
|
||||
{ id: 300, name: 'Ana' },
|
||||
],
|
||||
})
|
||||
|
||||
const result = await executeFor(coordinator, payload)({ mode: 'orphanDeputies' })
|
||||
expect(result).toMatchObject({
|
||||
criterio: expect.stringContaining('cadastro atual'),
|
||||
total: 1,
|
||||
dobradinhas: [{ nome: 'Ana', slug: 'ana', partido: 'PCdoB' }],
|
||||
})
|
||||
})
|
||||
|
||||
it('reports when no orphan dobradinhas exist', async () => {
|
||||
const payload = coveragePayload({
|
||||
municipalityStateDeputies: [10],
|
||||
stateDeputies: [{ id: 10, slug: 'maria', party: 'PT', notes: null, contact: 100 }],
|
||||
contacts: [{ id: 100, name: 'Maria' }],
|
||||
})
|
||||
|
||||
const result = await executeFor(coordinator, payload)({ mode: 'orphanDeputies' })
|
||||
expect(result).toMatchObject({
|
||||
message: 'Nenhuma dobradinha órfã cadastrada.',
|
||||
criterio: expect.stringContaining('cadastro atual'),
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue