Pass 2 W0: pina contratos de URL e loaders das listas + truth-up TESTING/TECH-DEBT

- unit: campaignListUrl (canonicalize/clamp/redirect), supporterUi, actionPlanUi (matriz tab->where), 5 parsers de entidade
- int: campaignEntityListData smokes (escopo por role, busca por marker, shape de paginacao)
- fixtures: suporte a stateDeputy (create + cleanup)
- docs: contagens reais (45/47/7), Praca ~96 hits, conflito C8 F4 resolvido (10 ladders restantes -> W4d)
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-25 13:30:39 -03:00
parent 3812d81261
commit dc9a39f800
8 changed files with 707 additions and 11 deletions

View file

@ -6,14 +6,14 @@ Ledger of known engineering debt. Items large enough to be scheduled work become
| Item | Location | Type | Risk | Effort | Priority | Status | Ref |
| --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | -------------------- | --------------------------- | ----------------------------------------------------------------------------------------- | -------------------- |
| 158 unused exports reported by knip (warn-level) | god modules, ui kit families | dead code | low | M | P2 | open — drive `exports` rule to error after splits | Phase 5 |
| 164 unused exports + 60 unused types reported by knip (warn-level; 158 at Pass 1) | god modules, ui kit families | dead code | low | M | P2 | open — Pass 2 W4b flips `exports` to error after W4a kills the biggest offender | Pass 2 W4b |
| knip loader noise: `Error loading src/payload.config.ts` (server-only guard) | knip run output | tooling noise | none (exit 0, static graph resolves) | S | P3 | open | |
| Missing-consent window fixture releases its exclusive lease during the observation window — readers can interleave (rare flake). Full fix: session-level advisory lock spanning delete→op→restore + updated fault-path pins | `tests/helpers/testDatabaseLease.ts` (`withMissingInviteConsentFixture`) | test-infra race | low (ms-wide window; shared leases added 2026-07-23 narrow it) | M | P2 | open | |
| `users` collection has no roles (all admins omnipotent) | `src/collections/Users.ts` | RBAC | medium (small trusted admin set) | M (schema migration) | P1 before widening `/admin` | open | AGENTS Known Gaps #1 |
| Any campaign user can enumerate staff names/roles (`canReadCampaignUsers`) | `src/utilities/access/campaignUsers.ts` | info disclosure | low | S | P2 | open | |
| `removeSupporterData` deletes via `overrideAccess: true` while `canDeleteSupporter` is admin-only | `src/app/(campaign)/campanha/actions/supporter.ts` | access asymmetry (likely intentional LGPD path) | low | S | P3 | open — document or align | |
| Legacy public consent flows hardcode document IDs (`consent: 2`) | `src/app/(frontend)/actions/submitWhatsapp.ts` | correctness | medium | S | P1 | open | AGENTS Known Gaps #2 |
| E2E suite thin (5 specs; most campaign CRUD int-only) | `tests/e2e/` | coverage | medium | L | P2 | open | |
| E2E suite thin (7 specs; most campaign CRUD int-only) | `tests/e2e/` | coverage | medium | L | P2 | open | |
| CI runs on GitHub mirror only (no Codeberg/Woodpecker pipeline) | `.github/workflows/ci.yml` | process | low | M | P3 | open | |
| E2E campaign journeys latency-flake on loaded local machines (5s expect / 30s test budgets vs cold dev compiles; suite green with CI retries) | `tests/e2e/campaignMunicipalities.e2e.spec.ts` | test-infra | low | S | P3 | open — consider per-step budgets or a warmed dist dir | |
| Post listing cache holds all published posts, filters in memory | `src/utilities/posts.ts` | scale (fine at ~tens of posts) | low | M | P3 | open — watch corpus growth | |
@ -21,7 +21,7 @@ Ledger of known engineering debt. Items large enough to be scheduled work become
| Compare mode (`?compare=`) still queries per candidate uncached cross-request | `src/utilities/municipalityMapData.ts` | perf | low | M | P3 | open — React `cache()` dedups in-request; add `unstable_cache` if profiling warrants | |
| Map bundle serializes all years × scenarios (~100KB raw) | `municipalityMapContract.ts` consumers | payload size | low — gzip crushes repeating keys to ~15-25KB on the wire; instant year/scenario switching is a core analysis flow worth the bytes | M | P3 | deferred 2026-07-23 with rationale (revisit only if RSC payload profiling says otherwise) | |
| Props→state `useEffect` mirrors in list controls/comboboxes | `MunicipalityListExpectedVotesControl`, `AsyncSearchCombobox`, `StrictCombobox` | pattern | none — reviewed 2026-07-23: guarded-equality sync is the correct "server echo vs local draft" reconciliation (A10 pattern), not derived state | — | — | closed (accepted as correct) | |
| User-visible "Praça" copy survived the M1 rename (map panel title "Mapa das Praças", admin field descriptions, provisional Onda 0 consent texts, `SupportStatusBadge` summary, fallback/error strings, code comments) | `rg -n 'Praça' src` (~15 hits) | copy drift | low (cosmetic; consent texts are provisional Onda 0 copies) | S | P2 | open — found 2026-07-24 docs sync | |
| User-visible "Praça" copy survived the M1 rename (map panel title "Mapa das Praças", admin field descriptions, provisional Onda 0 consent texts, `SupportStatusBadge` summary, fallback/error strings, code comments) | `rg -n 'Praça' src` (~96 hits in 53 files, ~55 user-visible — 2026-07-25 audit) | copy drift | low (cosmetic; consent texts are provisional Onda 0 copies) | S | P2 | open — scheduled Pass 2 W4f (consent texts stay for the legal batch) | Pass 2 W4f |
## Smell Inventory
@ -31,11 +31,13 @@ Ledger of known engineering debt. Items large enough to be scheduled work become
| God module: ~71 exports of electoral math | `src/lib/electionInsights.ts` | split by concern when next touched | ledgered |
| Multi-feature action bag: CRUD + CSV import pipeline + LGPD in one file (722 lines) | `src/app/(campaign)/campanha/actions/supporter.ts` | split CRUD vs import | done 2026-07-23 (`supporterImport.ts`) |
| Clone twins: create/update/staff-assert/unique-conflict skeleton | `actions/organization.ts` ↔ `actions/stateDeputy.ts` | extract shared factory | done 2026-07-23 (`runStaffEntityMutation` in `campaignEntityActions.ts`) |
| 13 near-identical `formActions.ts` try/catch→mapError→redirect ladders | `src/app/(campaign)/campanha/(app)/*/formActions.ts` | shared form-action wrapper | partially done 2026-07-23 — 4 `*/nova` ladders on `runCampaignRedirectFormAction`; remainder tracked as C8 F4 ([plan](plans/escala-dry-pos-c6.md)) |
| 14 near-identical `formActions.ts` try/catch→mapError→redirect/return ladders | `src/app/(campaign)/campanha/**/formActions.ts` | shared form-action wrapper | partially done — C8 F4 (2026-07-21/23) unified the municipality staff wrappers + 4 `*/nova` ladders on `runCampaignRedirectFormAction`; **10 ladders written since remain hand-rolled** (844 lines: assessores, planos, apoiadores ×2, demandas/[slug], liderancas/[id], organizacoes/[slug], dobradinhas/[slug], municipios editar, convite) — scheduled Pass 2 W4d (status conflict with [escala-dry-pos-c6.md](plans/escala-dry-pos-c6.md) "F4 entregue" resolved 2026-07-25: F4 was complete for the surfaces that existed then) |
| Flat 80+-file `src/utilities/` junk drawer; `lib/` vs `utilities/` boundary undocumented | `src/utilities/`, `src/lib/` | document convention + move misfiled modules | done 2026-07-23 (convention in `.cursor/rules/engineering-standards.mdc`; `RefreshRouteOnSave` moved to `components/`) |
| Praça→Município rename leftovers in identifiers (`PLAZA_MAP_YEARS`, `ACCESSIBLE_PLAZA_IDS_CONTEXT_KEY`, …) | map/list utilities | finish rename | done 2026-07-23 |
| Props→state `useEffect` mirrors | `MunicipalityListExpectedVotesControl`, comboboxes | key-remount or fully controlled | closed 2026-07-23 (accepted as correct — see Debt Ledger row) |
| Detail-page RSCs mixing loading + tab UI + forms (300+ lines) | `municipios/[slug]/page.tsx`, `planos/[slug]/page.tsx` | extract tab loaders/sections | ledgered (tabs stream via Suspense since Phase 3; extraction still open) |
| Detail-page RSCs mixing loading + tab UI + forms (300+ lines) | `municipios/[slug]/page.tsx`, `planos/[slug]/page.tsx` | extract tab loaders/sections | scheduled Pass 2 W4c (tabs stream via Suspense since Phase 3; extraction still open) |
| God module: list URL + filters + sort + labels + freshness in one file (765 lines, 59 exports) | `src/utilities/municipalityUi.ts` | split along url/filters/sort/labels/freshness seams | scheduled Pass 2 W1-D1 |
| `components/campaign/` flat with 106 files; `utilities/` flat with 92 | `src/components/campaign/`, `src/utilities/` | within-layer domain subfolders | components scheduled Pass 2 W2 (staged per-domain `git mv`); `utilities/` deferred with trigger: a domain gaining its 3rd new module in a month gets its subfolder |
## Sprout / Wrap Register

