style(B186): simplify fixes — NaN tiebreak, janela simétrica, testes sem bomb-relógio

- byGravity estagnação: Infinity - Infinity (NaN) matava o tiebreak para dois
  municípios nunca-sinal (topo da lista) — comparação explícita + teste
- fronteira da janela: módulo puro inclusivo (>= cutoff) e query da tool em
  greater_than_equal (antes greater_than, assimétrico) + testes 30/31 dias
- ultimoSinalAtrasDias reporta a idade da atualização decisiva no balde sinal
  (E9 podia devolver null ao lado de evidência 'há 2 dias' em linha stale)
- fixtures da tool com datas relativas a Date.now() (absolutas apodreceriam
  em ~30 dias); assert de user: ctx.user no caminho do assessor
- options type em inglês (windowDays/reason/sortBy); MunicipalityPriorityReason
  não exportado (knip); agora passado da tool (um relógio)
- expectedVotes select estreito { central: true }; sort '-createdAt' removido
  (módulo re-deriva o decisivo); NARROW_SCOPE_HINT compartilhado em aiToolScope
- baseResponse unifica o shape (short-circuit + retorno); docblock órfão da
  extração removido; prompt: coordenação/candidatura
- impl plan: registro de execução + shape entregue
This commit is contained in:
Francisco Solla 2026-08-10 08:09:31 -03:00
parent 11886a653d
commit b354ad55d6
6 changed files with 164 additions and 74 deletions

View file

