Fix the three leader-flow bugs the e2e suite pinned + RSC prop contracts

E2E-driven fixes (the suite now passes 9/9; two journeys remain
latency-flaky on cold dev compiles and pass on retry):

- leader home crashed with Forbidden since the remodel:
  loadLeaderContactsPageData loaded municipality options through the
  staff-scoped loader (leaders are denied municipality reads); their scope
  now derives from the engaged leadership's linked municipalities
- leaders saw 'Contato — —' instead of their supporters' names:
  getAccessibleContactIds now mirrors canReadSupporter for leaders
  (contacts of supporters they created)
- leader nav had two items with the same /campanha href (duplicate React
  keys); the leader home IS the contact tool — one entry
- RSC serialization contracts: CampaignListPagination is a server component
  again (hrefForPage stays server-side; CampaignTransitionAnchor client leaf
  intercepts clicks with serializable props); CampaignSearchForm takes
  basePath instead of a function prop
- hasAnyEstimate moved from the 'use client' PledgeEstimateForm to
  voteEstimate.ts — server components may not call client-module exports
- playwright.config honors TEQO_TEST_DATABASE_URL (parity with
  vitest.setup.ts) and uses a fixed .next/e2e dist dir so Next stops
  appending per-PID entries to tsconfig include; report artifacts
  eslint-ignored
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-24 00:38:40 -03:00
parent e79f60822e
commit 52c402c049
15 changed files with 195 additions and 105 deletions

View file

@ -183,6 +183,9 @@ const eslintConfig = [
'out/**',
'build/**',
'next-env.d.ts',
// Playwright artifacts (generated on e2e failures/reports):
'playwright-report/**',
'test-results/**',
]),
]

View file