View file

@ -4,9 +4,9 @@
Three layers, all local-database-only (see `local-database` skill; tests refuse any database not ending in `_test`):
- **Unit** (`tests/unit/**/*.unit.spec.ts`, 33+ specs): pure logic; runs with a deliberately invalid `DATABASE_URL` so nothing can touch a database.
- **Integration** (`tests/int/**/*.int.spec.ts`, 42+ specs): real Payload + Postgres (`teqo_test`); campaign fixtures allocate seeded municipalities via a Postgres sequence so parallel spec files never share one, and purge residue on claim. Shared consent rows with stable keys are leased (`tests/helpers/testDatabaseLease.ts`), never owned.
- **E2E** (`tests/e2e`, Playwright, 5 specs): smoke-level; most campaign behavior is pinned at the int layer.
- **Unit** (`tests/unit/**/*.unit.spec.ts`, 45 specs as of 2026-07-25): pure logic; runs with a deliberately invalid `DATABASE_URL` so nothing can touch a database.
- **Integration** (`tests/int/**/*.int.spec.ts`, 47 specs): real Payload + Postgres (`teqo_test`); campaign fixtures allocate seeded municipalities via a Postgres sequence so parallel spec files never share one, and purge residue on claim. Shared consent rows with stable keys are leased (`tests/helpers/testDatabaseLease.ts`), never owned.
- **E2E** (`tests/e2e`, Playwright, 7 specs): smoke-level; most campaign behavior is pinned at the int layer.
CI (`.github/workflows/ci.yml`) runs lint (zero warnings), typecheck, knip, unit, migrate, int against a Postgres 17 service.
@ -23,10 +23,12 @@ Modules the 2026-07 hardening phases touch, and where their behavior is pinned:
| Supporter import (token/bulk) | preview→confirm flow, dedup by phone, consent fail-closed | `campaignSupporter.int.spec.ts` | `supporterImportBulk`/`supporterImportToken` internals only via flow |
| Leader supporter create | linked-municipality scope; out-of-scope rejection; staff rejection | `collectionAccessLockdown.int.spec.ts` | |
| Posts visibility/caching | `isPostVisible` fail-closed; cached listing | (indirect via routes) | no dedicated `posts` spec — backlog |
| List URL contracts (Pass 2 W0, pinned 2026-07-25) | `campaignListUrl` canonicalize/clamp/redirect; supporter + actionPlan parse/serialize/href; 5 entity parsers | `campaignListUrl`, `supporterUi`, `actionPlanUi`, `campaignEntityListParsers` (unit) | municipality list URL pinned separately in `municipalityUi.unit.spec.ts` |
| Entity list loaders (Pass 2 W0) | scope (leader empty/denied), marker-narrowed q, kind/status filters, pagination shape | `campaignEntityListData.int.spec.ts` | advisor loader runs post-gate with `overrideAccess: true` (gate asserted upstream) |
## Characterization Backlog
- [ ] `src/utilities/posts.ts` — dedicated spec for `isPostVisible` fail-closed matrix and cache wrappers.
- [ ] `actionPlanViewModels.ts` + other `actionPlan*` utilities (~1k lines) — currently untested directly.
- [ ] `campaignDemandData.ts` / `leadershipData.ts` / `organizationData.ts` / `stateDeputyData.ts` list loaders.
- [ ] Election aggregate artifact (Phase 2): snapshot test pinning artifact ↔ `municipalityCatalog` key consistency.
- [ ] `actionPlanViewModels.ts` + other `actionPlan*` utilities (~1k lines) — `actionPlanUi.ts` pinned 2026-07-25 (Pass 2 W0); view models still untested directly.
- [x] `campaignDemandData.ts` / `leadershipData.ts` / `organizationData.ts` / `stateDeputyData.ts` list loaders — smoked 2026-07-25 (`campaignEntityListData.int.spec.ts`, Pass 2 W0).
- [x] Election aggregate artifact (Phase 2): snapshot test pinning artifact ↔ `municipalityCatalog` key consistency — delivered as `bahiaElectionAggregates.unit.spec.ts`.

