mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
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:
parent
3812d81261
commit
dc9a39f800
8 changed files with 707 additions and 11 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
199
tests/int/campaignEntityListData.int.spec.ts
Normal file
199
tests/int/campaignEntityListData.int.spec.ts
Normal 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 },
|
||||
])
|
||||
})
|
||||
})
|
||||
134
tests/unit/actionPlanUi.unit.spec.ts
Normal file
134
tests/unit/actionPlanUi.unit.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
75
tests/unit/campaignEntityListParsers.unit.spec.ts
Normal file
75
tests/unit/campaignEntityListParsers.unit.spec.ts
Normal 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',
|
||||
)
|
||||
})
|
||||
})
|
||||
151
tests/unit/campaignListUrl.unit.spec.ts
Normal file
151
tests/unit/campaignListUrl.unit.spec.ts
Normal 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',
|
||||
)
|
||||
})
|
||||
})
|
||||
113
tests/unit/supporterUi.unit.spec.ts
Normal file
113
tests/unit/supporterUi.unit.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue