teqo/tests/int/campaignMunicipalityEngagementLevel.int.spec.ts
Cursor Agent 0aa47f8673
feat(campanha): B147 município v2 shell + faixa de status
Rota paralela /campanha/municipio/<slug>/v2 com selects de nível, tendência e
sinal (modal de motivo opcional), classe territorial e agregado. Soft-dep B134:
motivo opcional no movimento de nível e remoção do campo “voltar atrás” do
write path/lista.

Co-authored-by: Francisco Solla <fsolla@users.noreply.github.com>
2026-08-02 23:29:05 +00:00

280 lines
10 KiB
TypeScript

// @vitest-environment node
import type { Payload } from 'payload'
import { getPayload } from 'payload'
import { beforeAll, describe, expect, it } from 'vitest'
import { setMunicipalityEngagementLevelRecord } from '@/app/(campaign)/campanha/actions/municipality'
import { ENGAGEMENT_LEVEL_PATTERN_ID, EngagementLevelBlockedError } from '@/lib/engagementLevel'
import config from '@/payload.config'
import { installCampaignFixtures } from '../helpers/campaignFixtures'
let payload: Payload
const campaignFixtures = installCampaignFixtures({
getPayload: () => payload,
setPayload: (nextPayload) => {
payload = nextPayload
},
})
const findDecisions = (municipalityID: number) =>
payload.find({
collection: 'allocationDecision',
where: { municipality: { equals: municipalityID } },
sort: 'createdAt',
depth: 0,
pagination: false,
})
describe('municipality engagement level (E14)', () => {
beforeAll(async () => {
payload = await getPayload({ config: await config })
})
it('records the level and its movement decision in one transaction', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const municipality = await fixtures.getMunicipality()
const updated = await setMunicipalityEngagementLevelRecord(payload, coordinator, {
municipality: municipality.id,
level: 'n3',
note: 'Prefeitura aliada e duas lideranças novas.',
})
fixtures.touchMunicipality(municipality.id)
expect(updated.engagementLevel).toBe('n3')
expect(updated.levelNote).toBe('Prefeitura aliada e duas lideranças novas.')
expect(updated.levelChangedAt).toBeTruthy()
const decisions = await findDecisions(municipality.id)
expect(decisions.docs).toHaveLength(1)
const decision = decisions.docs[0]!
fixtures.own('allocationDecision', decision.id)
expect(decision.patternId).toBe(ENGAGEMENT_LEVEL_PATTERN_ID)
expect(decision.outcome).toBe('movimento')
expect(decision.rationale).toBe('Prefeitura aliada e duas lideranças novas.')
expect(decision.decidedBy).toBe(coordinator.id)
expect(decision.snapshot).toMatchObject({
from: null,
to: 'n3',
violations: [],
overridden: false,
})
expect(decision.snapshot).not.toHaveProperty('reversalSignals')
})
it('records a movement with an empty motivo (B134)', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const municipality = await fixtures.getMunicipality()
const updated = await setMunicipalityEngagementLevelRecord(payload, coordinator, {
municipality: municipality.id,
level: 'n1',
note: null,
})
fixtures.touchMunicipality(municipality.id)
expect(updated.engagementLevel).toBe('n1')
expect(updated.levelNote).toBeNull()
const decisions = await findDecisions(municipality.id)
expect(decisions.docs).toHaveLength(1)
const decision = decisions.docs[0]!
fixtures.own('allocationDecision', decision.id)
expect(decision.rationale).toBe('')
expect(decision.snapshot).toMatchObject({ from: null, to: 'n1' })
expect(decision.snapshot).not.toHaveProperty('reversalSignals')
})
it('holds a movement that breaks the rules and writes nothing', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const municipality = await fixtures.getMunicipality()
await setMunicipalityEngagementLevelRecord(payload, coordinator, {
municipality: municipality.id,
level: 'n1',
note: 'Entrada no mapa como presença de mandato.',
})
fixtures.touchMunicipality(municipality.id)
// Two levels up, no triangulated shock, and the level was recorded seconds
// ago — every rule at once.
const blocked = await setMunicipalityEngagementLevelRecord(payload, coordinator, {
municipality: municipality.id,
level: 'n3',
note: 'Achismo da semana.',
}).catch((error: unknown) => error)
expect(blocked).toBeInstanceOf(EngagementLevelBlockedError)
expect((blocked as EngagementLevelBlockedError).violations.map((v) => v.id)).toContain(
'salto-de-dois-niveis',
)
const unchanged = await payload.findByID({
collection: 'municipality',
id: municipality.id,
depth: 0,
})
expect(unchanged.engagementLevel).toBe('n1')
const decisions = await findDecisions(municipality.id)
expect(decisions.docs).toHaveLength(1)
decisions.docs.forEach((decision) => fixtures.own('allocationDecision', decision.id))
})
it('lets the coordinator override knowingly and records what was overridden', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const municipality = await fixtures.getMunicipality()
await setMunicipalityEngagementLevelRecord(payload, coordinator, {
municipality: municipality.id,
level: 'n1',
note: 'Entrada no mapa.',
})
fixtures.touchMunicipality(municipality.id)
const moved = await setMunicipalityEngagementLevelRecord(payload, coordinator, {
municipality: municipality.id,
level: 'n2',
note: 'Adversário abriu comitê e o prefeito pediu presença.',
override: true,
})
expect(moved.engagementLevel).toBe('n2')
const decisions = await findDecisions(municipality.id)
decisions.docs.forEach((decision) => fixtures.own('allocationDecision', decision.id))
expect(decisions.docs).toHaveLength(2)
const latest = decisions.docs.at(-1)!
expect(latest.snapshot).toMatchObject({
from: 'n1',
to: 'n2',
overridden: true,
// A promotion is not held by the protection window — that rule guards
// against undoing a decision, not against deepening it.
violations: ['dois-movimentos-no-mes'],
})
expect(latest.snapshot).not.toHaveProperty('reversalSignals')
})
it('accepts a two-level jump when a triangulated shock is declared', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const municipality = await fixtures.getMunicipality()
await payload.update({
collection: 'municipality',
id: municipality.id,
data: { engagementLevel: 'n1' },
depth: 0,
})
fixtures.touchMunicipality(municipality.id)
// Push the clock out of both windows so the shock is the only thing at play.
await payload.db.drizzle.execute(
`UPDATE municipality SET level_changed_at = now() - interval '90 days' WHERE id = ${municipality.id}`,
)
const moved = await setMunicipalityEngagementLevelRecord(payload, coordinator, {
municipality: municipality.id,
level: 'n3',
note: 'Três sinais independentes de invasão na mesma semana.',
triangulatedShock: true,
})
expect(moved.engagementLevel).toBe('n3')
const decisions = await findDecisions(municipality.id)
decisions.docs.forEach((decision) => fixtures.own('allocationDecision', decision.id))
expect(decisions.docs).toHaveLength(1)
expect(decisions.docs[0]!.snapshot).toMatchObject({
from: 'n1',
to: 'n3',
triangulatedShock: true,
overridden: false,
})
expect(decisions.docs[0]!.snapshot).not.toHaveProperty('reversalSignals')
})
it('keeps the ladder with unrestricted staff — the advisor proposes, it does not move', async () => {
const fixtures = campaignFixtures()
const advisor = await fixtures.createCampaignUser('advisor')
const municipality = await fixtures.getMunicipality()
await fixtures.assignMunicipalityAdvisors(municipality.id, [advisor.id])
await expect(
setMunicipalityEngagementLevelRecord(payload, advisor, {
municipality: municipality.id,
level: 'n2',
note: 'Quero subir o meu município.',
}),
).rejects.toThrow('coordenação geral ou o candidato')
const decisions = await findDecisions(municipality.id)
expect(decisions.docs).toHaveLength(0)
})
it('hides the level from the leader and shows it to the advisor read-only', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const advisor = await fixtures.createCampaignUser('advisor')
const leaderAccount = await fixtures.createCampaignUser('leader')
const contact = await fixtures.createContact()
const municipality = await fixtures.getMunicipality()
await fixtures.assignMunicipalityAdvisors(municipality.id, [advisor.id])
await fixtures.createLeadership({
contact: contact.id,
municipalities: [municipality.id],
user: leaderAccount.id,
supportStatus: 'engajado',
})
await setMunicipalityEngagementLevelRecord(payload, coordinator, {
municipality: municipality.id,
level: 'n4',
note: 'Prioridade máxima do ciclo.',
})
fixtures.touchMunicipality(municipality.id)
const decisions = await findDecisions(municipality.id)
decisions.docs.forEach((decision) => fixtures.own('allocationDecision', decision.id))
const advisorRead = await payload.findByID({
collection: 'municipality',
id: municipality.id,
depth: 0,
user: advisor,
overrideAccess: false,
})
expect(advisorRead.engagementLevel).toBe('n4')
// The advisor may edit the município, but not this field: Payload drops a
// field the actor cannot write instead of failing the whole update, so the
// proof is that the level did not move.
const advisorAttempt = await payload.update({
collection: 'municipality',
id: municipality.id,
data: { engagementLevel: 'n0', levelNote: 'Assessor tentou mexer.' },
depth: 0,
user: advisor,
overrideAccess: false,
})
expect(advisorAttempt.engagementLevel).toBe('n4')
expect(advisorAttempt.levelNote).toBe('Prioridade máxima do ciclo.')
// The leader cannot read the município at all (lockdown), which is what
// keeps the ladder off their surface.
await expect(
payload.findByID({
collection: 'municipality',
id: municipality.id,
depth: 0,
user: leaderAccount,
overrideAccess: false,
}),
).rejects.toThrow()
})
})