View file

@ -15,6 +15,7 @@ import type {
Municipality,
MunicipalityUpdate,
Organization,
StateDeputy,
Supporter,
User,
VotePledge,
@ -38,6 +39,7 @@ type CampaignCollection =
| 'leadership'
| 'supporter'
| 'organization'
| 'stateDeputy'
| 'contact'
| 'campaignUser'
| 'consent'
@ -85,6 +87,7 @@ type VotePledgeInput = Partial<
type OrganizationInput = Partial<
Pick<Organization, 'name' | 'slug' | 'kind' | 'notes' | 'municipalities'>
>
type StateDeputyInput = Partial<Pick<StateDeputy, 'name' | 'slug' | 'party' | 'notes'>>
type CampaignDemandInput = Partial<
Pick<
CampaignDemand,
@ -111,6 +114,7 @@ const emptyOwnedIDs = (): OwnedIDs => ({
leadership: new Set(),
supporter: new Set(),
organization: new Set(),
stateDeputy: new Set(),
contact: new Set(),
campaignUser: new Set(),
consent: new Set(),
@ -662,6 +666,21 @@ export class CampaignFixtures {
return organization
}
async createStateDeputy(input: StateDeputyInput = {}): Promise<StateDeputy> {
const name = input.name ?? this.value('Deputado')
const stateDeputy = await this.rootPayload.create({
collection: 'stateDeputy',
data: {
...input,
name,
slug: input.slug ?? this.value('deputado'),
},
depth: 0,
})
this.own('stateDeputy', stateDeputy)
return stateDeputy
}
async createCampaignDemand(input: CampaignDemandInput): Promise<CampaignDemand> {
const title = input.title ?? this.value('Demanda')
const demand = await this.rootPayload.create({
@ -970,6 +989,7 @@ export class CampaignFixtures {
'leadership',
'supporter',
'organization',
'stateDeputy',
'contact',
'campaignUser',
'consent',

View file

@ -0,0 +1,199 @@
// @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 { 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 names 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,
municipalityNames: [municipality.name],
hasAppAccess: false,
})
})
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 })
})
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 })
})
})
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',
municipalityCount: 0,
leadershipCount: 0,
})
})
})
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 },
])
})
})