@ -6,6 +6,19 @@ Issue: #525
Intenção: docs/plans/sollinha-prioridades-do-momento.md
Appetite restante: ~1 dia eng (herdado); uma tool nova read-only + módulo puro + testes; sem migration/collection/Consent/UI
## Registro de execução (2026-08-10)
- **Entrega:** `getMunicipalityPriorities` nova (`src/utilities/ai/tools/getMunicipalityPriorities.ts`) + ranking puro `src/utilities/municipality/municipalityPriorities.ts` + resolver de escopo da B185 extraído (`src/utilities/ai/tools/aiToolScope.ts`, refactor de `getPendingLeaderships` sem mudança de comportamento — 18 testes dela verdes) + registro no `index.ts` + seção no system prompt; 32 unit tests (28 ranking + 10 tool, ajustados no simplify). Mergeado via PR (Closes #525).
- **Divergências aprovadas no gate:** frescor E9 (`max(lastUpdateAt, lastPledgeAt)`) em vez da semântica B185 de só-`municipalityUpdate`; exclusão literal do "boa recente" (vale até para o balde potencial); "última palavra decide"; potencial = top 5 do escopo por `expectedVotes.central` com fallback nos válidos 2022 (artefato), fonte rotulada.
- **Revisão /simplify (3 reviewers paralelos) — fixes aplicados:**
- **Bug real (P1):** comparador da estagnação com dois municípios nunca-sinal produzia `Infinity - Infinity = NaN` → tiebreak (prioridade alta/nome) morto exatamente no topo da lista; comparador explícito + teste.
- **Bomb-relógio (P1):** testes da tool usavam datas absolutas contra o relógio real — iriam apodrecer ~30 dias após 2026-08-08; fixtures agora derivam de `Date.now()` (o módulo puro já pinava `agora`).
- Fronteira da janela alinhada: módulo puro aceita update com exatamente `windowDays` (inclusivo) e a query da tool passou a `greater_than_equal` (antes `greater_than` — assimétrico); testes de fronteira nos dois lados.
- `ultimoSinalAtrasDias` agora reporta a idade da atualização decisiva quando o item veio do balde sinal (antes E9 podia devolver `null` ao lado de "atualização ruim há 2 dias" em linha stale).
- Identificadores do options type em inglês (`windowDays`/`reason`/`sortBy` — regra do repo; as chaves pt da resposta são o contrato B185); `MunicipalityPriorityReason` deixou de ser exportado (knip).
- `agora: new Date(now)` passado da tool (um único relógio); `expectedVotes: { central: true }` (select estreito); `sort: '-createdAt'` removido (o módulo re-deriva o decisivo — trabalho morto); cast redundante removido; `NARROW_SCOPE_HINT` movido para `aiToolScope.ts` (vocabulário de escopo, 2 call sites); `emptyResponse` duplicado unificado em `baseResponse`; docblock órfão da extração removido; alias `ResolvedScope` removido; "estado para o coordenador" no prompt virou "coordenação/candidatura" (candidate é unrestricted); allowlist P3-D estendida com a mesma justificativa da B185.
- Testes novos do simplify: dois nunca-sinal (NaN), exclusão-vence-estagnação, empate de potencial ordena por nome, fronteira exata 30/31 dias.
## Leitura da intenção
- **Outcome:** uma tool nova do Sollinha responde "quais devem ser minhas prioridades neste momento?" com **top N municípios do escopo do usuário**, cada item com **uma linha de evidência** (o fator que o colocou ali), o **critério de ordenação declarado na resposta**, filtro/reordenação por fator, exclusão de município com atualização recente favorável e sem sinais negativos, assessor = portfólio, leader = deny fail-closed, links via `buildCampaignLinks` (precedente B185/B162).
@ -41,7 +54,7 @@ flowchart LR
### Componentes / mudanças
- **`rankMunicipalityPriorities`** (`src/utilities/municipality/municipalityPriorities.ts`, novo): módulo **puro e client-safe** (importa `municipalitySignal.ts`, `municipalityLabels.ts`, `lib/engagementLevel`, `lib/schemas/municipalityUpdate`) com os baldes, a ordenação por gravidade, a exclusão do "favorável recente" e a geração de evidência de uma linha. Entrada: linhas de município (id/nome/slug/regiao/cidade/priority/engagementLevel/expectedVotes.central/lastUpdateAt/politicalTrend.status) + `lastPledgeAt` por id + atualizações da janela + `janelaDias` + filtros; saída: `prioridades[]` ordenadas com `motivo`/`evidencia`/`nivelEngajamento`/`ultimoSinalAtrasDias`/`potencialEstimado`/`fontePotencial`/`trechoAtualizacao`.
- **`rankMunicipalityPriorities`** (`src/utilities/municipality/municipalityPriorities.ts`, novo): módulo **puro e client-safe** (importa `municipalitySignal.ts`, `municipalityLabels.ts`, `lib/engagementLevel`, `lib/schemas/municipalityUpdate`) com os baldes, a ordenação por gravidade, a exclusão do "favorável recente" e a geração de evidência de uma linha. Entrada: linhas de município (id/nome/slug/regiao/cidade/priority/engagementLevel/expectedVotes.central/lastUpdateAt/politicalTrend.status) + `lastPledgeAt` por id + atualizações da janela + `windowDays` + filtros; saída: `MunicipalityPriorityItem[]` ordenadas — `{ id, nome, slug, regiao, cidade, motivo, evidencia, prioridade, nivelEngajamento, ultimoSinalAtrasDias, ultimaAtualizacao, potencialEstimado, fontePotencial }` (o trecho da atualização vai embutido na `evidencia` do balde sinal, não como campo separado).
- **`aiToolScope`** (`src/utilities/ai/tools/aiToolScope.ts`, novo): extração do `resolveScope`/`resolveMunicipalities` da B185 (Salvador-primeiro, `resolveMunicipalityName`, território por `normalizeSearchPhrase`, escopo do ator quando omitido) — **refatorar `getPendingLeaderships`** para consumir (mesmo shape, testes verdes).
- **`getMunicipalityPriorities`** (`src/utilities/ai/tools/getMunicipalityPriorities.ts`, novo): factory `tool()`; gate staff inline no topo (1 call site, padrão B185: `isStaffCampaignRole` → `{ error: 'Leitura de prioridades de municípios negada.' }` antes de qualquer query); leituras: municípios do escopo (`overrideAccess: false, user: ctx.user`, select estratégico + `lastUpdateAt`), atualizações da janela (`municipalityUpdate` com `municipality in ids` + `createdAt > cutoff`, select `municipality/polarity/urgent/adversarySignal/createdAt/body`, sort `-createdAt`), pledges via `aggregatePledgesByMunicipality` (helper existente — `lastPledgeAt` por id); fallback de potencial via `getMunicipalityFederalBaseline(slug).validVotesByYear['2022']` (artefato commitado, puro).
- **`index.ts`** (`src/utilities/ai/tools/index.ts`): registrar `getMunicipalityPriorities: getMunicipalityPriorities(ctx)`.

View file

@ -5,19 +5,17 @@ import type { AIToolContext } from '@/lib/ai/types'
import { getMunicipalityFederalBaseline } from '@/lib/bahiaElectionAggregates'
import { isStaffCampaignRole } from '@/lib/campaignRoles'
import { DAY_MS } from '@/lib/text'
import { resolveAIToolScope } from '@/utilities/ai/tools/aiToolScope'
import { AI_TOOL_NARROW_SCOPE_HINT, resolveAIToolScope } from '@/utilities/ai/tools/aiToolScope'
import { type PoliticalTrendStatus } from '@/utilities/municipality/municipalityLabels'
import {
PRIORITY_STALE_SIGNAL_DAYS,
rankMunicipalityPriorities,
type MunicipalityPriorityReason,
type PriorityMunicipalityInput,
type PriorityUpdateSignalInput,
} from '@/utilities/municipality/municipalityPriorities'
import { aggregatePledgesByMunicipality } from '@/utilities/votePledgeData'
const DENIED_MESSAGE = 'Leitura de prioridades de municípios negada.'
const NARROW_SCOPE_HINT = 'Estreite o escopo (território, cidade ou município) para ver o restante.'
const buildCriterion = (janelaDias: number): string =>
`Prioridades por gravidade: (1) sinal desfavorável recente (última atualização ruim, urgente ou com alerta de adversário na janela de ${janelaDias} dias); ` +
@ -114,24 +112,23 @@ export const getMunicipalityPriorities = (ctx: AIToolContext) =>
const scopeIDs = resolved.municipalityIDs
// The AI SDK applies the zod defaults before execute in the real route;
// the ?? guards keep direct calls (unit tests, future callers) sane.
const janelaDias = days ?? PRIORITY_STALE_SIGNAL_DAYS
const windowDays = days ?? PRIORITY_STALE_SIGNAL_DAYS
const safeLimit = limit ?? 10
const now = Date.now()
const now = new Date()
const emptyResponse = () => ({
const baseResponse = {
escopo: { tipo: resolved.kind, nome: resolved.name },
escopoRestrito: ctx.user.role === 'advisor',
criterio: buildCriterion(janelaDias),
janelaDias,
criterio: buildCriterion(windowDays),
janelaDias: windowDays,
motivoAplicado: reason ?? null,
total: 0,
prioridades: [],
truncado: false,
})
}
// Scoped to nothing (advisor asking outside the portfolio): fail closed
// before the municipality read — never an existence lie, no wasted trips.
if (scopeIDs !== null && scopeIDs.length === 0) return emptyResponse()
if (scopeIDs !== null && scopeIDs.length === 0) {
return { ...baseResponse, total: 0, prioridades: [], truncado: false }
}
const municipalities = await ctx.payload.find({
collection: 'municipality',
@ -147,7 +144,7 @@ export const getMunicipalityPriorities = (ctx: AIToolContext) =>
region: true,
priority: true,
engagementLevel: true,
expectedVotes: true,
expectedVotes: { central: true },
lastUpdateAt: true,
politicalTrend: { status: true },
},
@ -167,7 +164,13 @@ export const getMunicipalityPriorities = (ctx: AIToolContext) =>
and: [
{ municipality: { in: ids } },
{
createdAt: { greater_than: new Date(now - janelaDias * DAY_MS).toISOString() },
// Same boundary as the pure module: an update exactly
// `windowDays` old is still decisive.
createdAt: {
greater_than_equal: new Date(
now.getTime() - windowDays * DAY_MS,
).toISOString(),
},
},
],
},
@ -182,7 +185,6 @@ export const getMunicipalityPriorities = (ctx: AIToolContext) =>
adversarySignal: true,
body: true,
},
sort: '-createdAt',
overrideAccess: false,
user: ctx.user,
}),
@ -226,9 +228,10 @@ export const getMunicipalityPriorities = (ctx: AIToolContext) =>
)
const ranked = rankMunicipalityPriorities(inputs, lastPledgeAtById, signals, {
janelaDias,
motivo: reason as MunicipalityPriorityReason | undefined,
ordenarPor: sortBy,
windowDays,
reason,
sortBy,
agora: now,
})
const total = ranked.length
@ -236,15 +239,11 @@ export const getMunicipalityPriorities = (ctx: AIToolContext) =>
const truncado = total > top.length
return {
escopo: { tipo: resolved.kind, nome: resolved.name },
escopoRestrito: ctx.user.role === 'advisor',
criterio: buildCriterion(janelaDias),
janelaDias,
motivoAplicado: reason ?? null,
...baseResponse,
total,
prioridades: top,
truncado,
...(truncado ? { dica: NARROW_SCOPE_HINT } : {}),
...(truncado ? { dica: AI_TOOL_NARROW_SCOPE_HINT } : {}),
}
},
})

