Drop the redundant type column, URL param, and filters while keeping municipality.kind for map and dossier; update tests and delivery docs.
488 lines
19 KiB
TypeScript
488 lines
19 KiB
TypeScript
// @vitest-environment node
|
|
|
|
import type { Payload } from 'payload'
|
|
import { getPayload } from 'payload'
|
|
import { beforeAll, describe, expect, it } from 'vitest'
|
|
|
|
import { getMunicipalityCatalogEntry, municipalityCatalog } from '@/lib/municipalityCatalog'
|
|
import config from '@/payload.config'
|
|
import { loadMunicipalityListPageBundle } from '@/utilities/municipality/municipalityPageData'
|
|
import { computeMunicipalityTerritorialClass } from '@/utilities/municipality/municipalityTerritorialClass'
|
|
import { aggregatePledgesByMunicipality } from '@/utilities/votePledgeData'
|
|
import { rollupMunicipalityStaffVotes } from '@/utilities/votePledgeViews'
|
|
|
|
import { installCampaignFixtures } from '../helpers/campaignFixtures'
|
|
|
|
let payload: Payload
|
|
const campaignFixtures = installCampaignFixtures({
|
|
getPayload: () => payload,
|
|
setPayload: (nextPayload) => {
|
|
payload = nextPayload
|
|
},
|
|
})
|
|
|
|
describe('loadMunicipalityListPageBundle', () => {
|
|
beforeAll(async () => {
|
|
payload = await getPayload({ config: await config })
|
|
})
|
|
|
|
it('keeps a zeroed overview when the filtered municipality set is empty', async () => {
|
|
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
|
|
|
|
const bundle = await loadMunicipalityListPageBundle(payload, coordinator, { q: 'zzznomatch' })
|
|
|
|
// The overview stays on screen next to the empty state instead of unmounting.
|
|
expect(bundle.municipalities).toHaveLength(0)
|
|
expect(bundle.overview).not.toBeNull()
|
|
expect(bundle.overview!.municipalityCount).toBe(0)
|
|
expect(bundle.overview!.staffVoteTotalByScenario.central).toBe(0)
|
|
expect(bundle.overview!.goalCoverageByScenario.central.goal).toBe(0)
|
|
})
|
|
|
|
it('rolls up staffVoteTotal from expectedVotes when no pledge overrides apply', async () => {
|
|
const fixtures = campaignFixtures()
|
|
const coordinator = await fixtures.createCampaignUser('coordinator')
|
|
const municipality = await fixtures.getMunicipality()
|
|
fixtures.touchMunicipality(municipality.id)
|
|
|
|
await payload.update({
|
|
collection: 'municipality',
|
|
id: municipality.id,
|
|
data: { expectedVotes: { pessimistic: null, central: 1_500, optimistic: null } },
|
|
depth: 0,
|
|
overrideAccess: true,
|
|
})
|
|
|
|
const contact = await fixtures.createContact()
|
|
const leadership = await fixtures.createLeadership({
|
|
contact: contact.id,
|
|
municipalities: [municipality.id],
|
|
supportStatus: 'engajado',
|
|
})
|
|
await fixtures.createVotePledge({
|
|
leadership: leadership.id,
|
|
municipality: municipality.id,
|
|
declaredVotes: 80,
|
|
estimatedVotes: { pessimistic: null, central: 120, optimistic: null },
|
|
})
|
|
|
|
const refreshedMunicipality = await payload.findByID({
|
|
collection: 'municipality',
|
|
id: municipality.id,
|
|
depth: 0,
|
|
overrideAccess: true,
|
|
})
|
|
const aggregates = await aggregatePledgesByMunicipality(payload, [municipality.id])
|
|
const rollup = rollupMunicipalityStaffVotes([refreshedMunicipality], aggregates)
|
|
expect(rollup.staffVoteTotal).toBe(1_500)
|
|
|
|
const bundle = await loadMunicipalityListPageBundle(payload, coordinator, {
|
|
q: municipality.name,
|
|
})
|
|
|
|
expect(bundle.overview).not.toBeNull()
|
|
expect(bundle.overview!.staffVoteTotalByScenario.central).toBeGreaterThanOrEqual(1_500)
|
|
const row = bundle.municipalities.find((item) => item.slug === municipality.slug)
|
|
expect(row?.votePosition2022).not.toBeNull()
|
|
expect(row!.votePosition2022!.totalUnits).toBe(435)
|
|
expect(row!.votePosition2022!.rank).toBeGreaterThanOrEqual(1)
|
|
})
|
|
|
|
it('orders the filtered set by 2022 votes when sort=votos', async () => {
|
|
const fixtures = campaignFixtures()
|
|
const coordinator = await fixtures.createCampaignUser('coordinator')
|
|
const first = await fixtures.getMunicipality()
|
|
const second = await fixtures.getMunicipality()
|
|
fixtures.touchMunicipality(first.id)
|
|
fixtures.touchMunicipality(second.id)
|
|
|
|
const marker = `a11-votos-${Date.now()}`
|
|
await payload.update({
|
|
collection: 'municipality',
|
|
id: first.id,
|
|
data: { name: `${marker}-alpha` },
|
|
depth: 0,
|
|
overrideAccess: true,
|
|
})
|
|
await payload.update({
|
|
collection: 'municipality',
|
|
id: second.id,
|
|
data: { name: `${marker}-beta` },
|
|
depth: 0,
|
|
overrideAccess: true,
|
|
})
|
|
|
|
const bundle = await loadMunicipalityListPageBundle(payload, coordinator, {
|
|
q: marker,
|
|
sort: 'votos',
|
|
})
|
|
|
|
expect(bundle.municipalities.length).toBeGreaterThanOrEqual(2)
|
|
const votes = bundle.municipalities.map((row) => row.votePosition2022?.votes ?? 0)
|
|
const nonZero = votes.filter((value) => value > 0)
|
|
for (let index = 1; index < nonZero.length; index += 1) {
|
|
expect(nonZero[index - 1]!).toBeGreaterThanOrEqual(nonZero[index]!)
|
|
}
|
|
// Zeros (if any) must trail the non-zero block.
|
|
const firstZero = votes.findIndex((value) => value === 0)
|
|
if (firstZero >= 0) {
|
|
expect(votes.slice(firstZero).every((value) => value === 0)).toBe(true)
|
|
}
|
|
})
|
|
|
|
/**
|
|
* E9 allocation queue: the default ordering. Uses `expectedVotes` (mesa
|
|
* goals) so the deficits are pinned by the fixture instead of by whatever
|
|
* the 2022 artifact says for the allocated slugs.
|
|
*/
|
|
it('orders by uncovered deficit (default sort), biggest gap first', async () => {
|
|
const fixtures = campaignFixtures()
|
|
const coordinator = await fixtures.createCampaignUser('coordinator')
|
|
const wideGap = await fixtures.getMunicipality()
|
|
const nearlyCovered = await fixtures.getMunicipality()
|
|
|
|
const marker = `e9-deficit-${Date.now()}`
|
|
await payload.update({
|
|
collection: 'municipality',
|
|
id: wideGap.id,
|
|
data: {
|
|
name: `${marker}-wide`,
|
|
expectedVotes: { pessimistic: null, central: 5_000, optimistic: null },
|
|
},
|
|
depth: 0,
|
|
overrideAccess: true,
|
|
})
|
|
fixtures.touchMunicipality(wideGap.id)
|
|
await payload.update({
|
|
collection: 'municipality',
|
|
id: nearlyCovered.id,
|
|
data: {
|
|
name: `${marker}-near`,
|
|
expectedVotes: { pessimistic: null, central: 1_000, optimistic: null },
|
|
},
|
|
depth: 0,
|
|
overrideAccess: true,
|
|
})
|
|
fixtures.touchMunicipality(nearlyCovered.id)
|
|
|
|
const contact = await fixtures.createContact()
|
|
const leadership = await fixtures.createLeadership({
|
|
contact: contact.id,
|
|
municipalities: [nearlyCovered.id],
|
|
supportStatus: 'engajado',
|
|
})
|
|
await fixtures.createVotePledge({
|
|
leadership: leadership.id,
|
|
municipality: nearlyCovered.id,
|
|
declaredVotes: 900,
|
|
estimatedVotes: { pessimistic: null, central: 900, optimistic: null },
|
|
})
|
|
|
|
const descending = await loadMunicipalityListPageBundle(payload, coordinator, { q: marker })
|
|
expect(descending.municipalities.map((row) => row.slug)).toEqual([
|
|
wideGap.slug,
|
|
nearlyCovered.slug,
|
|
])
|
|
expect(descending.municipalities[0]!.goalCoverageByScenario.central.deficit).toBe(5_000)
|
|
expect(descending.municipalities[1]!.goalCoverageByScenario.central.deficit).toBe(100)
|
|
|
|
const ascending = await loadMunicipalityListPageBundle(payload, coordinator, {
|
|
q: marker,
|
|
sort: 'deficit',
|
|
dir: 'asc',
|
|
})
|
|
expect(ascending.municipalities.map((row) => row.slug)).toEqual([
|
|
nearlyCovered.slug,
|
|
wideGap.slug,
|
|
])
|
|
})
|
|
|
|
it('orders by frescor with "never had a signal" ahead of the oldest signal', async () => {
|
|
const fixtures = campaignFixtures()
|
|
const coordinator = await fixtures.createCampaignUser('coordinator')
|
|
const silent = await fixtures.getMunicipality()
|
|
const touched = await fixtures.getMunicipality()
|
|
|
|
const marker = `e9-frescor-${Date.now()}`
|
|
await payload.update({
|
|
collection: 'municipality',
|
|
id: silent.id,
|
|
data: { name: `${marker}-silent`, lastUpdateAt: null },
|
|
depth: 0,
|
|
overrideAccess: true,
|
|
})
|
|
fixtures.touchMunicipality(silent.id)
|
|
await payload.update({
|
|
collection: 'municipality',
|
|
id: touched.id,
|
|
data: { name: `${marker}-touched` },
|
|
depth: 0,
|
|
overrideAccess: true,
|
|
})
|
|
// The collection hook recomputes `lastUpdateAt` from this update.
|
|
await fixtures.createMunicipalityUpdate({
|
|
municipality: touched.id,
|
|
author: coordinator.id,
|
|
})
|
|
|
|
const coldestFirst = await loadMunicipalityListPageBundle(payload, coordinator, {
|
|
q: marker,
|
|
sort: 'frescor',
|
|
})
|
|
expect(coldestFirst.municipalities.map((row) => row.slug)).toEqual([silent.slug, touched.slug])
|
|
expect(coldestFirst.municipalities[0]!.lastSignalAt).toBeNull()
|
|
expect(coldestFirst.municipalities[1]!.lastSignalAt).not.toBeNull()
|
|
|
|
const freshestFirst = await loadMunicipalityListPageBundle(payload, coordinator, {
|
|
q: marker,
|
|
sort: 'frescor',
|
|
dir: 'asc',
|
|
})
|
|
expect(freshestFirst.municipalities.map((row) => row.slug)).toEqual([touched.slug, silent.slug])
|
|
})
|
|
|
|
it('counts a pledge date as a signal even when no staff update exists', async () => {
|
|
const fixtures = campaignFixtures()
|
|
const coordinator = await fixtures.createCampaignUser('coordinator')
|
|
const municipality = await fixtures.getMunicipality()
|
|
fixtures.touchMunicipality(municipality.id)
|
|
|
|
const marker = `e9-pledge-signal-${Date.now()}`
|
|
await payload.update({
|
|
collection: 'municipality',
|
|
id: municipality.id,
|
|
data: { name: marker, lastUpdateAt: null },
|
|
depth: 0,
|
|
overrideAccess: true,
|
|
})
|
|
|
|
const contact = await fixtures.createContact()
|
|
const leadership = await fixtures.createLeadership({
|
|
contact: contact.id,
|
|
municipalities: [municipality.id],
|
|
supportStatus: 'engajado',
|
|
})
|
|
// `declaredAt` is stamped by the collection's own beforeChange hook, so the
|
|
// fixture cannot pin it — read it back and assert the signal matches.
|
|
const pledge = await fixtures.createVotePledge({
|
|
leadership: leadership.id,
|
|
municipality: municipality.id,
|
|
declaredVotes: 50,
|
|
})
|
|
expect(pledge.declaredAt).toBeTruthy()
|
|
|
|
const bundle = await loadMunicipalityListPageBundle(payload, coordinator, { q: marker })
|
|
const row = bundle.municipalities.find((item) => item.slug === municipality.slug)
|
|
expect(row?.lastUpdateAt).toBeNull()
|
|
expect(row?.lastSignalAt).toBe(pledge.declaredAt)
|
|
expect(row?.pledges.lastPledgeAt).toBe(pledge.declaredAt)
|
|
})
|
|
|
|
/**
|
|
* E10: the class is derived from the committed TSE artifact, so the filter
|
|
* cannot be a Payload constraint. The bundle must still agree with itself —
|
|
* rows, `totalDocs` and the overview all counting the filtered scope.
|
|
*/
|
|
it('filters by the derived territorial class and keeps the overview honest', async () => {
|
|
const fixtures = campaignFixtures()
|
|
const coordinator = await fixtures.createCampaignUser('coordinator')
|
|
|
|
const redutoEntry = municipalityCatalog.find(
|
|
(entry) => computeMunicipalityTerritorialClass(entry.slug).class === 'reduto',
|
|
)!
|
|
const marginalEntry = municipalityCatalog.find(
|
|
(entry) => computeMunicipalityTerritorialClass(entry.slug).class === 'marginal',
|
|
)!
|
|
const reduto = await fixtures.getMunicipality(redutoEntry.slug)
|
|
const marginal = await fixtures.getMunicipality(marginalEntry.slug)
|
|
|
|
const marker = `e10-classe-${Date.now()}`
|
|
for (const [municipality, suffix] of [
|
|
[reduto, 'reduto'],
|
|
[marginal, 'marginal'],
|
|
] as const) {
|
|
await payload.update({
|
|
collection: 'municipality',
|
|
id: municipality.id,
|
|
data: { name: `${marker}-${suffix}` },
|
|
depth: 0,
|
|
overrideAccess: true,
|
|
})
|
|
fixtures.touchMunicipality(municipality.id)
|
|
}
|
|
|
|
const filtered = await loadMunicipalityListPageBundle(payload, coordinator, {
|
|
q: marker,
|
|
class: 'reduto',
|
|
})
|
|
expect(filtered.municipalities.map((row) => row.slug)).toEqual([reduto.slug])
|
|
expect(filtered.totalDocs).toBe(1)
|
|
expect(filtered.overview?.municipalityCount).toBe(1)
|
|
expect(filtered.municipalities[0]!.territorialClass).toBe('reduto')
|
|
expect(filtered.municipalities[0]!.territorialClassFactors.length).toBeGreaterThan(0)
|
|
|
|
// A native sort key would otherwise let Payload paginate before the class
|
|
// filter runs, which would hand back a short page with an inflated total.
|
|
const byName = await loadMunicipalityListPageBundle(payload, coordinator, {
|
|
q: marker,
|
|
class: 'reduto',
|
|
sort: 'name',
|
|
})
|
|
expect(byName.municipalities.map((row) => row.slug)).toEqual([reduto.slug])
|
|
expect(byName.totalDocs).toBe(1)
|
|
|
|
const sorted = await loadMunicipalityListPageBundle(payload, coordinator, {
|
|
q: marker,
|
|
sort: 'classe',
|
|
})
|
|
expect(sorted.municipalities.map((row) => row.slug)).toEqual([reduto.slug, marginal.slug])
|
|
})
|
|
|
|
/**
|
|
* E14: `nivel` is a stored column, so ordering is Postgres's. With no
|
|
* backfill almost every município is null, and where the nulls land decides
|
|
* what page 1 of "N4 primeiro" actually shows — so it is pinned rather than
|
|
* inferred from the label.
|
|
*/
|
|
it('sorts by nível with the un-levelled municípios last in both directions', async () => {
|
|
const fixtures = campaignFixtures()
|
|
const coordinator = await fixtures.createCampaignUser('coordinator')
|
|
|
|
const low = await fixtures.getMunicipality(municipalityCatalog[0]!.slug)
|
|
const high = await fixtures.getMunicipality(municipalityCatalog[1]!.slug)
|
|
const none = await fixtures.getMunicipality(municipalityCatalog[2]!.slug)
|
|
|
|
const marker = `e14-nivel-${Date.now()}`
|
|
for (const [municipality, suffix, engagementLevel] of [
|
|
[low, 'n0', 'n0'],
|
|
[high, 'n4', 'n4'],
|
|
[none, 'sem', null],
|
|
] as const) {
|
|
await payload.update({
|
|
collection: 'municipality',
|
|
id: municipality.id,
|
|
data: { name: `${marker}-${suffix}`, engagementLevel },
|
|
depth: 0,
|
|
overrideAccess: true,
|
|
})
|
|
fixtures.touchMunicipality(municipality.id)
|
|
}
|
|
|
|
const descending = await loadMunicipalityListPageBundle(payload, coordinator, {
|
|
q: marker,
|
|
sort: 'nivel',
|
|
dir: 'desc',
|
|
})
|
|
expect(descending.municipalities.map((row) => row.engagementLevel)).toEqual(['n4', 'n0', null])
|
|
|
|
const ascending = await loadMunicipalityListPageBundle(payload, coordinator, {
|
|
q: marker,
|
|
sort: 'nivel',
|
|
dir: 'asc',
|
|
})
|
|
expect(ascending.municipalities.map((row) => row.engagementLevel)).toEqual(['n0', 'n4', null])
|
|
})
|
|
|
|
it('keeps advisor access and applies URL filters on top', async () => {
|
|
const fixtures = campaignFixtures()
|
|
const advisor = await fixtures.createCampaignUser('advisor')
|
|
const administered = await fixtures.getMunicipality()
|
|
await fixtures.assignMunicipalityAdvisors(administered.id, [advisor.id])
|
|
|
|
const included = await loadMunicipalityListPageBundle(payload, advisor, {
|
|
q: administered.name,
|
|
})
|
|
expect(included.overview).not.toBeNull()
|
|
expect(included.municipalities.some((row) => row.slug === administered.slug)).toBe(true)
|
|
|
|
const outsideSlug = municipalityCatalog.find((entry) => entry.slug !== administered.slug)!.slug
|
|
|
|
const excluded = await loadMunicipalityListPageBundle(payload, advisor, {
|
|
slug: [outsideSlug],
|
|
})
|
|
expect(excluded.overview?.municipalityCount).toBe(0)
|
|
expect(excluded.municipalities).toHaveLength(0)
|
|
})
|
|
|
|
it('cross-filters column-filter options but never narrows a filter by itself', async () => {
|
|
const fixtures = campaignFixtures()
|
|
const coordinator = await fixtures.createCampaignUser('coordinator')
|
|
const municipality = await fixtures.getMunicipality()
|
|
const catalogEntry = getMunicipalityCatalogEntry(municipality.slug)
|
|
expect(catalogEntry).toBeDefined()
|
|
const outsideRegion = municipalityCatalog.find(
|
|
(entry) => entry.region !== catalogEntry!.region,
|
|
)!
|
|
|
|
const bundle = await loadMunicipalityListPageBundle(payload, coordinator, {
|
|
region: catalogEntry!.region,
|
|
})
|
|
|
|
// The território filter narrows the Município options…
|
|
expect(bundle.filterFacets.slugs).toContain(municipality.slug)
|
|
expect(bundle.filterFacets.slugs).not.toContain(outsideRegion.slug)
|
|
expect(bundle.filterFacets.slugs.length).toBeLessThan(municipalityCatalog.length)
|
|
// …but not its own: the OR set must stay addable.
|
|
expect(bundle.filterFacets.regions.length).toBeGreaterThan(1)
|
|
|
|
// A selected value stays listed even when other filters exclude it, so it
|
|
// can be undone from the popover.
|
|
const conflicting = await loadMunicipalityListPageBundle(payload, coordinator, {
|
|
region: catalogEntry!.region,
|
|
slug: outsideRegion.slug,
|
|
})
|
|
expect(conflicting.municipalities).toHaveLength(0)
|
|
expect(conflicting.filterFacets.slugs).toContain(outsideRegion.slug)
|
|
})
|
|
|
|
it('keeps the options of filters that share a popover with the applied one', async () => {
|
|
const fixtures = campaignFixtures()
|
|
const coordinator = await fixtures.createCampaignUser('coordinator')
|
|
const advisor = await fixtures.createCampaignUser('advisor')
|
|
const priorityMunicipality = await fixtures.getMunicipality()
|
|
await payload.update({
|
|
collection: 'municipality',
|
|
id: priorityMunicipality.id,
|
|
data: { priority: 'alta' },
|
|
overrideAccess: true,
|
|
})
|
|
const covered = await fixtures.getMunicipality(
|
|
municipalityCatalog.find((entry) => entry.slug !== priorityMunicipality.slug)!.slug,
|
|
)
|
|
await fixtures.assignMunicipalityAdvisors(covered.id, [advisor.id])
|
|
|
|
// "Prioritária" lives in the Município popover, so it must not hide the
|
|
// municipalities that popover is there to offer.
|
|
const byPriority = await loadMunicipalityListPageBundle(payload, coordinator, {
|
|
priority: 'alta',
|
|
})
|
|
expect(byPriority.municipalities.every((row) => row.priority === 'alta')).toBe(true)
|
|
expect(byPriority.filterFacets.slugs).toContain(covered.slug)
|
|
|
|
// Same for the "Sem assessor" toggle inside the Assessores popover: it would
|
|
// otherwise leave that popover with nobody to pick.
|
|
const withoutAdvisor = await loadMunicipalityListPageBundle(payload, coordinator, {
|
|
coverage: 'sem_assessor',
|
|
})
|
|
expect(withoutAdvisor.filterFacets.advisorIDs).toContain(advisor.id)
|
|
})
|
|
|
|
it('returns an empty bundle for leaders (municipality list lockdown)', async () => {
|
|
const fixtures = campaignFixtures()
|
|
const municipality = await fixtures.getMunicipality()
|
|
const leader = await fixtures.createCampaignUser('leader')
|
|
const contact = await fixtures.createContact()
|
|
await fixtures.createLeadership({
|
|
contact: contact.id,
|
|
municipalities: [municipality.id],
|
|
user: leader.id,
|
|
supportStatus: 'engajado',
|
|
})
|
|
|
|
const bundle = await loadMunicipalityListPageBundle(payload, leader, { q: municipality.name })
|
|
|
|
expect(bundle.overview).toBeNull()
|
|
expect(bundle.municipalities).toHaveLength(0)
|
|
expect(bundle.scopeTotal).toBe(0)
|
|
})
|
|
})
|