View file

@ -0,0 +1,134 @@
import { describe, expect, it } from 'vitest'
import {
actionPlanTabs,
buildActionPlanFiltersKey,
buildActionPlanListHref,
buildActionPlanListSearchParams,
buildActionPlanListWhere,
parseActionPlanListParams,
resolveActionPlanListUrl,
} from '@/utilities/actionPlanUi'
const NOW = new Date('2026-07-25T12:00:00.000Z')
describe('parseActionPlanListParams', () => {
it('defaults to the proximos tab on page 1', () => {
expect(parseActionPlanListParams({})).toEqual({ page: 1, tab: 'proximos' })
})
it('falls back to proximos on an unknown tab', () => {
expect(parseActionPlanListParams({ tab: 'passados' }).tab).toBe('proximos')
})
it('accepts every declared tab', () => {
for (const tab of actionPlanTabs) {
expect(parseActionPlanListParams({ tab }).tab).toBe(tab)
}
})
it('honors status only on the todos tab', () => {
expect(parseActionPlanListParams({ status: 'realizado' }).status).toBeUndefined()
expect(parseActionPlanListParams({ tab: 'todos', status: 'realizado' }).status).toBe(
'realizado',
)
expect(parseActionPlanListParams({ tab: 'todos', status: 'inexistente' }).status).toBeUndefined()
})
it('validates kind and municipality', () => {
expect(parseActionPlanListParams({ kind: 'caminhada' }).kind).toBe('caminhada')
expect(parseActionPlanListParams({ kind: 'festa' }).kind).toBeUndefined()
expect(parseActionPlanListParams({ municipality: '12' }).municipality).toBe(12)
expect(parseActionPlanListParams({ municipality: '0' }).municipality).toBeUndefined()
})
})
describe('buildActionPlanListWhere (tab → where matrix)', () => {
it('proximos = planejado/confirmado starting from now', () => {
const where = buildActionPlanListWhere({ page: 1, tab: 'proximos' }, NOW)
expect(where).toEqual({
and: [
{ status: { in: ['planejado', 'confirmado'] } },
{ startAt: { greater_than_equal: NOW.toISOString() } },
],
})
})
it('realizados and rascunhos filter by their status', () => {
expect(buildActionPlanListWhere({ page: 1, tab: 'realizados' }, NOW)).toEqual({
and: [{ status: { equals: 'realizado' } }],
})
expect(buildActionPlanListWhere({ page: 1, tab: 'rascunhos' }, NOW)).toEqual({
and: [{ status: { equals: 'rascunho' } }],
})
})
it('todos is unfiltered unless a status is chosen', () => {
expect(buildActionPlanListWhere({ page: 1, tab: 'todos' }, NOW)).toEqual({})
expect(buildActionPlanListWhere({ page: 1, tab: 'todos', status: 'cancelado' }, NOW)).toEqual({
and: [{ status: { equals: 'cancelado' } }],
})
})
it('prepends kind and municipality filters on any tab', () => {
const where = buildActionPlanListWhere(
{ page: 1, tab: 'realizados', kind: 'comicio', municipality: 3 },
NOW,
)
expect(where).toEqual({
and: [
{ kind: { equals: 'comicio' } },
{ municipality: { equals: 3 } },
{ status: { equals: 'realizado' } },
],
})
})
})
describe('buildActionPlanListSearchParams', () => {
it('omits the default tab and page 1', () => {
expect(buildActionPlanListSearchParams({ page: 1, tab: 'proximos' }).toString()).toBe('')
expect(buildActionPlanListSearchParams({ page: 2, tab: 'todos' }).toString()).toBe(
'tab=todos&page=2',
)
})
it('serializes filters in canonical order', () => {
const params = buildActionPlanListSearchParams({
page: 1,
tab: 'todos',
kind: 'caminhada',
status: 'cancelado',
municipality: 9,
})
expect(params.toString()).toBe('tab=todos&kind=caminhada&status=cancelado&municipality=9')
})
})
describe('hrefs and canonical URL resolution', () => {
it('builds hrefs against the planos base path', () => {
expect(buildActionPlanListHref({ page: 1, tab: 'proximos' }, 1)).toBe('/campanha/planos')
expect(buildActionPlanListHref({ page: 1, tab: 'realizados' }, 2)).toBe(
'/campanha/planos?tab=realizados&page=2',
)
})
it('derives the filters key from the canonical serialization', () => {
expect(buildActionPlanFiltersKey({ page: 1, tab: 'todos', kind: 'comicio' })).toBe(
'tab=todos&kind=comicio',
)
})
it('redirects an explicit default tab out of the URL', () => {
const result = resolveActionPlanListUrl({ tab: 'proximos' })
expect(result.href).toBe('/campanha/planos')
expect(result.redirectHref).toBe('/campanha/planos')
})
it('redirects on unsupported params and clamps the page', () => {
expect(resolveActionPlanListUrl({ foo: 'bar' }).redirectHref).toBe('/campanha/planos')
const clamped = resolveActionPlanListUrl({ tab: 'todos', page: '50' }, 2)
expect(clamped.state.page).toBe(2)
expect(clamped.redirectHref).toBe('/campanha/planos?tab=todos&page=2')
})
})

