From dc9a39f800969195396396d59ed4e72d91f27e3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Ign=C3=A1cio=20Fontoura=20Solla?= Date: Sat, 25 Jul 2026 13:30:39 -0300 Subject: [PATCH] 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) --- docs/TECH-DEBT.md | 12 +- docs/TESTING.md | 14 +- tests/helpers/campaignFixtures.ts | 20 ++ tests/int/campaignEntityListData.int.spec.ts | 199 ++++++++++++++++++ tests/unit/actionPlanUi.unit.spec.ts | 134 ++++++++++++ .../campaignEntityListParsers.unit.spec.ts | 75 +++++++ tests/unit/campaignListUrl.unit.spec.ts | 151 +++++++++++++ tests/unit/supporterUi.unit.spec.ts | 113 ++++++++++ 8 files changed, 707 insertions(+), 11 deletions(-) create mode 100644 tests/int/campaignEntityListData.int.spec.ts create mode 100644 tests/unit/actionPlanUi.unit.spec.ts create mode 100644 tests/unit/campaignEntityListParsers.unit.spec.ts create mode 100644 tests/unit/campaignListUrl.unit.spec.ts create mode 100644 tests/unit/supporterUi.unit.spec.ts diff --git a/docs/TECH-DEBT.md b/docs/TECH-DEBT.md index 981e6749..7f01cf95 100644 --- a/docs/TECH-DEBT.md +++ b/docs/TECH-DEBT.md @@ -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 diff --git a/docs/TESTING.md b/docs/TESTING.md index 85d30e49..41b171e7 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -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`. diff --git a/tests/helpers/campaignFixtures.ts b/tests/helpers/campaignFixtures.ts index 2b1f2828..01bfdd88 100644 --- a/tests/helpers/campaignFixtures.ts +++ b/tests/helpers/campaignFixtures.ts @@ -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 > +type StateDeputyInput = Partial> 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 { + 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 { 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', diff --git a/tests/int/campaignEntityListData.int.spec.ts b/tests/int/campaignEntityListData.int.spec.ts new file mode 100644 index 00000000..4d271a63 --- /dev/null +++ b/tests/int/campaignEntityListData.int.spec.ts @@ -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 }, + ]) + }) +}) diff --git a/tests/unit/actionPlanUi.unit.spec.ts b/tests/unit/actionPlanUi.unit.spec.ts new file mode 100644 index 00000000..f848da77 --- /dev/null +++ b/tests/unit/actionPlanUi.unit.spec.ts @@ -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') + }) +}) diff --git a/tests/unit/campaignEntityListParsers.unit.spec.ts b/tests/unit/campaignEntityListParsers.unit.spec.ts new file mode 100644 index 00000000..a7aa1b04 --- /dev/null +++ b/tests/unit/campaignEntityListParsers.unit.spec.ts @@ -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', + ) + }) +}) diff --git a/tests/unit/campaignListUrl.unit.spec.ts b/tests/unit/campaignListUrl.unit.spec.ts new file mode 100644 index 00000000..d53d4b7b --- /dev/null +++ b/tests/unit/campaignListUrl.unit.spec.ts @@ -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', + ) + }) +}) diff --git a/tests/unit/supporterUi.unit.spec.ts b/tests/unit/supporterUi.unit.spec.ts new file mode 100644 index 00000000..9a45b567 --- /dev/null +++ b/tests/unit/supporterUi.unit.spec.ts @@ -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) + }) +})