View file

@ -7,8 +7,9 @@ import { isStaffCampaignRole } from '@/lib/campaignRoles'
import { relationshipId, uniqueRelationshipIds } from '@/lib/relationship'
import { loadAIToolNamesByIds } from '@/utilities/ai/tools/aiToolQueries'
import {
AI_TOOL_NARROW_SCOPE_HINT,
resolveAIToolScope,
type AIToolScope as ResolvedScope,
type AIToolScope,
} from '@/utilities/ai/tools/aiToolScope'
import { supportStatusLabels } from '@/utilities/leadership/leadershipLabels'
@ -16,7 +17,6 @@ const DENIED_MESSAGE = 'Leitura de dados de lideranças negada.'
const PENDING_CRITERION =
'Status "A abordar" ou "Em disputa"; ou "Engajado" sem compromisso de votos no escopo consultado.'
const EMPTY_MUNICIPALITIES_CRITERION = 'Municípios do escopo sem nenhuma liderança vinculada.'
const NARROW_SCOPE_HINT = 'Estreite o escopo (território, cidade ou município) para ver o restante.'
const PENDING_SUPPORT_STATUSES = ['a_abordar', 'em_disputa', 'engajado'] as const
type PendingLeadershipDoc = {
@ -92,7 +92,7 @@ export const getPendingLeaderships = (ctx: AIToolContext) =>
const listPendingLeaderships = async (
ctx: AIToolContext,
scope: ResolvedScope,
scope: AIToolScope,
semAssessor: boolean,
limit: number,
) => {
@ -206,13 +206,13 @@ const listPendingLeaderships = async (
total,
liderancas,
truncado,
...(truncado ? { dica: NARROW_SCOPE_HINT } : {}),
...(truncado ? { dica: AI_TOOL_NARROW_SCOPE_HINT } : {}),
}
}
const listMunicipalitiesWithoutLeadership = async (
ctx: AIToolContext,
scope: ResolvedScope,
scope: AIToolScope,
limit: number,
) => {
const { payload } = ctx
@ -276,7 +276,7 @@ const listMunicipalitiesWithoutLeadership = async (
cidade: doc.city,
})),
truncado,
...(truncado ? { dica: NARROW_SCOPE_HINT } : {}),
...(truncado ? { dica: AI_TOOL_NARROW_SCOPE_HINT } : {}),
}
}