View file

@ -0,0 +1,75 @@
/**
* Pins the five "simple" entity list parsers ahead of the Pass 2 list-system
* consolidation (W1): lideranças, organizações, dobradinhas, demandas and
* assessores. Their URL contracts must survive the migration byte-identical.
*/
import { describe, expect, it } from 'vitest'
import { advisorListHrefForPage, parseAdvisorListParams } from '@/utilities/advisorData'
import { parseDemandListParams } from '@/utilities/campaignDemandData'
import { parseLeadershipListParams } from '@/utilities/leadershipData'
import { parseOrganizationListParams } from '@/utilities/organizationData'
import { parseStateDeputyListParams } from '@/utilities/stateDeputyData'
const qAndPageParsers = [
['leadership', parseLeadershipListParams],
['stateDeputy', parseStateDeputyListParams],
['advisor', parseAdvisorListParams],
] as const
describe.each(qAndPageParsers)('%s list parser (q + page)', (_name, parse) => {
it('defaults to page 1 without q', () => {
expect(parse({})).toEqual({ page: 1 })
})
it('validates page as a positive decimal integer', () => {
expect(parse({ page: '4' }).page).toBe(4)
expect(parse({ page: '0' }).page).toBe(1)
expect(parse({ page: '-2' }).page).toBe(1)
expect(parse({ page: '01' }).page).toBe(1)
expect(parse({ page: 'abc' }).page).toBe(1)
})
it('trims q, omits it when empty and takes the first repeated cell', () => {
expect(parse({ q: ' ana ' }).q).toBe('ana')
expect(parse({ q: ' ' }).q).toBeUndefined()
expect(parse({ q: ['primeiro', 'segundo'] }).q).toBe('primeiro')
})
})
describe('parseOrganizationListParams', () => {
it('keeps only known organization kinds', () => {
expect(parseOrganizationListParams({ kind: 'sindicato' }).kind).toBe('sindicato')
expect(parseOrganizationListParams({ kind: 'clube' }).kind).toBeUndefined()
})
it('combines q, kind and page', () => {
expect(parseOrganizationListParams({ q: 'cut', kind: 'sindicato', page: '2' })).toEqual({
page: 2,
q: 'cut',
kind: 'sindicato',
})
})
})
describe('parseDemandListParams', () => {
it('keeps only known statuses and kinds', () => {
expect(parseDemandListParams({ status: 'aberta' }).status).toBe('aberta')
expect(parseDemandListParams({ status: 'fechada' }).status).toBeUndefined()
expect(parseDemandListParams({ kind: 'material' }).kind).toBe('material')
expect(parseDemandListParams({ kind: 'outra-coisa' }).kind).toBeUndefined()
})
it('defaults to page 1 with no filters', () => {
expect(parseDemandListParams({})).toEqual({ page: 1 })
})
})
describe('advisorListHrefForPage', () => {
it('serializes q then page against the assessores base path', () => {
expect(advisorListHrefForPage({ page: 1 }, 1)).toBe('/campanha/assessores')
expect(advisorListHrefForPage({ page: 1, q: 'edi' }, 3)).toBe(
'/campanha/assessores?q=edi&page=3',
)
})
})

