mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-15 18:53:06 +00:00
The "Municípios" column of /campanha/dobradinhas reuses MunicipalityPortfolioCell unchanged, writing the inverted side of the relation (municipality.stateDeputies) through a single transactional batch action (setStateDeputyMunicipalitiesBatch): per-município advisory lock, all-or-nothing on a partial-scope batch instead of a silent partial apply, no floor (a dobradinha can hold zero municípios). stateDeputiesArraySchema's cap moved 20 → 435 to fit a whole território batch from the município side. The post-implementation critique found two P1s, and both were fixed instead of deferred. First: MunicipalityPortfolioCell and LeadershipStateDeputyRelationCell had grown the same chip-editing machine twice — optimistic delta, undo, ARIA 1.2 combobox, floor/ceiling, pointer-fine/coarse split — with incompatible contracts. This was the 3rd call site B34+ was waiting for, so RelationChipCell is now the single engine and both cells shrank to thin domain wrappers (chip/ search building, FormData, copy — nothing interaction-specific left in either). LeadershipStateDeputyRelationCell also gained an ownerName prop and its first dedicated test suite, which it shipped without before this. Second: an advisor could see, and attempt to remove, a município chip outside the portfolio they administer, failing silently at the document level. MunicipalityPortfolioCell now filters the visible chips by addableIds instead of only gating what can be added, so an advisor sees and can only touch municípios they administer. No migration, collection, or Consent. Docs updated: plan as-built, roadmap, AGENTS.md, project notebook.
478 lines
17 KiB
TypeScript
478 lines
17 KiB
TypeScript
// @vitest-environment node
|
|
|
|
/**
|
|
* Thin smokes for the five entity list loaders ahead of the Pass 2 list-system
|
|
* consolidation (W1): scope (role access), marker-narrowed search and the
|
|
* pagination shape every list page consumes ({ rows, totalDocs, totalPages }).
|
|
*/
|
|
import type { Payload } from 'payload'
|
|
import { getPayload } from 'payload'
|
|
import { beforeAll, describe, expect, it } from 'vitest'
|
|
|
|
import config from '@/payload.config'
|
|
import { loadAdvisorListPageData } from '@/utilities/advisorData'
|
|
import { loadDemandListPageData } from '@/utilities/campaignDemandData'
|
|
import { loadLeadershipListPageData } from '@/utilities/leadershipData'
|
|
import { loadOrganizationListPageData } from '@/utilities/organizationData'
|
|
import { loadStateDeputyListPageData } from '@/utilities/stateDeputyData'
|
|
import { NO_PARTY_FILTER_VALUE } from '@/utilities/stateDeputyListUrl'
|
|
|
|
import { installCampaignFixtures } from '../helpers/campaignFixtures'
|
|
|
|
let payload: Payload
|
|
const campaignFixtures = installCampaignFixtures({
|
|
getPayload: () => payload,
|
|
setPayload: (nextPayload) => {
|
|
payload = nextPayload
|
|
},
|
|
})
|
|
|
|
beforeAll(async () => {
|
|
payload = await getPayload({ config: await config })
|
|
})
|
|
|
|
describe('loadLeadershipListPageData', () => {
|
|
it('returns marker-scoped rows with municipality ids for staff', async () => {
|
|
const fixtures = campaignFixtures()
|
|
const coordinator = await fixtures.createCampaignUser('coordinator')
|
|
const municipality = await fixtures.getMunicipality()
|
|
const contact = await fixtures.createContact()
|
|
await fixtures.createLeadership({ contact: contact.id, municipalities: [municipality.id] })
|
|
|
|
const result = await loadLeadershipListPageData(payload, coordinator, {
|
|
page: 1,
|
|
q: contact.name,
|
|
})
|
|
|
|
expect(result.totalDocs).toBe(1)
|
|
expect(result.totalPages).toBe(1)
|
|
expect(result.rows).toHaveLength(1)
|
|
expect(result.rows[0]).toMatchObject({
|
|
name: contact.name,
|
|
municipalityIDs: [municipality.id],
|
|
hasAppAccess: false,
|
|
})
|
|
expect(result.filterFacets.municipalityIDs).toContain(municipality.id)
|
|
})
|
|
|
|
it('short-circuits to an empty page when no contact matches the query', async () => {
|
|
const fixtures = campaignFixtures()
|
|
const coordinator = await fixtures.createCampaignUser('coordinator')
|
|
|
|
const result = await loadLeadershipListPageData(payload, coordinator, {
|
|
page: 1,
|
|
q: fixtures.value('sem-resultado'),
|
|
})
|
|
|
|
expect(result).toEqual({
|
|
rows: [],
|
|
totalDocs: 0,
|
|
totalPages: 0,
|
|
filterFacets: { municipalityIDs: [] },
|
|
})
|
|
})
|
|
|
|
it('returns an empty page for leaders (staff guard, no throw)', async () => {
|
|
const fixtures = campaignFixtures()
|
|
const leader = await fixtures.createCampaignUser('leader')
|
|
|
|
const result = await loadLeadershipListPageData(payload, leader, { page: 1 })
|
|
|
|
expect(result).toEqual({
|
|
rows: [],
|
|
totalDocs: 0,
|
|
totalPages: 0,
|
|
filterFacets: { municipalityIDs: [] },
|
|
})
|
|
})
|
|
|
|
it('filters by supportStatus, sector, access and municipality', async () => {
|
|
const fixtures = campaignFixtures()
|
|
const coordinator = await fixtures.createCampaignUser('coordinator')
|
|
const municipalityA = await fixtures.getMunicipality()
|
|
const municipalityB = await fixtures.getMunicipality()
|
|
const marker = fixtures.value('lead-filter')
|
|
|
|
const matchContact = await fixtures.createContact({ name: `Ana ${marker}` })
|
|
const missContact = await fixtures.createContact({ name: `Bruno ${marker}` })
|
|
const match = await fixtures.createLeadership({
|
|
contact: matchContact.id,
|
|
municipalities: [municipalityA.id],
|
|
supportStatus: 'a_abordar',
|
|
sector: 'religioso',
|
|
})
|
|
await fixtures.createLeadership({
|
|
contact: missContact.id,
|
|
municipalities: [municipalityB.id],
|
|
supportStatus: 'engajado',
|
|
sector: 'sindical',
|
|
})
|
|
|
|
const byStatus = await loadLeadershipListPageData(payload, coordinator, {
|
|
page: 1,
|
|
q: marker,
|
|
statuses: ['a_abordar'],
|
|
})
|
|
expect(byStatus.rows.map((row) => row.id)).toEqual([match.id])
|
|
|
|
const bySector = await loadLeadershipListPageData(payload, coordinator, {
|
|
page: 1,
|
|
q: marker,
|
|
sectors: ['religioso'],
|
|
})
|
|
expect(bySector.rows.map((row) => row.id)).toEqual([match.id])
|
|
|
|
const byMunicipality = await loadLeadershipListPageData(payload, coordinator, {
|
|
page: 1,
|
|
q: marker,
|
|
municipalities: [municipalityA.id],
|
|
})
|
|
expect(byMunicipality.rows.map((row) => row.id)).toEqual([match.id])
|
|
|
|
const withoutAccess = await loadLeadershipListPageData(payload, coordinator, {
|
|
page: 1,
|
|
q: marker,
|
|
access: 'sem',
|
|
})
|
|
expect(withoutAccess.totalDocs).toBe(2)
|
|
expect(withoutAccess.rows.every((row) => !row.hasAppAccess)).toBe(true)
|
|
|
|
const withAccess = await loadLeadershipListPageData(payload, coordinator, {
|
|
page: 1,
|
|
q: marker,
|
|
access: 'com',
|
|
})
|
|
expect(withAccess.totalDocs).toBe(0)
|
|
})
|
|
|
|
it('sorts by contact.name in both directions via the dotted join path', async () => {
|
|
const fixtures = campaignFixtures()
|
|
const coordinator = await fixtures.createCampaignUser('coordinator')
|
|
const municipality = await fixtures.getMunicipality()
|
|
const marker = fixtures.value('lead-sort')
|
|
const alpha = await fixtures.createContact({ name: `Aaa ${marker}` })
|
|
const omega = await fixtures.createContact({ name: `Zzz ${marker}` })
|
|
const first = await fixtures.createLeadership({
|
|
contact: alpha.id,
|
|
municipalities: [municipality.id],
|
|
})
|
|
const last = await fixtures.createLeadership({
|
|
contact: omega.id,
|
|
municipalities: [municipality.id],
|
|
})
|
|
|
|
const asc = await loadLeadershipListPageData(payload, coordinator, {
|
|
page: 1,
|
|
q: marker,
|
|
sort: 'name',
|
|
dir: 'asc',
|
|
})
|
|
expect(asc.rows.map((row) => row.id)).toEqual([first.id, last.id])
|
|
|
|
const desc = await loadLeadershipListPageData(payload, coordinator, {
|
|
page: 1,
|
|
q: marker,
|
|
sort: 'name',
|
|
dir: 'desc',
|
|
})
|
|
expect(desc.rows.map((row) => row.id)).toEqual([last.id, first.id])
|
|
})
|
|
|
|
it('keeps the municipality facet reachable under other filters and unions the selection', async () => {
|
|
const fixtures = campaignFixtures()
|
|
const coordinator = await fixtures.createCampaignUser('coordinator')
|
|
const municipalityA = await fixtures.getMunicipality()
|
|
const municipalityB = await fixtures.getMunicipality()
|
|
const marker = fixtures.value('lead-facet')
|
|
await fixtures.createLeadership({
|
|
contact: (await fixtures.createContact({ name: `A ${marker}` })).id,
|
|
municipalities: [municipalityA.id],
|
|
supportStatus: 'a_abordar',
|
|
})
|
|
await fixtures.createLeadership({
|
|
contact: (await fixtures.createContact({ name: `B ${marker}` })).id,
|
|
municipalities: [municipalityB.id],
|
|
supportStatus: 'engajado',
|
|
})
|
|
|
|
const unfiltered = await loadLeadershipListPageData(payload, coordinator, {
|
|
page: 1,
|
|
q: marker,
|
|
})
|
|
expect(unfiltered.filterFacets.municipalityIDs).toEqual(
|
|
expect.arrayContaining([municipalityA.id, municipalityB.id]),
|
|
)
|
|
|
|
const filtered = await loadLeadershipListPageData(payload, coordinator, {
|
|
page: 1,
|
|
q: marker,
|
|
statuses: ['a_abordar'],
|
|
municipalities: [municipalityB.id],
|
|
})
|
|
// Own filter ignored for the facet — A stays; B stays because it is selected.
|
|
expect(filtered.filterFacets.municipalityIDs).toEqual(
|
|
expect.arrayContaining([municipalityA.id, municipalityB.id]),
|
|
)
|
|
})
|
|
|
|
it('does not leak out-of-scope leaderships to an advisor even with a foreign municipality filter', async () => {
|
|
const fixtures = campaignFixtures()
|
|
const advisor = await fixtures.createCampaignUser('advisor')
|
|
const inScope = await fixtures.getMunicipality()
|
|
const outOfScope = await fixtures.getMunicipality()
|
|
await fixtures.assignMunicipalityAdvisors(inScope.id, [advisor.id])
|
|
|
|
const marker = fixtures.value('lead-scope')
|
|
const visible = await fixtures.createLeadership({
|
|
contact: (await fixtures.createContact({ name: `In ${marker}` })).id,
|
|
municipalities: [inScope.id],
|
|
})
|
|
await fixtures.createLeadership({
|
|
contact: (await fixtures.createContact({ name: `Out ${marker}` })).id,
|
|
municipalities: [outOfScope.id],
|
|
})
|
|
|
|
const foreignFilter = await loadLeadershipListPageData(payload, advisor, {
|
|
page: 1,
|
|
q: marker,
|
|
municipalities: [outOfScope.id],
|
|
})
|
|
expect(foreignFilter.totalDocs).toBe(0)
|
|
expect(foreignFilter.rows).toEqual([])
|
|
|
|
const scoped = await loadLeadershipListPageData(payload, advisor, {
|
|
page: 1,
|
|
q: marker,
|
|
})
|
|
expect(scoped.rows.map((row) => row.id)).toEqual([visible.id])
|
|
})
|
|
})
|
|
|
|
describe('loadOrganizationListPageData', () => {
|
|
it('narrows by q and kind and counts linked leaderships', async () => {
|
|
const fixtures = campaignFixtures()
|
|
const coordinator = await fixtures.createCampaignUser('coordinator')
|
|
const municipality = await fixtures.getMunicipality()
|
|
const organization = await fixtures.createOrganization({
|
|
kind: 'associacao',
|
|
municipalities: [municipality.id],
|
|
})
|
|
const contact = await fixtures.createContact()
|
|
await fixtures.createLeadership({
|
|
contact: contact.id,
|
|
municipalities: [municipality.id],
|
|
organizations: [organization.id],
|
|
})
|
|
|
|
const match = await loadOrganizationListPageData(payload, coordinator, {
|
|
page: 1,
|
|
q: organization.name,
|
|
kind: 'associacao',
|
|
})
|
|
expect(match.totalDocs).toBe(1)
|
|
expect(match.rows[0]).toMatchObject({
|
|
slug: organization.slug,
|
|
kind: 'associacao',
|
|
municipalityNames: [municipality.name],
|
|
leadershipCount: 1,
|
|
})
|
|
|
|
const kindMiss = await loadOrganizationListPageData(payload, coordinator, {
|
|
page: 1,
|
|
q: organization.name,
|
|
kind: 'sindicato',
|
|
})
|
|
expect(kindMiss.totalDocs).toBe(0)
|
|
expect(kindMiss.rows).toEqual([])
|
|
})
|
|
})
|
|
|
|
describe('loadStateDeputyListPageData', () => {
|
|
it('narrows by q with zeroed relation counts for a fresh deputy', async () => {
|
|
const fixtures = campaignFixtures()
|
|
const coordinator = await fixtures.createCampaignUser('coordinator')
|
|
const stateDeputy = await fixtures.createStateDeputy({ party: 'PT' })
|
|
|
|
const result = await loadStateDeputyListPageData(payload, coordinator, {
|
|
page: 1,
|
|
q: stateDeputy.name,
|
|
})
|
|
|
|
expect(result.totalDocs).toBe(1)
|
|
expect(result.totalPages).toBe(1)
|
|
expect(result.rows[0]).toMatchObject({
|
|
slug: stateDeputy.slug,
|
|
party: 'PT',
|
|
municipalityIDs: [],
|
|
leaderships: [],
|
|
})
|
|
})
|
|
|
|
it('resolves named leaderships linked to the deputy (not a bare count)', async () => {
|
|
const fixtures = campaignFixtures()
|
|
const coordinator = await fixtures.createCampaignUser('coordinator')
|
|
const municipality = await fixtures.getMunicipality()
|
|
const stateDeputy = await fixtures.createStateDeputy({ party: 'PT' })
|
|
const contact = await fixtures.createContact()
|
|
const leadership = await fixtures.createLeadership({
|
|
contact: contact.id,
|
|
municipalities: [municipality.id],
|
|
})
|
|
await payload.update({
|
|
collection: 'leadership',
|
|
id: leadership.id,
|
|
data: { stateDeputies: [stateDeputy.id] },
|
|
depth: 0,
|
|
overrideAccess: true,
|
|
})
|
|
|
|
const result = await loadStateDeputyListPageData(payload, coordinator, {
|
|
page: 1,
|
|
q: stateDeputy.name,
|
|
})
|
|
|
|
expect(result.rows[0]).toMatchObject({
|
|
slug: stateDeputy.slug,
|
|
leaderships: [{ id: leadership.id, name: contact.name }],
|
|
})
|
|
})
|
|
|
|
it('sorts by party in both directions, pinning where the party-less row falls', async () => {
|
|
const fixtures = campaignFixtures()
|
|
const coordinator = await fixtures.createCampaignUser('coordinator')
|
|
const marker = fixtures.value('sort')
|
|
const withPsd = await fixtures.createStateDeputy({
|
|
name: `Dep ${marker} PSD`,
|
|
party: 'PSD',
|
|
})
|
|
const withPt = await fixtures.createStateDeputy({ name: `Dep ${marker} PT`, party: 'PT' })
|
|
const withoutParty = await fixtures.createStateDeputy({ name: `Dep ${marker} sem` })
|
|
|
|
// Pinned adapter behavior (Postgres default, unmodified by Payload): ASC
|
|
// sorts nulls LAST, DESC sorts nulls FIRST — the party-less row never
|
|
// lands in the middle. "Sem partido" is the direct, unambiguous path to
|
|
// find it either way.
|
|
const asc = await loadStateDeputyListPageData(payload, coordinator, {
|
|
page: 1,
|
|
q: marker,
|
|
sort: 'party',
|
|
dir: 'asc',
|
|
})
|
|
expect(asc.rows.map((row) => row.slug)).toEqual([withPsd.slug, withPt.slug, withoutParty.slug])
|
|
|
|
const desc = await loadStateDeputyListPageData(payload, coordinator, {
|
|
page: 1,
|
|
q: marker,
|
|
sort: 'party',
|
|
dir: 'desc',
|
|
})
|
|
expect(desc.rows.map((row) => row.slug)).toEqual([withoutParty.slug, withPt.slug, withPsd.slug])
|
|
})
|
|
|
|
it('filters by selected parties and by the "Sem partido" sentinel', async () => {
|
|
const fixtures = campaignFixtures()
|
|
const coordinator = await fixtures.createCampaignUser('coordinator')
|
|
const marker = fixtures.value('filter')
|
|
const withPt = await fixtures.createStateDeputy({ name: `Dep ${marker} PT`, party: 'PT' })
|
|
await fixtures.createStateDeputy({ name: `Dep ${marker} PSD`, party: 'PSD' })
|
|
const withoutParty = await fixtures.createStateDeputy({ name: `Dep ${marker} sem` })
|
|
|
|
const byParty = await loadStateDeputyListPageData(payload, coordinator, {
|
|
page: 1,
|
|
q: marker,
|
|
parties: ['PT'],
|
|
})
|
|
expect(byParty.rows.map((row) => row.slug)).toEqual([withPt.slug])
|
|
|
|
const byNoParty = await loadStateDeputyListPageData(payload, coordinator, {
|
|
page: 1,
|
|
q: marker,
|
|
parties: [NO_PARTY_FILTER_VALUE],
|
|
})
|
|
expect(byNoParty.rows.map((row) => row.slug)).toEqual([withoutParty.slug])
|
|
})
|
|
|
|
it('computes the party facet from the current search, ignoring the party filter itself', async () => {
|
|
const fixtures = campaignFixtures()
|
|
const coordinator = await fixtures.createCampaignUser('coordinator')
|
|
const marker = fixtures.value('facet')
|
|
await fixtures.createStateDeputy({ name: `Dep ${marker} PT`, party: 'PT' })
|
|
await fixtures.createStateDeputy({ name: `Dep ${marker} PSD`, party: 'PSD' })
|
|
await fixtures.createStateDeputy({ name: `Dep ${marker} sem` })
|
|
|
|
const unfiltered = await loadStateDeputyListPageData(payload, coordinator, {
|
|
page: 1,
|
|
q: marker,
|
|
})
|
|
expect(unfiltered.filterFacets).toEqual({ parties: ['PSD', 'PT'], hasNoParty: true })
|
|
|
|
// Selecting PT keeps every party option reachable — the facet ignores its
|
|
// own popover's filter so the other options remain visible to switch to.
|
|
const filteredByPt = await loadStateDeputyListPageData(payload, coordinator, {
|
|
page: 1,
|
|
q: marker,
|
|
parties: ['PT'],
|
|
})
|
|
expect(filteredByPt.filterFacets).toEqual({ parties: ['PSD', 'PT'], hasNoParty: true })
|
|
})
|
|
})
|
|
|
|
describe('loadDemandListPageData', () => {
|
|
it('lists staff demands with municipality labels, open count and status filter', async () => {
|
|
const fixtures = campaignFixtures()
|
|
const coordinator = await fixtures.createCampaignUser('coordinator')
|
|
const municipality = await fixtures.getMunicipality()
|
|
const demand = await fixtures.createCampaignDemand({
|
|
municipality: municipality.id,
|
|
status: 'aberta',
|
|
})
|
|
|
|
const open = await loadDemandListPageData(payload, coordinator, {
|
|
page: 1,
|
|
status: 'aberta',
|
|
})
|
|
const row = open.rows.find((candidate) => candidate.slug === demand.slug)
|
|
expect(row).toMatchObject({
|
|
title: demand.title,
|
|
status: 'aberta',
|
|
municipalityName: municipality.name,
|
|
municipalitySlug: municipality.slug,
|
|
})
|
|
expect(open.openCount).toBeGreaterThanOrEqual(1)
|
|
expect(open.totalPages).toBeGreaterThanOrEqual(1)
|
|
|
|
const closed = await loadDemandListPageData(payload, coordinator, {
|
|
page: 1,
|
|
status: 'rejeitada',
|
|
})
|
|
expect(closed.rows.some((candidate) => candidate.slug === demand.slug)).toBe(false)
|
|
})
|
|
|
|
it('rejects leaders (demands are staff-only)', async () => {
|
|
const fixtures = campaignFixtures()
|
|
const leader = await fixtures.createCampaignUser('leader')
|
|
|
|
await expect(loadDemandListPageData(payload, leader, { page: 1 })).rejects.toThrow(/permissão/i)
|
|
})
|
|
})
|
|
|
|
describe('loadAdvisorListPageData', () => {
|
|
it('lists only advisor accounts, narrowed by q, with portfolio municipalities', async () => {
|
|
const fixtures = campaignFixtures()
|
|
const advisor = await fixtures.createCampaignUser('advisor')
|
|
// A coordinator sharing the marker must not appear (role filter).
|
|
await fixtures.createCampaignUser('coordinator', {
|
|
name: `${advisor.name} coordenando`,
|
|
})
|
|
const municipality = await fixtures.getMunicipality()
|
|
await fixtures.assignMunicipalityAdvisors(municipality.id, [advisor.id])
|
|
|
|
const result = await loadAdvisorListPageData(payload, { page: 1, q: advisor.name })
|
|
|
|
expect(result.totalDocs).toBe(1)
|
|
expect(result.rows[0]).toMatchObject({ id: advisor.id, name: advisor.name })
|
|
expect(result.rows[0]!.municipalities).toEqual([
|
|
{ id: municipality.id, name: municipality.name, slug: municipality.slug },
|
|
])
|
|
})
|
|
})
|