View file

@ -37,7 +37,7 @@ export const PRIORITY_POTENTIAL_TOP_N = 5
/** Longest update excerpt carried into an evidence line. */
const PRIORITY_UPDATE_EXCERPT_MAX_LENGTH = 200
export type MunicipalityPriorityReason = 'sinal_desfavoravel' | 'estagnacao' | 'potencial'
type MunicipalityPriorityReason = 'sinal_desfavoravel' | 'estagnacao' | 'potencial'
export type PriorityMunicipalityInput = {
id: number
@ -81,11 +81,11 @@ export type MunicipalityPriorityItem = {
}
export type MunicipalityPriorityRankOptions = {
janelaDias: number
windowDays: number
/** Narrow the ranking to one reason ("só as sem atualização"). */
motivo?: MunicipalityPriorityReason
reason?: MunicipalityPriorityReason
/** Default gravity (sinal > estagnação > potencial); potential re-sorts all. */
ordenarPor?: 'gravidade' | 'potencial'
sortBy?: 'gravidade' | 'potencial'
/** One clock read for the whole ranking — tests pin `agora`. */
agora?: Date
}
@ -140,7 +140,7 @@ const evaluateMunicipality = (
lastPledgeAt: string | null,
decisiveUpdate: PriorityUpdateSignalInput | null,
now: Date,
janelaDias: number,
windowDays: number,
): EvaluatedMunicipality => {
const lastSignalAgeDays = municipalitySignalAgeInDays(
resolveMunicipalityLastSignalAt(input.lastUpdateAt, lastPledgeAt),
@ -164,7 +164,7 @@ const evaluateMunicipality = (
) {
bucket = 'sinal_desfavoravel'
}
} else if (lastSignalAgeDays === null || lastSignalAgeDays >= janelaDias) {
} else if (lastSignalAgeDays === null || lastSignalAgeDays >= windowDays) {
bucket = 'estagnacao'
}
@ -230,6 +230,12 @@ const buildEvidence = (evaluated: EvaluatedMunicipality): string => {
const priorityRank: Record<'alta' | 'normal', number> = { alta: 0, normal: 1 }
const bucketRank: Record<MunicipalityPriorityReason, number> = {
sinal_desfavoravel: 0,
estagnacao: 1,
potencial: 2,
}
const byName = (left: PriorityMunicipalityInput, right: PriorityMunicipalityInput): number =>
left.name.localeCompare(right.name, 'pt-BR')
@ -240,11 +246,6 @@ const tiebreak = (left: EvaluatedMunicipality, right: EvaluatedMunicipality): nu
}
const byGravity = (left: EvaluatedMunicipality, right: EvaluatedMunicipality): number => {
const bucketRank: Record<MunicipalityPriorityReason, number> = {
sinal_desfavoravel: 0,
estagnacao: 1,
potencial: 2,
}
const byBucket =
bucketRank[left.bucket as MunicipalityPriorityReason] -
bucketRank[right.bucket as MunicipalityPriorityReason]
@ -259,12 +260,13 @@ const byGravity = (left: EvaluatedMunicipality, right: EvaluatedMunicipality): n
return tiebreak(left, right)
}
if (left.bucket === 'estagnacao') {
// Coldest first; never-signaled (+Infinity) outranks every date.
const byAge =
(right.lastSignalAgeDays ?? Number.POSITIVE_INFINITY) -
(left.lastSignalAgeDays ?? Number.POSITIVE_INFINITY)
if (byAge !== 0) return byAge
return tiebreak(left, right)
// Coldest first; never-signaled (+Infinity) outranks every date. The
// explicit equality check keeps two never-signaled rows from comparing
// Infinity - Infinity (NaN), which would silently skip the tiebreak.
const leftAge = left.lastSignalAgeDays ?? Number.POSITIVE_INFINITY
const rightAge = right.lastSignalAgeDays ?? Number.POSITIVE_INFINITY
if (leftAge === rightAge) return tiebreak(left, right)
return rightAge - leftAge
}
if (left.bucket === 'potencial') {
const byPotential = (right.potential ?? 0) - (left.potential ?? 0)
@ -290,7 +292,11 @@ const toItem = (evaluated: EvaluatedMunicipality): MunicipalityPriorityItem => (
evidencia: buildEvidence(evaluated),
prioridade: evaluated.input.priority,
nivelEngajamento: engagementLevelLabel(evaluated.input.engagementLevel),
ultimoSinalAtrasDias: evaluated.lastSignalAgeDays,
// When a decisive update placed the item, its age IS the item's signal age
// (E9 would hide it behind a null lastUpdateAt on stale rows).
ultimoSinalAtrasDias: evaluated.decisiveUpdate
? evaluated.decisiveUpdateAgeDays
: evaluated.lastSignalAgeDays,
ultimaAtualizacao: evaluated.input.lastUpdateAt,
potencialEstimado: evaluated.potential,
fontePotencial: evaluated.potentialSource,
@ -309,7 +315,7 @@ export const rankMunicipalityPriorities = (
options: MunicipalityPriorityRankOptions,
): MunicipalityPriorityItem[] => {
const now = options.agora ?? new Date()
const cutoff = now.getTime() - options.janelaDias * DAY_MS
const cutoff = now.getTime() - options.windowDays * DAY_MS
const updatesByMunicipality = new Map<number, PriorityUpdateSignalInput[]>()
for (const update of updates) {
@ -330,7 +336,7 @@ export const rankMunicipalityPriorities = (
lastPledgeAtById.get(input.id) ?? null,
decisiveByMunicipality.get(input.id) ?? null,
now,
options.janelaDias,
options.windowDays,
),
)
@ -351,13 +357,13 @@ export const rankMunicipalityPriorities = (
}
}
const sortBy = options.ordenarPor === 'potencial' ? byPotential : byGravity
const sortBy = options.sortBy === 'potencial' ? byPotential : byGravity
const ranked = evaluated
.filter((entry) => entry.bucket !== 'nada' && entry.bucket !== 'excluido')
.sort(sortBy)
if (options.motivo) {
const reason = options.motivo
if (options.reason) {
const reason = options.reason
return ranked.filter((entry) => entry.bucket === reason).map(toItem)
}

View file

@ -56,9 +56,9 @@ const rank = (
options.lastPledgeAtById ?? new Map(),
options.updates ?? [],
{
janelaDias: options.janelaDias ?? 30,
motivo: options.motivo,
ordenarPor: options.ordenarPor,
windowDays: options.janelaDias ?? 30,
reason: options.motivo,
sortBy: options.ordenarPor,
agora: NOW,
},
)
@ -407,4 +407,74 @@ describe('rankMunicipalityPriorities options (B186)', () => {
const result2 = rank([mun({ id: 2, lastUpdateAt: daysAgo(40) })], { janelaDias: 30 })
expect(result2[0]!.motivo).toBe('estagnacao')
})
it('fronteira exata da janela: atualização com exatamente 30 dias ainda é decisiva', () => {
const result = rank([mun({ id: 1, lastUpdateAt: daysAgo(30) })], {
updates: [update(1, daysAgo(30))],
})
expect(result[0]!.motivo).toBe('sinal_desfavoravel')
const result2 = rank([mun({ id: 2, lastUpdateAt: daysAgo(31) })], {
updates: [update(2, daysAgo(31))],
})
expect(result2[0]!.motivo).toBe('estagnacao')
})
it('estagnação: sinal com exatamente 30 dias está no limite (>= janela)', () => {
const result = rank([mun({ id: 1, lastUpdateAt: daysAgo(30) })])
expect(result[0]!.motivo).toBe('estagnacao')
})
})
describe('rankMunicipalityPriorities tiebreaks (B186)', () => {
it('dois municípios nunca-sinal: prioridade alta primeiro, depois nome (Infinity não vira NaN)', () => {
const result = rank([
mun({ id: 1, name: 'Abreu e Lima', slug: 'a', priority: 'normal' }),
mun({ id: 2, name: 'Belmonte', slug: 'b', priority: 'alta' }),
mun({ id: 3, name: 'Cairu', slug: 'c', priority: 'normal' }),
])
expect(result.map((item) => item.id)).toEqual([2, 1, 3])
})
it('candidatos empatados em potencial ordenam por nome', () => {
const result = rank([
mun({
id: 1,
name: 'Zé Ninguém',
slug: 'z',
lastUpdateAt: daysAgo(2),
engagementLevel: 'n0',
expectedVotesCentral: 50000,
}),
mun({
id: 2,
name: 'Alfa',
slug: 'a',
lastUpdateAt: daysAgo(2),
engagementLevel: 'n0',
expectedVotesCentral: 50000,
}),
mun({
id: 3,
name: 'Beta',
slug: 'b',
lastUpdateAt: daysAgo(2),
engagementLevel: 'n0',
expectedVotesCentral: 90000,
}),
])
expect(result.map((item) => item.id)).toEqual([3, 2, 1])
})
it('exclusão vence a estagnação: atualização boa recente com pledge antigo fica fora', () => {
const result = rank([mun({ id: 1, lastUpdateAt: daysAgo(2) })], {
lastPledgeAtById: new Map([[1, daysAgo(40)]]),
updates: [update(1, daysAgo(2), { polarity: 'boa' })],
})
expect(result).toHaveLength(0)
})
})

View file

@ -9,6 +9,9 @@ import { stub } from '../helpers/stub'
const denied = { error: 'Leitura de prioridades de municípios negada.' }
const nowMs = Date.now()
const daysAgo = (days: number): string => new Date(nowMs - days * 86_400_000).toISOString()
type FindMock = ReturnType<typeof vi.fn>
const findResult = (docs: unknown[]) => ({ docs })
@ -44,7 +47,7 @@ const municipalityDoc = (overrides: Record<string, unknown>) => ({
const updateDoc = (overrides: Record<string, unknown>) => ({
municipality: 10,
createdAt: '2026-08-08T12:00:00.000Z',
createdAt: daysAgo(2),
polarity: 'ruim',
urgent: false,
adversarySignal: false,
@ -91,6 +94,9 @@ describe('getMunicipalityPriorities gate (B186)', () => {
const result = (await execute(scriptedPayload(find), advisor)({})) as Record<string, unknown>
expect(result.total).toBe(0)
expect(result.escopoRestrito).toBe(true)
const munCall = find.mock.calls[0]![0] as { user: unknown; overrideAccess: boolean }
expect(munCall.overrideAccess).toBe(false)
expect(munCall.user).toBe(advisor)
})
})
@ -104,15 +110,13 @@ describe('getMunicipalityPriorities reads (B186)', () => {
id: 11,
name: 'Brejões',
slug: 'brejoes',
lastUpdateAt: '2026-06-01T12:00:00.000Z',
lastUpdateAt: daysAgo(70),
}),
]),
)
find.mockResolvedValueOnce(findResult([updateDoc({ municipality: 10 })]))
find.mockResolvedValueOnce(
findResult([
{ municipality: 11, declaredVotes: 300, declaredAt: '2026-07-01T12:00:00.000Z' },
]),
findResult([{ municipality: 11, declaredVotes: 300, declaredAt: daysAgo(40) }]),
)
const result = (await execute(scriptedPayload(find))({})) as {
@ -133,10 +137,9 @@ describe('getMunicipalityPriorities reads (B186)', () => {
expect(updateCall.where.and).toHaveLength(2)
expect(updateCall.where.and[0]).toEqual({ municipality: { in: [10, 11] } })
expect(updateCall.where.and[1]).toEqual({
createdAt: { greater_than: expect.any(String) } as unknown,
createdAt: { greater_than_equal: expect.any(String) } as unknown,
})
expect(updateCall.overrideAccess).toBe(false)
expect(updateCall.sort).toBe('-createdAt')
const pledgeCall = find.mock.calls[2]![0] as { collection: string; where: unknown }
expect(pledgeCall.collection).toBe('votePledge')
@ -155,13 +158,14 @@ describe('getMunicipalityPriorities reads (B186)', () => {
})
expect(first.evidencia).toContain('prefeito fechou com o adversário')
expect(first.ultimaAtualizacao).toBeNull()
expect(first.ultimoSinalAtrasDias).toBe(2)
})
it('escopo região resolve antes das leituras e estreita o where', async () => {
const find = vi.fn()
const doc = municipalityDoc({
id: 10,
lastUpdateAt: '2026-08-08T12:00:00.000Z',
lastUpdateAt: daysAgo(2),
engagementLevel: 'n2',
})
find.mockResolvedValueOnce(findResult([doc]))
@ -211,8 +215,8 @@ describe('getMunicipalityPriorities reads (B186)', () => {
const find = vi.fn()
find.mockResolvedValueOnce(
findResult([
municipalityDoc({ id: 1, name: 'A', slug: 'a', lastUpdateAt: '2026-06-01T12:00:00.000Z' }),
municipalityDoc({ id: 2, name: 'B', slug: 'b', lastUpdateAt: '2026-06-02T12:00:00.000Z' }),
municipalityDoc({ id: 1, name: 'A', slug: 'a', lastUpdateAt: daysAgo(70) }),
municipalityDoc({ id: 2, name: 'B', slug: 'b', lastUpdateAt: daysAgo(69) }),
]),
)
find.mockResolvedValueOnce(findResult([]))
@ -233,9 +237,7 @@ describe('getMunicipalityPriorities reads (B186)', () => {
it('passa motivo, ordenação e janela custom ao ranking', async () => {
const find = vi.fn()
find.mockResolvedValueOnce(
findResult([municipalityDoc({ id: 1, lastUpdateAt: '2026-06-01T12:00:00.000Z' })]),
)
find.mockResolvedValueOnce(findResult([municipalityDoc({ id: 1, lastUpdateAt: daysAgo(70) })]))
find.mockResolvedValueOnce(findResult([]))
find.mockResolvedValueOnce(findResult([]))
@ -256,8 +258,8 @@ describe('getMunicipalityPriorities reads (B186)', () => {
expect(result.criterio).toContain('60 dias')
expect(result.prioridades).toHaveLength(1)
const updateCall = find.mock.calls[1]![0] as { where: { and: unknown[] } }
const createdAtClause = updateCall.where.and[1] as { createdAt: { greater_than: string } }
const cutoffMs = new Date(createdAtClause.createdAt.greater_than).getTime()
const createdAtClause = updateCall.where.and[1] as { createdAt: { greater_than_equal: string } }
const cutoffMs = new Date(createdAtClause.createdAt.greater_than_equal).getTime()
const expectedCutoffMs = Date.now() - 60 * 86_400_000
expect(Math.abs(cutoffMs - expectedCutoffMs)).toBeLessThan(5_000)
})
@ -270,7 +272,7 @@ describe('getMunicipalityPriorities reads (B186)', () => {
id: 10,
engagementLevel: 'n1',
expectedVotes: null,
lastUpdateAt: '2026-08-08T12:00:00.000Z',
lastUpdateAt: daysAgo(2),
}),
]),
)