View file

@ -0,0 +1,151 @@
import { describe, expect, it } from 'vitest'
import {
allParamValues,
buildListHref,
firstValue,
inspectRawListParams,
normalizedText,
resolveListUrl,
strictDecimalInteger,
type RawSearchParams,
} from '@/utilities/campaignListUrl'
describe('firstValue', () => {
it('returns the first cell of a repeated param and passes scalars through', () => {
expect(firstValue(['a', 'b'])).toBe('a')
expect(firstValue('x')).toBe('x')
expect(firstValue(undefined)).toBeUndefined()
})
})
describe('allParamValues', () => {
it('flattens, trims, dedupes and drops empty cells', () => {
expect(allParamValues([' a ', 'b', 'a', ''])).toEqual(['a', 'b'])
expect(allParamValues('solo')).toEqual(['solo'])
expect(allParamValues(undefined)).toEqual([])
expect(allParamValues([' '])).toEqual([])
})
})
describe('normalizedText', () => {
it('trims and turns empty/non-string into undefined', () => {
expect(normalizedText(' salvador ')).toBe('salvador')
expect(normalizedText('')).toBeUndefined()
expect(normalizedText(' ')).toBeUndefined()
expect(normalizedText(undefined)).toBeUndefined()
})
})
describe('strictDecimalInteger', () => {
it('accepts only positive decimal integers without leading zeros', () => {
expect(strictDecimalInteger('12')).toBe(12)
expect(strictDecimalInteger('1')).toBe(1)
expect(strictDecimalInteger('0')).toBeUndefined()
expect(strictDecimalInteger('01')).toBeUndefined()
expect(strictDecimalInteger('-3')).toBeUndefined()
expect(strictDecimalInteger('1.5')).toBeUndefined()
expect(strictDecimalInteger('abc')).toBeUndefined()
expect(strictDecimalInteger(undefined)).toBeUndefined()
})
it('rejects unsafe integers', () => {
expect(strictDecimalInteger('9007199254740993')).toBeUndefined()
})
})
describe('inspectRawListParams', () => {
const paramNameSet = new Set(['q', 'page'])
it('flags unsupported params and excludes them from the serialized query', () => {
const result = inspectRawListParams({ q: 'x', foo: '1' }, paramNameSet)
expect(result.hasUnsupportedParams).toBe(true)
expect(result.query).toBe('q=x')
})
it('serializes repeated values in order and skips undefined', () => {
const result = inspectRawListParams({ q: ['a', 'b'], page: undefined }, paramNameSet)
expect(result.hasUnsupportedParams).toBe(false)
expect(result.query).toBe('q=a&q=b')
})
})
// A minimal list-state fixture mirroring how supporter/actionPlan lists wire
// resolveListUrl: q + page, canonical order q-then-page, page omitted at 1.
type FixtureState = { page: number; q?: string }
const fixtureParamNameSet = new Set(['q', 'page'])
const parseFixture = (params: RawSearchParams): FixtureState => {
const q = normalizedText(firstValue(params.q))
return {
page: strictDecimalInteger(firstValue(params.page)) ?? 1,
...(q ? { q } : {}),
}
}
const buildFixtureSearchParams = (state: FixtureState, page = state.page): URLSearchParams => {
const params = new URLSearchParams()
if (state.q) params.set('q', state.q)
if (page > 1) params.set('page', String(page))
return params
}
const resolveFixtureUrl = (params: RawSearchParams, totalPages?: number) =>
resolveListUrl({
params,
paramNameSet: fixtureParamNameSet,
parse: parseFixture,
buildSearchParams: buildFixtureSearchParams,
basePath: '/campanha/lista',
totalPages,
})
describe('resolveListUrl', () => {
it('passes a canonical URL through without a redirect', () => {
const result = resolveFixtureUrl({ q: 'x', page: '2' })
expect(result.state).toEqual({ page: 2, q: 'x' })
expect(result.href).toBe('/campanha/lista?q=x&page=2')
expect(result.redirectHref).toBeUndefined()
})
it('redirects when raw params are in non-canonical order', () => {
const result = resolveFixtureUrl({ page: '2', q: 'x' })
expect(result.redirectHref).toBe('/campanha/lista?q=x&page=2')
})
it('redirects on unsupported params, dropping them', () => {
const result = resolveFixtureUrl({ q: 'x', foo: '1' })
expect(result.state).toEqual({ page: 1, q: 'x' })
expect(result.redirectHref).toBe('/campanha/lista?q=x')
})
it('clamps page to totalPages and redirects', () => {
const result = resolveFixtureUrl({ page: '9' }, 3)
expect(result.state.page).toBe(3)
expect(result.redirectHref).toBe('/campanha/lista?page=3')
})
it('does not clamp when totalPages is 0 (empty result set)', () => {
const result = resolveFixtureUrl({ page: '9' }, 0)
expect(result.state.page).toBe(9)
expect(result.redirectHref).toBeUndefined()
})
it('normalizes page=1 out of the URL', () => {
const result = resolveFixtureUrl({ page: '1' })
expect(result.href).toBe('/campanha/lista')
expect(result.redirectHref).toBe('/campanha/lista')
})
})
describe('buildListHref', () => {
it('omits the query entirely when the builder emits no params', () => {
expect(buildListHref({ page: 1 }, buildFixtureSearchParams, '/campanha/lista', 1)).toBe(
'/campanha/lista',
)
expect(buildListHref({ page: 1 }, buildFixtureSearchParams, '/campanha/lista', 4)).toBe(
'/campanha/lista?page=4',
)
})
})

