teqo-backup/tests/int/campaignMunicipalityAccess.int.spec.ts
Francisco Ignácio Fontoura Solla 77023db99d B34+: scale and DRY after municipality chip lists.
Memoize access reads per req, cache portfolio index, trim chip revalidates, and shrink RSC payload via catalog-backed labels.
2026-07-28 19:49:29 -03:00

363 lines
13 KiB
TypeScript

// @vitest-environment node
import type { Payload, PayloadRequest } from 'payload'
import { getPayload } from 'payload'
import { beforeAll, describe, expect, it } from 'vitest'
import {
assignMunicipalityAdvisorsRecord,
setMunicipalityExpectedVotesRecord,
setMunicipalityPoliticalTrendRecord,
updateMunicipalityStrategyRecord,
} from '@/app/(campaign)/campanha/actions/municipality'
import { municipalityCatalog } from '@/lib/municipalityCatalog'
import config from '@/payload.config'
import { getAccessibleMunicipalityIds } from '@/utilities/campaignAccess'
import { installCampaignFixtures, relationIds } from '../helpers/campaignFixtures'
import { stub } from '../helpers/stub'
let payload: Payload
const campaignFixtures = installCampaignFixtures({
getPayload: () => payload,
setPayload: (nextPayload) => {
payload = nextPayload
},
})
describe('municipality catalog seed and per-role access', () => {
beforeAll(async () => {
payload = await getPayload({ config: await config })
})
it('has all 435 predefined municipalities seeded by the migration', async () => {
const total = await payload.count({ collection: 'municipality', where: {} })
expect(total.totalDocs).toBe(municipalityCatalog.length)
expect(total.totalDocs).toBe(435)
const salvadorZones = await payload.count({
collection: 'municipality',
where: { and: [{ city: { equals: 'Salvador' } }, { kind: { equals: 'zona' } }] },
})
expect(salvadorZones.totalDocs).toBe(19)
})
it('agrees with the static catalog on every slug and território', async () => {
// The chip cells ship `{ id, slug }` and resolve name/região from the static
// catalog, so a seeded row the catalog does not know would render no chip at
// all. Only the count was pinned before; this is the agreement that swap needs.
//
// `name` is deliberately not asserted: it is a system field in production,
// but `municipalityPageData.int.spec.ts` renames three rows to search for
// them and never restores them, so the shared test database diverges by
// design. Slug is the identity fixtures claim rows by, and it never moves.
const seeded = await payload.find({
collection: 'municipality',
depth: 0,
limit: 0,
pagination: false,
select: { slug: true, region: true },
})
const bySlug = new Map(seeded.docs.map((municipality) => [municipality.slug, municipality]))
expect(bySlug.size).toBe(municipalityCatalog.length)
for (const entry of municipalityCatalog) {
const row = bySlug.get(entry.slug)
expect(row, entry.slug).toBeDefined()
expect(row?.region, entry.slug).toBe(entry.region)
}
})
it('gives the coordinator unrestricted municipality reads', async () => {
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
const result = await payload.count({
collection: 'municipality',
where: {},
user: coordinator,
overrideAccess: false,
})
expect(result.totalDocs).toBe(435)
})
it('gives the candidate unrestricted municipality reads', async () => {
const candidate = await campaignFixtures().createCampaignUser('candidate')
const result = await payload.count({
collection: 'municipality',
where: {},
user: candidate,
overrideAccess: false,
})
expect(result.totalDocs).toBe(435)
})
it('scopes an advisor to the municipalities they administer — including the map scope helper', async () => {
const fixtures = campaignFixtures()
const advisor = await fixtures.createCampaignUser('advisor')
const administered = await fixtures.getMunicipality()
await fixtures.assignMunicipalityAdvisors(administered.id, [advisor.id])
const visible = await payload.find({
collection: 'municipality',
where: {},
depth: 0,
pagination: false,
select: { name: true },
user: advisor,
overrideAccess: false,
})
expect(visible.docs.map((municipality) => municipality.id)).toEqual([administered.id])
})
it('denies leaders read access to municipalities (lockdown)', async () => {
const fixtures = campaignFixtures()
const account = await fixtures.createCampaignUser('leader')
const contact = await fixtures.createContact()
const linked = await fixtures.getMunicipality()
await fixtures.createLeadership({
contact: contact.id,
municipalities: [linked.id],
user: account.id,
supportStatus: 'engajado',
})
await expect(
payload.count({
collection: 'municipality',
where: {},
user: account,
overrideAccess: false,
}),
).rejects.toThrow(/permissão/i)
})
it('still exposes linked municipality IDs to leaders for supporter registration scope', async () => {
const fixtures = campaignFixtures()
const account = await fixtures.createCampaignUser('leader')
const contact = await fixtures.createContact()
const linked = await fixtures.getMunicipality()
const otherLinked = await fixtures.getMunicipality()
await fixtures.createLeadership({
contact: contact.id,
municipalities: [linked.id, otherLinked.id],
user: account.id,
supportStatus: 'engajado',
})
const visible = await getAccessibleMunicipalityIds(
stub<PayloadRequest>({ payload, user: account, context: {} }),
account,
)
expect(visible?.sort((a, b) => a - b)).toEqual(
[linked.id, otherLinked.id].sort((a, b) => a - b),
)
})
it('clears leader municipality scope when the leadership is no longer engaged', async () => {
const fixtures = campaignFixtures()
const account = await fixtures.createCampaignUser('leader')
const contact = await fixtures.createContact()
const linked = await fixtures.getMunicipality()
const leadership = await fixtures.createLeadership({
contact: contact.id,
municipalities: [linked.id],
user: account.id,
supportStatus: 'engajado',
})
await payload.update({
collection: 'leadership',
id: leadership.id,
data: { supportStatus: 'em_disputa' },
depth: 0,
})
const scope = await getAccessibleMunicipalityIds(
stub<PayloadRequest>({ payload, user: account, context: {} }),
account,
)
expect(scope).toEqual([])
})
it('blocks leaders from reading municipality strategy fields', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const account = await fixtures.createCampaignUser('leader')
const contact = await fixtures.createContact()
const municipality = await fixtures.getMunicipality()
await fixtures.createLeadership({
contact: contact.id,
municipalities: [municipality.id],
user: account.id,
supportStatus: 'engajado',
})
await updateMunicipalityStrategyRecord(payload, coordinator, {
municipality: municipality.id,
priority: 'alta',
strengths: ['Base sindical forte'],
dobradinhaNotes: 'Negociação com deputado estadual em curso.',
})
await setMunicipalityPoliticalTrendRecord(payload, coordinator, {
municipality: municipality.id,
status: 'desfavoravel',
note: 'Prefeito rompeu com a chapa.',
})
await setMunicipalityExpectedVotesRecord(payload, coordinator, {
municipality: municipality.id,
expectedVotes: { pessimistic: null, central: 1200, optimistic: null },
})
fixtures.touchMunicipality(municipality.id)
await expect(
payload.findByID({
collection: 'municipality',
id: municipality.id,
depth: 0,
user: account,
overrideAccess: false,
}),
).rejects.toThrow()
const staffRead = await payload.findByID({
collection: 'municipality',
id: municipality.id,
depth: 0,
user: coordinator,
overrideAccess: false,
})
expect(staffRead.priority).toBe('alta')
expect(staffRead.expectedVotes?.central).toBe(1200)
expect(staffRead.politicalTrend?.status).toBe('desfavoravel')
expect(staffRead.politicalTrend?.recordedBy).toBeTruthy()
expect(staffRead.politicalTrend?.recordedAt).toBeTruthy()
})
it('lets an advisor edit strategy only on administered municipalities', async () => {
const fixtures = campaignFixtures()
const advisor = await fixtures.createCampaignUser('advisor')
const administered = await fixtures.getMunicipality()
const outside = await fixtures.getMunicipality()
await fixtures.assignMunicipalityAdvisors(administered.id, [advisor.id])
const updated = await updateMunicipalityStrategyRecord(payload, advisor, {
municipality: administered.id,
priority: 'alta',
nextSteps: 'Agendar reunião com a executiva municipal.',
budgetNotes: 'Emenda de R$ 2 mi para saúde (2025) empenhada.',
})
expect(updated.priority).toBe('alta')
expect(updated.nextSteps).toBe('Agendar reunião com a executiva municipal.')
expect(updated.budgetNotes).toBe('Emenda de R$ 2 mi para saúde (2025) empenhada.')
fixtures.touchMunicipality(administered.id)
await expect(
updateMunicipalityStrategyRecord(payload, advisor, {
municipality: outside.id,
priority: 'alta',
}),
).rejects.toThrow()
})
it('restricts advisor assignment to unrestricted staff and validates roles', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const advisor = await fixtures.createCampaignUser('advisor')
const leaderAccount = await fixtures.createCampaignUser('leader')
const municipality = await fixtures.getMunicipality()
await expect(
assignMunicipalityAdvisorsRecord(payload, advisor, {
municipality: municipality.id,
advisors: [advisor.id],
}),
).rejects.toThrow('coordenação geral ou o candidato')
await expect(
assignMunicipalityAdvisorsRecord(payload, coordinator, {
municipality: municipality.id,
advisors: [leaderAccount.id],
}),
).rejects.toThrow()
// The candidate is eligible as both assignee and assigner (B19 + E4R).
const candidate = await fixtures.createCampaignUser('candidate')
const assigned = await assignMunicipalityAdvisorsRecord(payload, candidate, {
municipality: municipality.id,
advisors: [advisor.id, candidate.id],
})
fixtures.touchMunicipality(municipality.id)
expect(relationIds(assigned.advisors)).toEqual([advisor.id, candidate.id])
const advisorOnly = await assignMunicipalityAdvisorsRecord(payload, coordinator, {
municipality: municipality.id,
advisors: [advisor.id],
})
expect(relationIds(advisorOnly.advisors)).toEqual([advisor.id])
const scope = await getAccessibleMunicipalityIds(
stub<PayloadRequest>({ payload, user: advisor, context: {} }),
advisor,
)
expect(scope).toEqual([municipality.id])
})
it('lets staff set expectedVotes with role and municipality scope enforced', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const advisor = await fixtures.createCampaignUser('advisor')
const administered = await fixtures.getMunicipality()
const outside = await fixtures.getMunicipality()
await fixtures.assignMunicipalityAdvisors(administered.id, [advisor.id])
const updated = await setMunicipalityExpectedVotesRecord(payload, coordinator, {
municipality: administered.id,
expectedVotes: { pessimistic: null, central: 2500, optimistic: null },
})
expect(updated.expectedVotes?.central).toBe(2500)
fixtures.touchMunicipality(administered.id)
const advisorUpdated = await setMunicipalityExpectedVotesRecord(payload, advisor, {
municipality: administered.id,
expectedVotes: { pessimistic: null, central: 3000, optimistic: null },
})
expect(advisorUpdated.expectedVotes?.central).toBe(3000)
fixtures.touchMunicipality(administered.id)
await expect(
setMunicipalityExpectedVotesRecord(payload, advisor, {
municipality: outside.id,
expectedVotes: { pessimistic: null, central: 100, optimistic: null },
}),
).rejects.toThrow()
const cleared = await setMunicipalityExpectedVotesRecord(payload, coordinator, {
municipality: administered.id,
expectedVotes: { pessimistic: null, central: null, optimistic: null },
})
expect(cleared.expectedVotes?.central ?? null).toBeNull()
fixtures.touchMunicipality(administered.id)
})
it('lets staff set and clear political trend status', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const municipality = await fixtures.getMunicipality()
const updated = await setMunicipalityPoliticalTrendRecord(payload, coordinator, {
municipality: municipality.id,
status: 'favoravel',
note: null,
})
expect(updated.politicalTrend?.status).toBe('favoravel')
fixtures.touchMunicipality(municipality.id)
const cleared = await setMunicipalityPoliticalTrendRecord(payload, coordinator, {
municipality: municipality.id,
status: null,
note: null,
})
expect(cleared.politicalTrend?.status).toBeNull()
fixtures.touchMunicipality(municipality.id)
})
})