mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 06:43:06 +00:00
- 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
291 lines
11 KiB
TypeScript
291 lines
11 KiB
TypeScript
import type { Payload } from 'payload'
|
|
import { describe, expect, it, vi } from 'vitest'
|
|
|
|
import type { AIToolContext } from '@/lib/ai/types'
|
|
import type { CampaignUser } from '@/payload-types'
|
|
import { getMunicipalityPriorities } from '@/utilities/ai/tools/getMunicipalityPriorities'
|
|
|
|
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 })
|
|
|
|
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 advisor = stub<CampaignUser>({ collection: 'campaignUser', role: 'advisor' })
|
|
|
|
const scriptedPayload = (find: FindMock): Payload =>
|
|
stub<Payload>({ find: find as Payload['find'] })
|
|
|
|
const untouchablePayload = scriptedPayload(
|
|
vi.fn(() => {
|
|
throw new Error('gate must fail closed before any payload query')
|
|
}),
|
|
)
|
|
|
|
const municipalityDoc = (overrides: Record<string, unknown>) => ({
|
|
id: 10,
|
|
name: 'Amargosa',
|
|
slug: 'amargosa',
|
|
city: 'Amargosa',
|
|
region: 'Vale do Jiquiriçá',
|
|
priority: 'normal',
|
|
engagementLevel: null,
|
|
expectedVotes: null,
|
|
lastUpdateAt: null,
|
|
politicalTrend: null,
|
|
...overrides,
|
|
})
|
|
|
|
const updateDoc = (overrides: Record<string, unknown>) => ({
|
|
municipality: 10,
|
|
createdAt: daysAgo(2),
|
|
polarity: 'ruim',
|
|
urgent: false,
|
|
adversarySignal: false,
|
|
body: 'prefeito fechou com o adversário',
|
|
...overrides,
|
|
})
|
|
|
|
type ExecutableTool = {
|
|
execute: (args: unknown, options?: unknown) => Promise<unknown>
|
|
}
|
|
|
|
const execute =
|
|
(payload: Payload, user: CampaignUser = coordinator) =>
|
|
(args: unknown) =>
|
|
(getMunicipalityPriorities(ctxFor(user, payload)) as unknown as ExecutableTool).execute!(args)
|
|
|
|
describe('getMunicipalityPriorities gate (B186)', () => {
|
|
it('denies a leader with the chat-shaped error before any payload query', async () => {
|
|
const payload = untouchablePayload
|
|
await expect(execute(payload, leader)({})).resolves.toEqual(denied)
|
|
expect(payload.find).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('lets a coordinator pass and returns the empty staff shape', async () => {
|
|
const find = vi.fn().mockResolvedValue(findResult([]))
|
|
const result = (await execute(scriptedPayload(find))({})) as Record<string, unknown>
|
|
|
|
expect(result.total).toBe(0)
|
|
expect(result.prioridades).toEqual([])
|
|
expect(result.truncado).toBe(false)
|
|
expect(result.escopoRestrito).toBe(false)
|
|
expect(result.escopo).toEqual({ tipo: 'todos', nome: null })
|
|
expect(result.janelaDias).toBe(30)
|
|
expect(result.motivoAplicado).toBeNull()
|
|
expect(result.criterio).toContain('30 dias')
|
|
const munCall = find.mock.calls[0]![0] as { collection: string; overrideAccess: boolean }
|
|
expect(munCall.collection).toBe('municipality')
|
|
expect(munCall.overrideAccess).toBe(false)
|
|
expect(find).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('lets an advisor pass (RBAC atual via access control, não pelo gate)', async () => {
|
|
const find = vi.fn().mockResolvedValue(findResult([]))
|
|
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)
|
|
})
|
|
})
|
|
|
|
describe('getMunicipalityPriorities reads (B186)', () => {
|
|
it('carrega municípios do escopo, atualizações da janela e agregado de pledges com acesso do ator', async () => {
|
|
const find = vi.fn()
|
|
find.mockResolvedValueOnce(
|
|
findResult([
|
|
municipalityDoc({ id: 10, expectedVotes: { central: 52400 }, engagementLevel: 'n0' }),
|
|
municipalityDoc({
|
|
id: 11,
|
|
name: 'Brejões',
|
|
slug: 'brejoes',
|
|
lastUpdateAt: daysAgo(70),
|
|
}),
|
|
]),
|
|
)
|
|
find.mockResolvedValueOnce(findResult([updateDoc({ municipality: 10 })]))
|
|
find.mockResolvedValueOnce(
|
|
findResult([{ municipality: 11, declaredVotes: 300, declaredAt: daysAgo(40) }]),
|
|
)
|
|
|
|
const result = (await execute(scriptedPayload(find))({})) as {
|
|
total: number
|
|
prioridades: Array<Record<string, unknown>>
|
|
}
|
|
|
|
const munCall = find.mock.calls[0]![0] as { where: unknown }
|
|
expect(munCall.where).toEqual({})
|
|
|
|
const updateCall = find.mock.calls[1]![0] as {
|
|
collection: string
|
|
where: { and: unknown[] }
|
|
overrideAccess: boolean
|
|
sort: string
|
|
}
|
|
expect(updateCall.collection).toBe('municipalityUpdate')
|
|
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_equal: expect.any(String) } as unknown,
|
|
})
|
|
expect(updateCall.overrideAccess).toBe(false)
|
|
|
|
const pledgeCall = find.mock.calls[2]![0] as { collection: string; where: unknown }
|
|
expect(pledgeCall.collection).toBe('votePledge')
|
|
expect(pledgeCall.where).toEqual({ municipality: { in: [10, 11] } })
|
|
|
|
// The ranking ran with the loaded data: 10 via potential, 11 via stagnation.
|
|
expect(result.total).toBe(2)
|
|
expect(result.prioridades.map((p) => p.motivo)).toEqual(['sinal_desfavoravel', 'estagnacao'])
|
|
const first = result.prioridades[0]!
|
|
expect(first).toMatchObject({
|
|
id: 10,
|
|
nome: 'Amargosa',
|
|
slug: 'amargosa',
|
|
motivo: 'sinal_desfavoravel',
|
|
prioridade: 'normal',
|
|
})
|
|
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: daysAgo(2),
|
|
engagementLevel: 'n2',
|
|
})
|
|
find.mockResolvedValueOnce(findResult([doc]))
|
|
find.mockResolvedValueOnce(findResult([doc]))
|
|
find.mockResolvedValueOnce(findResult([]))
|
|
find.mockResolvedValueOnce(findResult([]))
|
|
|
|
const result = (await execute(scriptedPayload(find))({
|
|
scope: 'Vale do Jiquiriça',
|
|
})) as { escopo: { tipo: string; nome: string }; total: number }
|
|
|
|
expect(result.escopo).toEqual({ tipo: 'regiao', nome: 'Vale do Jiquiriçá' })
|
|
const scopeCall = find.mock.calls[0]![0] as { where: unknown }
|
|
expect(scopeCall.where).toEqual({ region: { equals: 'Vale do Jiquiriçá' } })
|
|
const munCall = find.mock.calls[1]![0] as { where: unknown }
|
|
expect(munCall.where).toEqual({ id: { in: [10] } })
|
|
const updateCall = find.mock.calls[2]![0] as { where: { and: unknown[] } }
|
|
expect(updateCall.where.and[0]).toEqual({ municipality: { in: [10] } })
|
|
// Fresh signal, low potential and no engagement → no bucket.
|
|
expect(result.total).toBe(0)
|
|
})
|
|
|
|
it('escopo desconhecido devolve erro sem consultar nada', async () => {
|
|
const find = vi.fn()
|
|
const result = (await execute(scriptedPayload(find))({
|
|
scope: 'Lugar Inexistente',
|
|
})) as { error: string }
|
|
expect(result.error).toContain('Escopo não reconhecido')
|
|
expect(find).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('sem municípios no escopo (assessor fora do portfólio) não consulta atualizações nem pledges', async () => {
|
|
const find = vi.fn().mockResolvedValue(findResult([]))
|
|
const result = (await execute(
|
|
scriptedPayload(find),
|
|
advisor,
|
|
)({
|
|
scope: 'Salvador',
|
|
})) as { escopoRestrito: boolean; total: number }
|
|
|
|
expect(result.escopoRestrito).toBe(true)
|
|
expect(result.total).toBe(0)
|
|
expect(find).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('aplica limite com total real e devolve a dica de estreitar', async () => {
|
|
const find = vi.fn()
|
|
find.mockResolvedValueOnce(
|
|
findResult([
|
|
municipalityDoc({ id: 1, name: 'A', slug: 'a', lastUpdateAt: daysAgo(70) }),
|
|
municipalityDoc({ id: 2, name: 'B', slug: 'b', lastUpdateAt: daysAgo(69) }),
|
|
]),
|
|
)
|
|
find.mockResolvedValueOnce(findResult([]))
|
|
find.mockResolvedValueOnce(findResult([]))
|
|
|
|
const result = (await execute(scriptedPayload(find))({ limit: 1 })) as {
|
|
total: number
|
|
truncado: boolean
|
|
dica?: string
|
|
prioridades: unknown[]
|
|
}
|
|
|
|
expect(result.total).toBe(2)
|
|
expect(result.truncado).toBe(true)
|
|
expect(result.dica).toContain('Estreite o escopo')
|
|
expect(result.prioridades).toHaveLength(1)
|
|
})
|
|
|
|
it('passa motivo, ordenação e janela custom ao ranking', async () => {
|
|
const find = vi.fn()
|
|
find.mockResolvedValueOnce(findResult([municipalityDoc({ id: 1, lastUpdateAt: daysAgo(70) })]))
|
|
find.mockResolvedValueOnce(findResult([]))
|
|
find.mockResolvedValueOnce(findResult([]))
|
|
|
|
const result = (await execute(scriptedPayload(find))({
|
|
reason: 'estagnacao',
|
|
sortBy: 'potencial',
|
|
days: 60,
|
|
limit: 3,
|
|
})) as {
|
|
motivoAplicado: string | null
|
|
janelaDias: number
|
|
criterio: string
|
|
prioridades: unknown[]
|
|
}
|
|
|
|
expect(result.motivoAplicado).toBe('estagnacao')
|
|
expect(result.janelaDias).toBe(60)
|
|
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_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)
|
|
})
|
|
|
|
it('fallback dos válidos 2022 alimenta o potencial quando não há estimativa', async () => {
|
|
const find = vi.fn()
|
|
find.mockResolvedValueOnce(
|
|
findResult([
|
|
municipalityDoc({
|
|
id: 10,
|
|
engagementLevel: 'n1',
|
|
expectedVotes: null,
|
|
lastUpdateAt: daysAgo(2),
|
|
}),
|
|
]),
|
|
)
|
|
find.mockResolvedValueOnce(findResult([]))
|
|
find.mockResolvedValueOnce(findResult([]))
|
|
|
|
const result = (await execute(scriptedPayload(find))({})) as {
|
|
prioridades: Array<{ evidencia: string; fontePotencial: string | null }>
|
|
}
|
|
|
|
// "Amargosa" is a real catalog municipality — its 2022 valid votes come
|
|
// from the committed artifact; a non-catalog slug would yield null.
|
|
expect(result.prioridades[0]!.fontePotencial).toBe('validos_2022')
|
|
expect(result.prioridades[0]!.evidencia).toContain('válidos em 2022')
|
|
})
|
|
})
|