View file

@ -0,0 +1,113 @@
import { describe, expect, it } from 'vitest'
import {
buildSupporterFiltersKey,
buildSupporterListHref,
buildSupporterListSearchParams,
canAccessSupporterArea,
getSupporterScopeLabel,
parseSupporterListParams,
resolveSupporterListUrl,
} from '@/utilities/supporterUi'
describe('parseSupporterListParams', () => {
it('defaults to page 1 with no filters', () => {
expect(parseSupporterListParams({})).toEqual({ page: 1 })
})
it('validates page strictly (positive decimal integer)', () => {
expect(parseSupporterListParams({ page: '3' }).page).toBe(3)
expect(parseSupporterListParams({ page: '0' }).page).toBe(1)
expect(parseSupporterListParams({ page: '02' }).page).toBe(1)
expect(parseSupporterListParams({ page: 'x' }).page).toBe(1)
})
it('trims q and omits it when empty', () => {
expect(parseSupporterListParams({ q: ' maria ' }).q).toBe('maria')
expect(parseSupporterListParams({ q: ' ' }).q).toBeUndefined()
})
it('keeps only known vote intentions', () => {
expect(parseSupporterListParams({ voteIntention: 'certo' }).voteIntention).toBe('certo')
expect(parseSupporterListParams({ voteIntention: 'talvez' }).voteIntention).toBeUndefined()
})
it('canonicalizes city through the Bahia municipality resolver', () => {
expect(parseSupporterListParams({ city: ' salvador ' }).city).toBe('Salvador')
expect(parseSupporterListParams({ city: 'Gotham' }).city).toBeUndefined()
})
it('parses municipality as a strict positive integer', () => {
expect(parseSupporterListParams({ municipality: '42' }).municipality).toBe(42)
expect(parseSupporterListParams({ municipality: '-1' }).municipality).toBeUndefined()
})
it('takes the first cell of repeated params', () => {
expect(parseSupporterListParams({ q: ['ana', 'bia'] }).q).toBe('ana')
})
})
describe('buildSupporterListSearchParams', () => {
it('serializes in canonical order and omits page 1', () => {
const params = buildSupporterListSearchParams({
page: 1,
q: 'ana',
voteIntention: 'certo',
city: 'Salvador',
municipality: 7,
})
expect(params.toString()).toBe('q=ana&voteIntention=certo&city=Salvador&municipality=7')
})
it('includes page only beyond 1 and supports the page override', () => {
expect(buildSupporterListSearchParams({ page: 2 }).toString()).toBe('page=2')
expect(buildSupporterListSearchParams({ page: 1 }, 5).toString()).toBe('page=5')
})
})
describe('buildSupporterFiltersKey / buildSupporterListHref', () => {
it('derives a stable filters key from the canonical serialization', () => {
expect(buildSupporterFiltersKey({ page: 3, q: 'ana' })).toBe('q=ana&page=3')
})
it('builds hrefs against the supporters base path', () => {
expect(buildSupporterListHref({ page: 1 }, 1)).toBe('/campanha/apoiadores')
expect(buildSupporterListHref({ page: 1, q: 'ana' }, 2)).toBe(
'/campanha/apoiadores?q=ana&page=2',
)
})
})
describe('resolveSupporterListUrl', () => {
it('is canonical for a clean URL', () => {
const result = resolveSupporterListUrl({ voteIntention: 'indeciso' })
expect(result.state.voteIntention).toBe('indeciso')
expect(result.href).toBe('/campanha/apoiadores?voteIntention=indeciso')
expect(result.redirectHref).toBeUndefined()
})
it('redirects away from unsupported params and clamps the page', () => {
const unsupported = resolveSupporterListUrl({ foo: 'bar' })
expect(unsupported.redirectHref).toBe('/campanha/apoiadores')
const clamped = resolveSupporterListUrl({ page: '99' }, 4)
expect(clamped.state.page).toBe(4)
expect(clamped.redirectHref).toBe('/campanha/apoiadores?page=4')
})
})
describe('getSupporterScopeLabel', () => {
it('pluralizes by count', () => {
expect(getSupporterScopeLabel(1)).toMatch(/^1 apoiador /)
expect(getSupporterScopeLabel(2)).toMatch(/^2 apoiadores /)
})
})
describe('canAccessSupporterArea', () => {
it('allows staff roles and blocks leaders', () => {
expect(canAccessSupporterArea('coordinator')).toBe(true)
expect(canAccessSupporterArea('advisor')).toBe(true)
expect(canAccessSupporterArea('candidate')).toBe(true)
expect(canAccessSupporterArea('leader')).toBe(false)
})
})