@ -9,6 +9,13 @@ import { assertTestDatabase } from './tests/helpers/assertTestDatabase'
* `override: true` guarantees the test DB wins over anything in the shell.
*/
loadEnv({ path: '.env.test', override: true })
// Optional local override when the default Docker port is occupied (e.g. mapped
// to 5433) — same escape hatch vitest.setup.ts documents.
if (process.env.TEQO_TEST_DATABASE_URL) {
process.env.DATABASE_URL = process.env.TEQO_TEST_DATABASE_URL
}
assertTestDatabase(process.env.DATABASE_URL)
const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? 'http://localhost:3000'
const webServerPort = new URL(baseURL).port || (baseURL.startsWith('https:') ? '443' : '80')
@ -77,9 +84,12 @@ export default defineConfig({
env: {
DATABASE_URL: process.env.DATABASE_URL as string,
PORT: webServerPort,
// A prior Playwright process can finish tearing down after the next starts.
// Per-process output prevents that cleanup from deleting the active server's files.
NEXT_DIST_DIR: `.next/e2e-${process.pid}`,
// Fixed dist dir: Next dev appends `<distDir>/types/**/*.ts` to
// tsconfig.json's include with an EXACT-string check, so per-process
// names polluted tsconfig with one entry per run. A prior Playwright
// process tearing down late can, rarely, delete files of the next run's
// server — if a boot flakes, rerun (dev-only trade-off).
NEXT_DIST_DIR: '.next/e2e',
PAYLOAD_SECRET: process.env.PAYLOAD_SECRET ?? 'test-only-secret-not-used-in-production',
NEXT_PUBLIC_SITE_URL: process.env.NEXT_PUBLIC_SITE_URL ?? baseURL,
},

View file

@ -79,9 +79,7 @@ export default async function StateDeputiesPage({ searchParams }: StateDeputiesP
ariaLabel="Buscar dobradinha por nome"
placeholder="Buscar por nome…"
initialQuery={state.q ?? ''}
hrefForQuery={(query) =>
query ? `/campanha/dobradinhas?q=${encodeURIComponent(query)}` : '/campanha/dobradinhas'
}
basePath="/campanha/dobradinhas"
/>
<CampaignListResults>

View file

@ -77,9 +77,7 @@ export default async function LeadershipsPage({ searchParams }: LeadershipsPageP
ariaLabel="Buscar liderança por nome"
placeholder="Buscar por nome…"
initialQuery={state.q ?? ''}
hrefForQuery={(query) =>
query ? `/campanha/liderancas?q=${encodeURIComponent(query)}` : '/campanha/liderancas'
}
basePath="/campanha/liderancas"
/>
<CampaignListResults>

View file

@ -82,11 +82,7 @@ export default async function OrganizationsPage({ searchParams }: OrganizationsP
ariaLabel="Buscar organização por nome"
placeholder="Buscar por nome…"
initialQuery={state.q ?? ''}
hrefForQuery={(query) =>
query
? `/campanha/organizacoes?q=${encodeURIComponent(query)}`
: '/campanha/organizacoes'
}
basePath="/campanha/organizacoes"
/>
<CampaignListResults>

View file

@ -1,18 +1,14 @@
'use client'
import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react'
import { useRouter } from 'next/navigation'
import { useTransition, type MouseEvent } from 'react'
import { useCampaignListPending } from '@/components/campaign/CampaignListPending'
import { CampaignTransitionAnchor } from '@/components/campaign/CampaignListPending'
import { Button } from '@/components/ui/button'
import {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from '@/components/ui/Pagination'
import { cn } from '@/lib/utils'
export const getPaginationPages = (page: number, totalPages: number): number[] => {
const pages = new Set([1, totalPages, page - 1, page, page + 1])
@ -21,6 +17,12 @@ export const getPaginationPages = (page: number, totalPages: number): number[] =
.sort((a, b) => a - b)
}
/**
* Server component: `hrefForPage` never crosses the client boundary. The
* anchors themselves are `CampaignTransitionAnchor` client leaves so page
* changes ride the shared list transition (results dim) instead of a dead
* full-page load.
*/
export const CampaignListPagination = ({
page,
totalPages,
@ -30,83 +32,83 @@ export const CampaignListPagination = ({
totalPages: number
hrefForPage: (page: number) => string
}) => {
const router = useRouter()
const shared = useCampaignListPending()
const [isLocalPending, startLocalTransition] = useTransition()
const isPending = shared?.isPending ?? isLocalPending
const startTransition = shared?.startTransition ?? startLocalTransition
if (totalPages <= 1) return null
const visiblePages = getPaginationPages(page, totalPages)
/** Plain left-clicks navigate in a transition (pending UI); modified clicks keep link semantics. */
const interceptNavigation = (event: MouseEvent<HTMLAnchorElement>, href: string) => {
if (
event.defaultPrevented ||
event.button !== 0 ||
event.metaKey ||
event.ctrlKey ||
event.shiftKey ||
event.altKey
) {
return
}
event.preventDefault()
startTransition(() => {
router.push(href)
})
}
return (
<div
className="transition-opacity data-[pending=true]:opacity-70"
data-pending={isPending || undefined}
aria-busy={isPending}
>
<p className="sr-only" aria-live="polite">
{isPending ? 'Carregando página…' : ''}
</p>
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
<Pagination>
<PaginationContent>
<PaginationItem>
<Button
asChild
variant="ghost"
size="default"
className={cn(
'min-h-11 min-w-11 pl-1.5!',
page <= 1 ? 'pointer-events-none opacity-50' : undefined,
)}
>
<CampaignTransitionAnchor
href={hrefForPage(Math.max(1, page - 1))}
onClick={(event) => interceptNavigation(event, hrefForPage(Math.max(1, page - 1)))}
aria-label="Ir para a página anterior"
aria-disabled={page <= 1}
className={page <= 1 ? 'pointer-events-none opacity-50' : undefined}
/>
</PaginationItem>
{visiblePages.map((pageNumber, index) => {
const previousPage = visiblePages[index - 1]
const hasGap = previousPage !== undefined && pageNumber - previousPage > 1
data-slot="pagination-link"
>
<ChevronLeftIcon data-icon="inline-start" />
<span className="hidden sm:block">Anterior</span>
</CampaignTransitionAnchor>
</Button>
</PaginationItem>
{visiblePages.map((pageNumber, index) => {
const previousPage = visiblePages[index - 1]
const hasGap = previousPage !== undefined && pageNumber - previousPage > 1
const isActive = pageNumber === page
return (
<PaginationItem key={pageNumber} className="contents">
{hasGap ? <PaginationEllipsis /> : null}
<PaginationLink
return (
<PaginationItem key={pageNumber} className="contents">
{hasGap ? <PaginationEllipsis /> : null}
<Button
asChild
variant={isActive ? 'outline' : 'ghost'}
size="icon"
className="min-h-11 min-w-11"
>
<CampaignTransitionAnchor
href={hrefForPage(pageNumber)}
onClick={(event) => interceptNavigation(event, hrefForPage(pageNumber))}
isActive={pageNumber === page}
aria-label={`Ir para a página ${pageNumber}`}
aria-current={isActive ? 'page' : undefined}
data-slot="pagination-link"
data-active={isActive}
>
{pageNumber}
</PaginationLink>
</PaginationItem>
)
})}
<PaginationItem>
<PaginationNext
</CampaignTransitionAnchor>
</Button>
</PaginationItem>
)
})}
<PaginationItem>
<Button
asChild
variant="ghost"
size="default"
className={cn(
'min-h-11 min-w-11 pr-1.5!',
page >= totalPages ? 'pointer-events-none opacity-50' : undefined,
)}
>
<CampaignTransitionAnchor
href={hrefForPage(Math.min(totalPages, page + 1))}
onClick={(event) =>
interceptNavigation(event, hrefForPage(Math.min(totalPages, page + 1)))
}
aria-label="Ir para a próxima página"
aria-disabled={page >= totalPages}
className={page >= totalPages ? 'pointer-events-none opacity-50' : undefined}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
</div>
data-slot="pagination-link"
>
<span className="hidden sm:block">Próxima</span>
<ChevronRightIcon data-icon="inline-end" />
</CampaignTransitionAnchor>
</Button>
</PaginationItem>
</PaginationContent>
</Pagination>
)
}

View file

@ -1,9 +1,11 @@
'use client'
import { useRouter } from 'next/navigation'
import {
createContext,
useContext,
useTransition,
type ComponentPropsWithoutRef,
type ReactNode,
type TransitionStartFunction,
} from 'react'
@ -38,6 +40,47 @@ export const CampaignListPendingBoundary = ({ children }: { children: ReactNode
export const useCampaignListPending = (): CampaignListPendingValue | null =>
useContext(CampaignListPendingContext)
/**
* Anchor that navigates through the shared list transition (falling back to a
* local one), so the results region dims instead of a dead full-page load.
* Takes only serializable props — safe to render from server components.
*/
export const CampaignTransitionAnchor = ({
href,
children,
...anchorProps
}: Omit<ComponentPropsWithoutRef<'a'>, 'href' | 'onClick'> & { href: string }) => {
const router = useRouter()
const shared = useContext(CampaignListPendingContext)
const [, startLocalTransition] = useTransition()
const startTransition = shared?.startTransition ?? startLocalTransition
return (
<a
href={href}
{...anchorProps}
onClick={(event) => {
if (
event.defaultPrevented ||
event.button !== 0 ||
event.metaKey ||
event.ctrlKey ||
event.shiftKey ||
event.altKey
) {
return
}
event.preventDefault()
startTransition(() => {
router.push(href)
})
}}
>
{children}
</a>
)
}
/** Wraps the server-rendered results; dims while a shared navigation is pending. */
export const CampaignListResults = ({
children,

View file

@ -15,12 +15,13 @@ export const CampaignSearchForm = ({
ariaLabel,
placeholder,
initialQuery,
hrefForQuery,
basePath,
}: {
ariaLabel: string
placeholder: string
initialQuery: string
hrefForQuery: (query: string) => string
/** List route; the query lands as `?q=` (serializable — no function props across the RSC boundary). */
basePath: string
}) => {
const router = useRouter()
const shared = useCampaignListPending()
@ -29,6 +30,9 @@ export const CampaignSearchForm = ({
const startTransition = shared?.startTransition ?? startLocalTransition
const [query, setQuery] = useState(initialQuery)
const hrefForQuery = (value: string) =>
value ? `${basePath}?q=${encodeURIComponent(value)}` : basePath
return (
<form
role="search"

View file

@ -1,4 +1,4 @@
import { hasAnyEstimate, PledgeEstimateForm } from '@/components/campaign/PledgeEstimateForm'
import { PledgeEstimateForm } from '@/components/campaign/PledgeEstimateForm'
import { Badge } from '@/components/ui/Badge'
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
import {
@ -7,6 +7,7 @@ import {
getVoteEstimateForScenario,
} from '@/utilities/voteEstimate'
import { type StaffPledgeRow } from '@/utilities/votePledgeData'
import { hasAnyEstimate } from '@/utilities/voteEstimate'
const voteFormatter = new Intl.NumberFormat('pt-BR')
const dateFormatter = new Intl.DateTimeFormat('pt-BR')
@ -20,7 +21,10 @@ type MunicipalityPledgesPanelProps = {
}
/** Staff-only: declared vs estimated votes per leadership in this municipality. */
export const MunicipalityPledgesPanel = ({ pledges, estimateFormAction }: MunicipalityPledgesPanelProps) => {
export const MunicipalityPledgesPanel = ({
pledges,
estimateFormAction,
}: MunicipalityPledgesPanelProps) => {
const declaredTotal = pledges.reduce((total, pledge) => total + pledge.declaredVotes, 0)
const effectiveTotal = pledges.reduce(
(total, pledge) =>

View file

@ -22,9 +22,6 @@ type PledgeEstimateFormProps = {
) => Promise<CampaignFormActionState>
}
const hasAnyEstimate = (estimates: VoteEstimateScenarioViewModel): boolean =>
estimates.pessimistic != null || estimates.central != null || estimates.optimistic != null
/** Staff-only inline estimate. The leader never sees these fields. */
export const PledgeEstimateForm = ({
pledgeID,
@ -82,5 +79,3 @@ export const PledgeEstimateForm = ({
</form>
)
}
export { hasAnyEstimate }

View file

@ -30,7 +30,8 @@ const staffNav: CampaignNavItem[] = [
]
const leaderNav: CampaignNavItem[] = [
{ title: 'Início', href: '/campanha', icon: HomeIcon },
// The leader home IS the contact tool — one entry, one href (duplicate
// hrefs previously produced duplicate React keys in the sidebar).
{ title: 'Meus contatos', href: '/campanha', icon: UsersIcon },
]

View file

@ -68,7 +68,16 @@ export const getAccessibleContactIds = async (
}
}
if (currentUser.role === 'advisor' && collections.supporter) {
// Advisors read contacts of supporters in their municipalities; leaders read
// contacts of the supporters they created — mirroring `canReadSupporter`.
const supporterWhere =
currentUser.role === 'advisor'
? { municipality: { in: municipalityIDs ?? [] } }
: currentUser.role === 'leader'
? { createdBy: { equals: currentUser.id } }
: null
if (supporterWhere && collections.supporter) {
const supporterResult = await find({
collection: 'supporter',
depth: 0,
@ -77,11 +86,7 @@ export const getAccessibleContactIds = async (
pagination: false,
req,
select: { contact: true },
where: {
municipality: {
in: municipalityIDs ?? [],
},
},
where: supporterWhere,
})
for (const doc of supporterResult.docs) {
const id = relationshipId(doc.contact)

View file

@ -2,8 +2,8 @@ import type { Payload } from 'payload'
import type { RelationOption } from '@/components/campaign/RelationMultiSelect'
import type { CampaignUser, Contact, Municipality } from '@/payload-types'
import { getEngagedLeaderMunicipalityIds } from '@/utilities/campaignAccess'
import { getSupporterRegistrationConsent } from '@/utilities/campaignConsent'
import { loadMunicipalityOptions } from '@/utilities/campaignRelationOptions'
export type LeaderContactListItem = {
id: number
@ -42,12 +42,38 @@ const toLeaderContactListItem = (supporter: {
}
}
/**
* Leaders cannot read the municipality collection (lockdown), so their
* registration scope comes from the engaged leadership's linked municipality
* IDs — the same scope `canCreateSupporter` enforces on the write path.
*/
const loadLeaderMunicipalityOptions = async (
payload: Payload,
user: CampaignUser,
): Promise<RelationOption[]> => {
const ids = await getEngagedLeaderMunicipalityIds(payload, user.id)
if (ids.length === 0) return []
const result = await payload.find({
collection: 'municipality',
where: { id: { in: ids } },
depth: 0,
limit: 0,
pagination: false,
sort: 'name',
select: { name: true },
// Intentional bypass: scope already derived from the leader's own leadership.
overrideAccess: true,
})
return result.docs.map((municipality) => ({ id: municipality.id, name: municipality.name }))
}
export const loadLeaderContactsPageData = async (
payload: Payload,
user: CampaignUser,
): Promise<LeaderContactsPageView> => {
const [municipalityOptions, registrationConsent, supporters] = await Promise.all([
loadMunicipalityOptions(payload, user),
loadLeaderMunicipalityOptions(payload, user),
getSupporterRegistrationConsent(payload),
payload.find({
collection: 'supporter',

View file

@ -16,6 +16,10 @@ export type VoteEstimateScenarioViewModel = {
optimistic: number | null
}
/** True when at least one scenario has a recorded estimate. */
export const hasAnyEstimate = (estimates: VoteEstimateScenarioViewModel): boolean =>
estimates.pessimistic != null || estimates.central != null || estimates.optimistic != null
export const voteEstimateScenarioLabels: Record<VoteEstimateScenario, string> = {
pessimistic: 'Pessimista',
central: 'Média',

View file

@ -37,7 +37,8 @@
"**/*.tsx",
"next-env.d.ts",
".next/types/**/*.ts",
".next/*/types/**/*.ts"
".next/dev/types/**/*.ts",
".next/e2e/types/**/*.ts"
],
"exclude": [
"node_modules"