From 5f11960c3aa35eaf69550363d9a4ca0ae8676d2d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Francisco=20Ign=C3=A1cio=20Fontoura=20Solla?=
Date: Tue, 28 Jul 2026 08:05:45 -0300
Subject: [PATCH] B32+: share the campaign JSON route wrapper, fetch helper and
cell auto-save.
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`campaignJsonMutationRoute` now builds every JSON POST route under /campanha —
`isSameOriginRequest` had been copied into five of them, so the CSRF policy was
failing open by omission; a convention guard sweeps all of `src/app` and names
its three exceptions. `postCampaignJson` is the only place that spells
`credentials: 'same-origin'`, and `useCampaignCellAutosave` is the debounce /
abort / revert / flush-on-close machine the quick-edit cells shipped three
times.
The shell keeps the `aria-live` region outside the Popover/Drawer, since closing
is what commits and it was unmounting the node that would announce the result;
`variant="sheet"` took a `footer` slot, which let the signal control drop the
overlay it only kept for one button.
Two bugs the extraction made visible: a revert issued while a different payload
was in flight was dropped silently, and `change()` never cleared a past failure,
which outranks everything the live region would otherwise say.
---
.cursor/rules/projects/nucleos-eleitorais.mdc | 1 +
AGENTS.md | 2 +
docs/plans/escala-dry-pos-b32.md | 48 +++-
docs/roadmap.md | 6 +-
.../(app)/atividades/contactSearchActions.ts | 5 +-
.../(app)/liderancas/support-status/route.ts | 36 +--
.../(app)/municipios/advisors/route.ts | 38 +--
.../municipios/engagement-level/route.ts | 90 +++---
.../(app)/municipios/expected-votes/route.ts | 38 +--
.../(app)/municipios/political-trend/route.ts | 37 +--
.../LeadershipListSupportStatusControl.tsx | 144 +---------
.../MunicipalityListAdvisorsControl.tsx | 34 +--
.../MunicipalityListExpectedVotesControl.tsx | 153 ++--------
.../MunicipalityListLevelControl.tsx | 81 +++---
.../MunicipalityListSignalControl.tsx | 168 ++++-------
.../MunicipalityListTrendControl.tsx | 254 ++++-------------
.../shared/CampaignCellEditOverlay.tsx | 113 +++++---
.../LeadershipStateDeputyRelationCell.tsx | 13 +-
.../shared/MunicipalityPortfolioCell.tsx | 6 +
.../shared/useCampaignCellAutosave.ts | 233 +++++++++++++++
.../shared/useCampaignCellFailureChannel.ts | 33 +++
src/lib/campaignJsonRequest.ts | 25 ++
src/utilities/campaignActionContext.ts | 3 +-
src/utilities/campaignFormActionError.ts | 6 +
src/utilities/campaignJsonMutationRoute.ts | 69 ++++-
src/utilities/sameOriginRequest.ts | 4 +-
tests/unit/campaignCellAutosave.unit.spec.ts | 269 ++++++++++++++++++
.../unit/campaignCellEditOverlay.unit.spec.ts | 156 +++++++++-
.../campaignJsonMutationRoute.unit.spec.ts | 121 ++++++++
tests/unit/codebaseConventions.unit.spec.ts | 40 +++
30 files changed, 1396 insertions(+), 830 deletions(-)
create mode 100644 src/components/campaign/shared/useCampaignCellAutosave.ts
create mode 100644 src/components/campaign/shared/useCampaignCellFailureChannel.ts
create mode 100644 src/lib/campaignJsonRequest.ts
create mode 100644 tests/unit/campaignCellAutosave.unit.spec.ts
create mode 100644 tests/unit/campaignJsonMutationRoute.unit.spec.ts
diff --git a/.cursor/rules/projects/nucleos-eleitorais.mdc b/.cursor/rules/projects/nucleos-eleitorais.mdc
index 1f7fffd3..ffa644f1 100644
--- a/.cursor/rules/projects/nucleos-eleitorais.mdc
+++ b/.cursor/rules/projects/nucleos-eleitorais.mdc
@@ -7,6 +7,7 @@ alwaysApply: false
## Status
+- **B32+ entregue em código (2026-07-28):** escala e DRY pós-B32 (+564 / −802 linhas em 15 arquivos, mais quatro novos; sem migration e sem mudança de contrato de rede). Toda rota JSON de `/campanha` agora nasce de **`campaignJsonMutationRoute(config, handler)`** — origem verificada, corpo parseado e `catch` mapeado por construção, com o `bodySchema.parse` **dentro do mesmo `try`** para não trocar a mensagem de corpo inválido; `parseCampaignJsonRequestBody`/`campaignJsonMutationErrorResponse` deixaram de ser exportados e um **guard de convenção** em `codebaseConventions.unit.spec.ts` recusa um `POST` que não passe pelo wrapper. Isso existe porque a política de mesma origem estava copiada em cinco rotas: a que esquecesse a linha aceitaria POST cross-origin em silêncio. `engagement-level` mantém o 409 dentro do handler. No cliente, `postCampaignJson` (transporte) e **`useCampaignCellAutosave`** (`shared/`) absorvem os três controles de auto-save. Três achados da auditoria que valem mais que o refactor: `display`/`draft` **nunca divergiram** (eram setados juntos em todos os caminhos — o hook tem um valor só, e o `noteDraft` do Trend fica no chamador porque texto cru e nota normalizada são coisas diferentes); `reportFailure` **não** existia nos quatro controles como o plano afirmava (o de assessores não importava `sonner`); e `flush()` idempotente elimina na raiz o `inFlightRef` que o B24 tinha como contorno de dois caminhos para o mesmo save. O hook é dono do `open` — fechar É o commit —, e `valueRef` avança sincronamente para não reler o render que agendou a mudança (bug que B33+ e B34 já pagaram). Casca: a região `aria-live` mudou para **fora** do Popover/Drawer (prop `statusMessage`), já que o fechamento que dispara a gravação desmontava justamente o nó que anunciaria "Salvando…"; e `variant="sheet"` ganhou slot `footer`, que tirou "Registrar movimento" de baixo da dobra no controle de nível (E14) e permitiu ao Sinal (B26) largar o Popover/Drawer próprios — `formId` na casca acabou desnecessário, porque `
: null}
+ statusMessage={statusMessage}
+ tooltipContent={hasNote ? {value.note}
: null}
contentClassName="w-72 p-3"
preventPopoverAutoFocus
trigger={
- displayTrend.status ? (
- {trendLabel}
+ value.status ? (
+ {trendLabel}
) : (
{trendLabel}
)
@@ -272,8 +122,16 @@ export const MunicipalityListTrendControl = ({
Tendência
handleStatusChange(event.target.value)}
+ value={value.status ?? ''}
+ onChange={(event) =>
+ change(
+ (current) => ({
+ ...current,
+ status: parsePoliticalTrendStatusFormValue(event.target.value || undefined),
+ }),
+ STATUS_AUTOSAVE_MS,
+ )
+ }
className="min-h-11 w-full"
>
Não registrada
@@ -293,8 +151,17 @@ export const MunicipalityListTrendControl = ({
handleNoteChange(event.target.value)}
- onBlur={flushDraft}
+ onChange={(event) => {
+ setNoteDraft(event.target.value)
+ // Updater, not a literal: `value.status` here is the render that
+ // scheduled this keystroke, so a select change batched into the
+ // same tick would be overwritten by its predecessor.
+ change(
+ (current) => ({ ...current, note: normalizeNote(event.target.value) }),
+ NOTE_AUTOSAVE_MS,
+ )
+ }}
+ onBlur={flush}
maxLength={NOTE_MAX_LENGTH}
rows={3}
className="min-h-20 resize-y"
@@ -306,9 +173,6 @@ export const MunicipalityListTrendControl = ({
{errorMessage}
) : null}
-
- {statusMessage}
-
)
diff --git a/src/components/campaign/shared/CampaignCellEditOverlay.tsx b/src/components/campaign/shared/CampaignCellEditOverlay.tsx
index 0346cd26..32e0dff0 100644
--- a/src/components/campaign/shared/CampaignCellEditOverlay.tsx
+++ b/src/components/campaign/shared/CampaignCellEditOverlay.tsx
@@ -1,6 +1,6 @@
'use client'
-import { useRef, type ReactNode } from 'react'
+import { useRef, useState, type ReactNode } from 'react'
import { CampaignHoverTooltip } from '@/components/campaign/shared/CampaignHoverTooltip'
import { Button } from '@/components/ui/button'
@@ -18,12 +18,8 @@ import { cn } from '@/lib/utils'
export type CampaignCellEditOverlayVariant = 'popover' | 'sheet'
-/**
- * Shared so the triggers this container does NOT own — `MunicipalityListSignalControl`,
- * which wraps its own `` — can't drift from the ones it does.
- * `relative` is load-bearing: see the comment where it is composed below.
- */
-export const campaignCellEditTriggerClassName =
+/** `relative` is load-bearing: see the comment where it is composed below. */
+const campaignCellEditTriggerClassName =
'relative min-h-11 rounded-md px-1 hover:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring'
type CampaignCellEditOverlayProps = {
@@ -36,6 +32,14 @@ type CampaignCellEditOverlayProps = {
triggerLabel: string
triggerClassName?: string
triggerBusy?: boolean
+ /**
+ * Announced by the region this shell keeps OUTSIDE the overlay. Closing is
+ * what commits a draft, and a region rendered inside the Popover/Drawer
+ * unmounts with it — so the one announcement that matters most ("Salvando…",
+ * and the failure that follows) was the one that never reached a screen
+ * reader (B32+ F4).
+ */
+ statusMessage?: string
children: ReactNode
// Popover only ─────────────────────────────────────────────────────────────
@@ -54,6 +58,15 @@ type CampaignCellEditOverlayProps = {
/** Subject line under the title: which row is being edited. */
description?: string
sheetBodyClassName?: string
+ /**
+ * Replaces the default "Fechar" for a sheet whose primary action is an
+ * explicit submit (E14's "Registrar movimento"): in a scrolling body the
+ * button drifts below the fold, and the footer is where a thumb expects it.
+ * A submit that belongs to a ` ` in `children` is associated by the
+ * standard `form` attribute — the caller owns both ids, so the shell does not
+ * need to know there is a form at all.
+ */
+ footer?: ReactNode
}
/**
@@ -80,13 +93,26 @@ export const CampaignCellEditOverlay = ({
triggerLabel,
triggerClassName,
triggerBusy,
+ statusMessage,
tooltipContent,
align = 'start',
contentClassName,
sheetBodyClassName,
+ footer,
preventPopoverAutoFocus,
children,
}: CampaignCellEditOverlayProps) => {
+ /**
+ * The region is mounted from the first open onwards and never unmounted
+ * again — a live region has to exist BEFORE its text changes or most screen
+ * readers skip the first message, and it has to outlive the close, which is
+ * what commits. Mounting it unconditionally instead would leave ~250 polite
+ * regions registered on a 25-row município page (5 controls × 2 sibling
+ * trees), which is a load on assistive tech that nothing here needs.
+ */
+ const [everOpened, setEverOpened] = useState(open)
+ if (open && !everOpened) setEverOpened(true)
+
const titleRef = useRef(null)
const triggerButton = (
)
+ // One region per control, next to its trigger — never a single global one:
+ // two cells saving at once would then overwrite each other's announcement,
+ // and neither would say which row it came from.
+ // `role="status"` and not a bare `aria-live`: its implicit `aria-atomic`
+ // makes the sentence be announced whole, which is what these messages are —
+ // `MunicipalityPortfolioCell` had already settled on it.
+ const liveRegion =
+ statusMessage === undefined || !everOpened ? null : (
+
+ {statusMessage}
+
+ )
+
if (variant === 'sheet') {
return (
<>
{triggerButton}
+ {liveRegion}
{/*
* Focus lands on the title, never on the first field: base-ui's
@@ -144,11 +184,15 @@ export const CampaignCellEditOverlay = ({
{children}
- }
- >
- Fechar
-
+ {footer ?? (
+ // Swiping down is the gesture, but it is not an affordance
+ // anyone sees.
+ }
+ >
+ Fechar
+
+ )}
@@ -157,26 +201,29 @@ export const CampaignCellEditOverlay = ({
}
return (
-
-
- {triggerButton}
-
- event.preventDefault() : undefined}
- >
- {children}
-
-
+ <>
+
+
+ {triggerButton}
+
+ event.preventDefault() : undefined}
+ >
+ {children}
+
+
+ {liveRegion}
+ >
)
}
diff --git a/src/components/campaign/shared/LeadershipStateDeputyRelationCell.tsx b/src/components/campaign/shared/LeadershipStateDeputyRelationCell.tsx
index feae608f..560f1e3c 100644
--- a/src/components/campaign/shared/LeadershipStateDeputyRelationCell.tsx
+++ b/src/components/campaign/shared/LeadershipStateDeputyRelationCell.tsx
@@ -375,11 +375,18 @@ export const LeadershipStateDeputyRelationCell = ({
)}
-
- {statusMessage}
-
+
+ {/*
+ * Outside the Popover on purpose (B32+ F4): a region that unmounts with
+ * the overlay announces nothing once the overlay closes, and closing is
+ * what commits here. This cell still hand-rolls its trigger and popover —
+ * migrating it onto `CampaignCellEditOverlay` is B31's extraction trigger.
+ */}
+
+ {statusMessage}
+
)
}
diff --git a/src/components/campaign/shared/MunicipalityPortfolioCell.tsx b/src/components/campaign/shared/MunicipalityPortfolioCell.tsx
index caed993d..b17c0acd 100644
--- a/src/components/campaign/shared/MunicipalityPortfolioCell.tsx
+++ b/src/components/campaign/shared/MunicipalityPortfolioCell.tsx
@@ -799,6 +799,12 @@ export const MunicipalityPortfolioCell = ({
>
+ {/*
+ * This cell keeps its own region rather than the shell's `statusMessage`:
+ * the shell mounts that one from the first open of the overlay, and on a
+ * fine pointer this cell's editor IS the cell — the Drawer never opens,
+ * so nothing would ever be announced.
+ */}
{statusMessage}
diff --git a/src/components/campaign/shared/useCampaignCellAutosave.ts b/src/components/campaign/shared/useCampaignCellAutosave.ts
new file mode 100644
index 00000000..f9105bc7
--- /dev/null
+++ b/src/components/campaign/shared/useCampaignCellAutosave.ts
@@ -0,0 +1,233 @@
+'use client'
+
+import { useEffect, useRef, useState } from 'react'
+
+import { useCampaignCellFailureChannel } from '@/components/campaign/shared/useCampaignCellFailureChannel'
+import { postCampaignJson } from '@/lib/campaignJsonRequest'
+
+const DIRTY_MESSAGE = 'Alterações serão salvas automaticamente.'
+
+type CampaignCellAutosavePayload = { status: string; message: string }
+
+type SuccessPayload = Extract
+
+type UseCampaignCellAutosaveOptions = {
+ /** Server value. Adopted only when it changes from OUTSIDE (navigation / RSC refresh). */
+ value: TValue
+ /** What "unchanged" means for this shape — normalization included (see the trend's note). */
+ equals: (left: TValue, right: TValue) => boolean
+ endpoint: string
+ buildBody: (value: TValue) => unknown
+ readSaved: (payload: SuccessPayload) => TValue
+ /** Shown when the server fails without a safe message of its own. */
+ errorMessage: string
+ /** Announced while a save is in flight, e.g. "Salvando tendência." */
+ pendingMessage: string
+}
+
+/**
+ * The auto-save machine behind the quick-edit cells (B24 tendência, B27 votos
+ * estimados, B32 status de apoio): debounce, abort of a superseded request,
+ * revert to the last confirmed value, and the flush that closing the overlay
+ * performs.
+ *
+ * Four invariants it exists to hold:
+ *
+ * - **One value.** A field that normalizes (the trend's justificativa) keeps
+ * its raw draft string in the caller; everything else is this one value.
+ * - **`flush()` is the only save path**, so blur-then-close cannot send the
+ * same payload twice.
+ * - **Closing IS the commit** — hence the hook owning `open`. A caller that
+ * could close without flushing is the bug this replaces.
+ * - **`valueRef` advances synchronously**, so work scheduled by an event
+ * cannot read the render that scheduled it.
+ *
+ * `value` may be a fresh object per render (the trend's is): the adoption
+ * effect is guarded by `equals`, not by React's dependency comparison, which
+ * makes a correct `equals` load-bearing for a caller passing an unstable
+ * reference.
+ */
+export const useCampaignCellAutosave = (
+ options: UseCampaignCellAutosaveOptions,
+) => {
+ const { value: serverValue } = options
+ // Effects and async work read the latest options through a ref: every
+ // callback here is recreated per render, but a save started three renders ago
+ // must not report through a stale one. Written during render on purpose — a
+ // save started by a handler in this same commit has to see these options, and
+ // rows are keyed by id, so a discarded render carries the same props anyway.
+ const optionsRef = useRef(options)
+ optionsRef.current = options
+
+ const [open, setOpen] = useState(false)
+ const [value, setValue] = useState(serverValue)
+ const [isDirty, setIsDirty] = useState(false)
+ const [isPending, setIsPending] = useState(false)
+ const { errorMessage, setErrorMessage, reportFailure, noteOpenChange } =
+ useCampaignCellFailureChannel()
+
+ const saveTimeoutRef = useRef | undefined>(undefined)
+ const abortRef = useRef(null)
+ const committedRef = useRef(serverValue)
+ const lastPropsRef = useRef(serverValue)
+ const saveGenerationRef = useRef(0)
+ /** Payload of the request in flight: blur and close flush the same one. */
+ const inFlightRef = useRef(null)
+ /**
+ * Advanced synchronously so two changes batched into one tick — or a flush
+ * fired by the same event that changed the value — cannot read the render
+ * that scheduled them (the bug B33+/B34 both paid for).
+ */
+ const valueRef = useRef(serverValue)
+
+ const commitLocalValue = (next: TValue) => {
+ valueRef.current = next
+ setValue(next)
+ }
+
+ // Adopt server props only when they change from outside (navigation / RSC refresh).
+ useEffect(() => {
+ if (optionsRef.current.equals(serverValue, lastPropsRef.current)) return
+ lastPropsRef.current = serverValue
+ committedRef.current = serverValue
+ valueRef.current = serverValue
+ setValue(serverValue)
+ setIsDirty(false)
+ // `equals` is read off the ref: an inline arrow at the call site would
+ // otherwise re-run this on every render.
+ }, [serverValue])
+
+ // A row unmounted mid-debounce drops the pending edit. Deliberate: there
+ // would be nothing left to report a failure through.
+ useEffect(
+ () => () => {
+ clearTimeout(saveTimeoutRef.current)
+ abortRef.current?.abort()
+ },
+ [],
+ )
+
+ /**
+ * "Nothing left to do" means equal to what the server will END UP with, which
+ * during a request is the payload in flight and not the last confirmation.
+ * Comparing against `committedRef` alone silently dropped a revert: with `B`
+ * in flight, going back to `A` looked settled, so nothing was sent and `B`'s
+ * success then repainted the cell as `B`.
+ */
+ const isSettled = (next: TValue) => {
+ const { equals } = optionsRef.current
+ return inFlightRef.current !== null
+ ? equals(next, inFlightRef.current)
+ : equals(next, committedRef.current)
+ }
+
+ const save = async (next: TValue) => {
+ const current = optionsRef.current
+ if (isSettled(next)) return
+
+ const generation = ++saveGenerationRef.current
+ abortRef.current?.abort()
+ const controller = new AbortController()
+ abortRef.current = controller
+ inFlightRef.current = next
+
+ setIsPending(true)
+ setErrorMessage(null)
+
+ const revert = () => {
+ commitLocalValue(committedRef.current)
+ setIsDirty(false)
+ }
+
+ try {
+ const { ok, payload } = await postCampaignJson(
+ current.endpoint,
+ current.buildBody(next),
+ controller.signal,
+ )
+
+ if (generation !== saveGenerationRef.current) return
+
+ if (!ok || payload.status !== 'success') {
+ revert()
+ reportFailure(payload.status === 'error' ? payload.message : current.errorMessage)
+ return
+ }
+
+ // Narrowed by the discriminant just above. The cast is the cheaper trade:
+ // typing `readSaved` over the whole union would push a `status` re-check
+ // into all three callers, which have already been told this succeeded.
+ committedRef.current = current.readSaved(payload as SuccessPayload)
+ commitLocalValue(committedRef.current)
+ setIsDirty(false)
+ } catch {
+ if (controller.signal.aborted || generation !== saveGenerationRef.current) return
+ revert()
+ reportFailure(current.errorMessage)
+ } finally {
+ if (generation === saveGenerationRef.current) {
+ inFlightRef.current = null
+ setIsPending(false)
+ }
+ }
+ }
+
+ /**
+ * Applies the value locally and schedules the save; a value already settled
+ * cancels it. Takes an updater when the next value depends on the current
+ * one — two controls in the same tick (a select plus a keystroke) must not
+ * each build their payload from the render that scheduled them.
+ */
+ const change = (next: TValue | ((current: TValue) => TValue), delayMs: number) => {
+ const resolved =
+ typeof next === 'function' ? (next as (current: TValue) => TValue)(valueRef.current) : next
+
+ commitLocalValue(resolved)
+ clearTimeout(saveTimeoutRef.current)
+ saveTimeoutRef.current = undefined
+ // Editing is what clears a past failure: leaving it set would keep it above
+ // `isPending`/`isDirty` in `statusMessage`, announcing the old error over
+ // everything typed next.
+ setErrorMessage(null)
+
+ if (isSettled(resolved)) {
+ setIsDirty(false)
+ return
+ }
+
+ setIsDirty(true)
+ saveTimeoutRef.current = setTimeout(() => {
+ saveTimeoutRef.current = undefined
+ void save(resolved)
+ }, delayMs)
+ }
+
+ /** Sends a pending change now instead of waiting out the debounce. Idempotent. */
+ const flush = () => {
+ clearTimeout(saveTimeoutRef.current)
+ saveTimeoutRef.current = undefined
+ void save(valueRef.current)
+ setIsDirty(false)
+ }
+
+ const onOpenChange = (nextOpen: boolean) => {
+ noteOpenChange(nextOpen)
+ if (nextOpen) {
+ setIsDirty(false)
+ setErrorMessage(null)
+ } else if (open) {
+ flush()
+ }
+ setOpen(nextOpen)
+ }
+
+ const statusMessage = errorMessage
+ ? errorMessage
+ : isPending
+ ? options.pendingMessage
+ : isDirty
+ ? DIRTY_MESSAGE
+ : ''
+
+ return { open, onOpenChange, value, change, flush, isPending, errorMessage, statusMessage }
+}
diff --git a/src/components/campaign/shared/useCampaignCellFailureChannel.ts b/src/components/campaign/shared/useCampaignCellFailureChannel.ts
new file mode 100644
index 00000000..75888a00
--- /dev/null
+++ b/src/components/campaign/shared/useCampaignCellFailureChannel.ts
@@ -0,0 +1,33 @@
+'use client'
+
+import { useRef, useState } from 'react'
+import { toast } from 'sonner'
+
+/**
+ * One policy, worth naming because every quick-edit cell has to obey it:
+ * **closing unmounts the inline Alert, so a failure that arrives after that has
+ * to change channel.** These cells keep saving past the dismissal — closing is
+ * what commits the draft, and nothing aborts a request already in flight — so a
+ * revert reported only through the Alert is a revert reported to nobody.
+ *
+ * While the overlay is open the Alert is the closer channel; once closed, a
+ * toast is what outlives it.
+ *
+ * The caller advances `noteOpenChange` from its own `onOpenChange`: the ref, not
+ * the state, is what an async callback started three renders ago can read.
+ */
+export const useCampaignCellFailureChannel = () => {
+ const [errorMessage, setErrorMessage] = useState(null)
+ const openRef = useRef(false)
+
+ const reportFailure = (message: string) => {
+ setErrorMessage(message)
+ if (!openRef.current) toast.error(message)
+ }
+
+ const noteOpenChange = (nextOpen: boolean) => {
+ openRef.current = nextOpen
+ }
+
+ return { errorMessage, setErrorMessage, reportFailure, noteOpenChange }
+}
diff --git a/src/lib/campaignJsonRequest.ts b/src/lib/campaignJsonRequest.ts
new file mode 100644
index 00000000..c1eb094b
--- /dev/null
+++ b/src/lib/campaignJsonRequest.ts
@@ -0,0 +1,25 @@
+/**
+ * Transport half of the list cells' quick edits: the preamble every control
+ * repeated around `fetch` (same-origin credentials, JSON headers, the cast of
+ * the discriminated response). Deliberately knows nothing about revert, retry
+ * or debounce — that is `useCampaignCellAutosave`'s, or the caller's.
+ *
+ * `ok` travels next to the payload because a route can answer a real body with
+ * a non-200 status (`engagement-level` returns its `blocked` state as 409), so
+ * the two are not the same question.
+ */
+export const postCampaignJson = async (
+ url: string,
+ body: unknown,
+ signal?: AbortSignal,
+): Promise<{ ok: boolean; payload: TResponse }> => {
+ const response = await fetch(url, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
+ credentials: 'same-origin',
+ signal,
+ body: JSON.stringify(body),
+ })
+
+ return { ok: response.ok, payload: (await response.json()) as TResponse }
+}
diff --git a/src/utilities/campaignActionContext.ts b/src/utilities/campaignActionContext.ts
index 6ff6997c..1b584ca5 100644
--- a/src/utilities/campaignActionContext.ts
+++ b/src/utilities/campaignActionContext.ts
@@ -6,6 +6,7 @@ import { getPayload, type Payload } from 'payload'
import type { CampaignUser } from '@/payload-types'
import { isCampaignStaff, isCampaignUnrestricted } from '@/utilities/campaignAccess'
import { getCampaignUser } from '@/utilities/campaignAuth'
+import { CAMPAIGN_AUTH_REQUIRED_MESSAGE } from '@/utilities/campaignFormActionError'
import type { PayloadTransactionRequest } from '@/utilities/payloadTransaction'
export type CampaignActionContext = {
@@ -20,7 +21,7 @@ export const requireCampaignUser = async (
getUser: CampaignUserGetter = getCampaignUser,
): Promise => {
const actor = await getUser()
- if (!actor) throw new Error('Autenticação necessária.')
+ if (!actor) throw new Error(CAMPAIGN_AUTH_REQUIRED_MESSAGE)
return actor
}
diff --git a/src/utilities/campaignFormActionError.ts b/src/utilities/campaignFormActionError.ts
index 1413928b..e7de669e 100644
--- a/src/utilities/campaignFormActionError.ts
+++ b/src/utilities/campaignFormActionError.ts
@@ -15,6 +15,12 @@ export type CampaignFormActionState = CampaignFormErrorState & {
}
export const CAMPAIGN_SESSION_EXPIRED_MESSAGE = 'Sessão expirada. Entre novamente.'
+/**
+ * Thrown when there is no campaign session at all. It is matched by exact
+ * string in `safeMessages`, so a reworded throw would silently collapse a 401
+ * into the generic message — hence the constant rather than the literal.
+ */
+export const CAMPAIGN_AUTH_REQUIRED_MESSAGE = 'Autenticação necessária.'
export type MapCampaignFormActionErrorOptions = {
error: unknown
diff --git a/src/utilities/campaignJsonMutationRoute.ts b/src/utilities/campaignJsonMutationRoute.ts
index 7f6cfc85..ea4028db 100644
--- a/src/utilities/campaignJsonMutationRoute.ts
+++ b/src/utilities/campaignJsonMutationRoute.ts
@@ -1,18 +1,29 @@
import { NextResponse } from 'next/server'
import {
+ CAMPAIGN_AUTH_REQUIRED_MESSAGE,
CAMPAIGN_SESSION_EXPIRED_MESSAGE,
mapCampaignFormActionError,
} from '@/utilities/campaignFormActionError'
+import { isSameOriginRequest } from '@/utilities/sameOriginRequest'
-export type CampaignJsonErrorBody = { status: 'error'; message: string }
+type CampaignJsonErrorBody = { status: 'error'; message: string }
/**
- * Shared shell for `POST /campanha/**` JSON mutation routes (list-popover
- * auto-save): parses the body, mapping a malformed one to the same 400 both
- * routes already used. Domain parsing/success payloads stay per-route.
+ * Structural on purpose: any zod schema satisfies it, and the shell stays out
+ * of the validation library's type surface.
*/
-export const parseCampaignJsonRequestBody = async (
+type CampaignJsonBodySchema = { parse: (value: unknown) => TBody }
+
+type CampaignJsonMutationRouteConfig = {
+ bodySchema: CampaignJsonBodySchema
+ /** Domain messages allowed to reach the client verbatim (everything else collapses). */
+ safeMessages: readonly string[]
+ genericMessage: string
+}
+
+/** Parses the body, mapping a malformed one to a 400 every route shares. */
+const parseCampaignJsonRequestBody = async (
request: Request,
): Promise<
{ ok: true; body: unknown } | { ok: false; response: NextResponse }
@@ -35,19 +46,23 @@ export const parseCampaignJsonRequestBody = async (
* two auth ones, appended once here) pass through as 400, an expired/missing
* session is 401, anything else collapses to `genericMessage`.
*/
-export const campaignJsonMutationErrorResponse = (
+const campaignJsonMutationErrorResponse = (
error: unknown,
{ safeMessages, genericMessage }: { safeMessages: readonly string[]; genericMessage: string },
): NextResponse => {
const mapped = mapCampaignFormActionError({
error,
- safeMessages: [...safeMessages, 'Autenticação necessária.', CAMPAIGN_SESSION_EXPIRED_MESSAGE],
+ safeMessages: [
+ ...safeMessages,
+ CAMPAIGN_AUTH_REQUIRED_MESSAGE,
+ CAMPAIGN_SESSION_EXPIRED_MESSAGE,
+ ],
genericMessage,
})
const isAuthError =
error instanceof Error &&
- (error.message === 'Autenticação necessária.' ||
+ (error.message === CAMPAIGN_AUTH_REQUIRED_MESSAGE ||
error.message === CAMPAIGN_SESSION_EXPIRED_MESSAGE)
return NextResponse.json(
@@ -55,3 +70,41 @@ export const campaignJsonMutationErrorResponse = (
{ status: isAuthError ? 401 : 400 },
)
}
+
+/**
+ * Builds the `POST` of a `/campanha/**` JSON mutation route (the list cells'
+ * quick edits): same-origin check, body parse, schema parse and error mapping,
+ * leaving each route with its schema, its action call and its success payload.
+ *
+ * It is a wrapper and not a `guard(request)` helper because the origin check is
+ * the kind of line a sixth route forgets, and forgetting it fails OPEN — a
+ * cookie-authenticated POST from anywhere would be honoured. Here it is
+ * structural: there is no way to export a handler that skipped it.
+ * `tests/unit/codebaseConventions.unit.spec.ts` refuses a route that exports
+ * `POST` without going through this.
+ *
+ * The schema is parsed inside the same `try` as the handler, so a malformed
+ * field keeps mapping to the route's own `genericMessage`, as before.
+ */
+export const campaignJsonMutationRoute =
+ (
+ config: CampaignJsonMutationRouteConfig ,
+ handler: (body: TBody) => Promise>,
+ ) =>
+ async (request: Request): Promise> => {
+ if (!isSameOriginRequest(request)) {
+ return NextResponse.json(
+ { status: 'error', message: 'Requisição inválida.' },
+ { status: 403 },
+ )
+ }
+
+ const parsed = await parseCampaignJsonRequestBody(request)
+ if (!parsed.ok) return parsed.response
+
+ try {
+ return await handler(config.bodySchema.parse(parsed.body))
+ } catch (error) {
+ return campaignJsonMutationErrorResponse(error, config)
+ }
+ }
diff --git a/src/utilities/sameOriginRequest.ts b/src/utilities/sameOriginRequest.ts
index 4c35a474..51a9ebd1 100644
--- a/src/utilities/sameOriginRequest.ts
+++ b/src/utilities/sameOriginRequest.ts
@@ -2,7 +2,9 @@
* CSRF guard for cookie-authenticated JSON route handlers (auto-save popovers):
* a same-origin browser request either omits `Origin` (same-site navigation)
* or sends one matching the request URL; anything else is rejected before the
- * body is even parsed. Shared by every `POST /campanha/**` JSON endpoint.
+ * body is even parsed. Its single consumer is `campaignJsonMutationRoute`,
+ * which is how every `POST /campanha/**` endpoint gets it: applying the policy
+ * here and the sharing there is what stops a new route from omitting it.
*/
export const isSameOriginRequest = (request: Request): boolean => {
const origin = request.headers.get('origin')
diff --git a/tests/unit/campaignCellAutosave.unit.spec.ts b/tests/unit/campaignCellAutosave.unit.spec.ts
new file mode 100644
index 00000000..8295fd61
--- /dev/null
+++ b/tests/unit/campaignCellAutosave.unit.spec.ts
@@ -0,0 +1,269 @@
+import { act, renderHook } from '@testing-library/react'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+import { useCampaignCellAutosave } from '@/components/campaign/shared/useCampaignCellAutosave'
+
+const errorToast = vi.fn()
+vi.mock('sonner', () => ({ toast: { error: (message: string) => errorToast(message) } }))
+
+/**
+ * B32+ F1: this state machine (debounce, coalescing, abort of a superseded
+ * request, revert, and the flush that closing performs) shipped three times
+ * inside three components, where only the e2e could reach it. Extracting it is
+ * what makes it testable at this level — so these cases are the precondition of
+ * the refactor, not decoration.
+ */
+type TestResponse =
+ | { status: 'success'; message: string; saved: string }
+ | { status: 'error'; message: string }
+
+const ENDPOINT = '/campanha/municipios/political-trend'
+const ERROR_MESSAGE = 'Não foi possível salvar. Tente novamente.'
+const DELAY = 600
+
+const jsonResponse = (payload: TestResponse, init?: { ok?: boolean }) => ({
+ ok: init?.ok ?? true,
+ json: async () => payload,
+})
+
+const success = (saved: string) => jsonResponse({ status: 'success', message: 'Salvo.', saved })
+
+const renderAutosave = (value = 'inicial') =>
+ renderHook(
+ (props: { value: string }) =>
+ useCampaignCellAutosave({
+ value: props.value,
+ equals: (left, right) => left === right,
+ endpoint: ENDPOINT,
+ buildBody: (next) => ({ value: next }),
+ readSaved: (payload) => payload.saved,
+ errorMessage: ERROR_MESSAGE,
+ pendingMessage: 'Salvando.',
+ }),
+ { initialProps: { value } },
+ )
+
+const fetchMock = vi.fn()
+
+const requestBodies = () =>
+ fetchMock.mock.calls.map((call) => JSON.parse(String(call[1].body)) as { value: string })
+
+beforeEach(() => {
+ vi.useFakeTimers()
+ fetchMock.mockReset()
+ fetchMock.mockResolvedValue(success('salvo'))
+ errorToast.mockReset()
+ vi.stubGlobal('fetch', fetchMock)
+})
+
+afterEach(() => {
+ vi.useRealTimers()
+ vi.unstubAllGlobals()
+})
+
+describe('useCampaignCellAutosave', () => {
+ it('waits out the debounce before posting', async () => {
+ const { result } = renderAutosave()
+
+ act(() => result.current.change('editado', DELAY))
+
+ expect(result.current.value).toBe('editado')
+ expect(result.current.statusMessage).toBe('Alterações serão salvas automaticamente.')
+ act(() => void vi.advanceTimersByTime(DELAY - 1))
+ expect(fetchMock).not.toHaveBeenCalled()
+
+ await act(async () => void vi.advanceTimersByTime(1))
+ expect(requestBodies()).toEqual([{ value: 'editado' }])
+ expect(result.current.statusMessage).toBe('')
+ })
+
+ it('coalesces keystrokes into a single request', async () => {
+ const { result } = renderAutosave()
+
+ act(() => result.current.change('e', DELAY))
+ act(() => void vi.advanceTimersByTime(DELAY - 50))
+ act(() => result.current.change('ed', DELAY))
+ await act(async () => void vi.advanceTimersByTime(DELAY))
+
+ expect(requestBodies()).toEqual([{ value: 'ed' }])
+ })
+
+ it('cancels the pending save when the value returns to the committed one', async () => {
+ const { result } = renderAutosave()
+
+ act(() => result.current.change('editado', DELAY))
+ act(() => result.current.change('inicial', DELAY))
+ await act(async () => void vi.advanceTimersByTime(DELAY))
+
+ expect(fetchMock).not.toHaveBeenCalled()
+ expect(result.current.statusMessage).toBe('')
+ })
+
+ it('flushes the pending change when the overlay closes instead of dropping it', async () => {
+ const { result } = renderAutosave()
+
+ act(() => result.current.onOpenChange(true))
+ act(() => result.current.change('editado', DELAY))
+ await act(async () => result.current.onOpenChange(false))
+
+ expect(requestBodies()).toEqual([{ value: 'editado' }])
+ })
+
+ it('does not re-post the payload already in flight when blur and close both flush', async () => {
+ let settleSave: (value: unknown) => void = () => {}
+ fetchMock.mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ settleSave = resolve
+ }),
+ )
+
+ const { result } = renderAutosave()
+
+ act(() => result.current.onOpenChange(true))
+ act(() => result.current.change('editado', DELAY))
+ // Blur flushes…
+ act(() => result.current.flush())
+ // …and closing flushes again before the first request has answered.
+ act(() => result.current.onOpenChange(false))
+
+ expect(fetchMock).toHaveBeenCalledTimes(1)
+
+ await act(async () => settleSave(success('editado')))
+ expect(fetchMock).toHaveBeenCalledTimes(1)
+ expect(result.current.value).toBe('editado')
+ })
+
+ it('ignores a response overtaken by a newer save', async () => {
+ let resolveFirst: (value: unknown) => void = () => {}
+ fetchMock.mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ resolveFirst = resolve
+ }),
+ )
+ fetchMock.mockResolvedValueOnce(success('segundo'))
+
+ const { result } = renderAutosave()
+
+ act(() => result.current.change('primeiro', DELAY))
+ await act(async () => void vi.advanceTimersByTime(DELAY))
+ act(() => result.current.change('segundo', DELAY))
+ await act(async () => void vi.advanceTimersByTime(DELAY))
+
+ await act(async () => resolveFirst(success('primeiro')))
+
+ expect(result.current.value).toBe('segundo')
+ expect(result.current.errorMessage).toBeNull()
+ })
+
+ it('reverts on failure and toasts only once the overlay is closed', async () => {
+ fetchMock.mockResolvedValue(
+ jsonResponse(
+ { status: 'error', message: 'Você não administra este município.' },
+ {
+ ok: false,
+ },
+ ),
+ )
+
+ const { result } = renderAutosave()
+
+ act(() => result.current.onOpenChange(true))
+ act(() => result.current.change('editado', DELAY))
+ await act(async () => void vi.advanceTimersByTime(DELAY))
+
+ expect(result.current.value).toBe('inicial')
+ expect(result.current.errorMessage).toBe('Você não administra este município.')
+ // Still open: the inline Alert is the closer channel.
+ expect(errorToast).not.toHaveBeenCalled()
+
+ act(() => result.current.change('outra', DELAY))
+ await act(async () => result.current.onOpenChange(false))
+
+ expect(errorToast).toHaveBeenCalledWith('Você não administra este município.')
+ expect(result.current.value).toBe('inicial')
+ })
+
+ it('collapses a failure without a message of its own into the caller error', async () => {
+ fetchMock.mockRejectedValue(new Error('network down'))
+
+ const { result } = renderAutosave()
+
+ act(() => result.current.change('editado', DELAY))
+ await act(async () => void vi.advanceTimersByTime(DELAY))
+
+ expect(result.current.errorMessage).toBe(ERROR_MESSAGE)
+ expect(result.current.value).toBe('inicial')
+ })
+
+ it('sends a revert issued while a different payload is in flight', async () => {
+ let settleFirst: (value: unknown) => void = () => {}
+ fetchMock.mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ settleFirst = resolve
+ }),
+ )
+ fetchMock.mockResolvedValueOnce(success('inicial'))
+
+ const { result } = renderAutosave()
+
+ act(() => result.current.change('editado', DELAY))
+ await act(async () => void vi.advanceTimersByTime(DELAY))
+ // Back to the value the server still holds, while `editado` is unanswered:
+ // "settled" has to mean the payload in flight, or nothing is sent and the
+ // first response repaints the cell with the value the user just undid.
+ act(() => result.current.change('inicial', DELAY))
+ await act(async () => void vi.advanceTimersByTime(DELAY))
+
+ await act(async () => settleFirst(success('editado')))
+
+ expect(requestBodies()).toEqual([{ value: 'editado' }, { value: 'inicial' }])
+ expect(result.current.value).toBe('inicial')
+ })
+
+ it('clears a past failure as soon as the value changes again', async () => {
+ fetchMock.mockResolvedValueOnce(
+ jsonResponse({ status: 'error', message: 'Recusado.' }, { ok: false }),
+ )
+
+ const { result } = renderAutosave()
+
+ act(() => result.current.onOpenChange(true))
+ act(() => result.current.change('editado', DELAY))
+ await act(async () => void vi.advanceTimersByTime(DELAY))
+ expect(result.current.statusMessage).toBe('Recusado.')
+
+ // `errorMessage` outranks everything in `statusMessage`, so leaving it set
+ // would announce the old failure over every later keystroke.
+ act(() => result.current.change('corrigido', DELAY))
+ expect(result.current.errorMessage).toBeNull()
+ expect(result.current.statusMessage).toBe('Alterações serão salvas automaticamente.')
+ })
+
+ it('resolves an updater against the latest value, not the render that scheduled it', async () => {
+ const { result } = renderAutosave()
+
+ act(() => {
+ result.current.change('a', DELAY)
+ result.current.change((current) => `${current}b`, DELAY)
+ })
+ await act(async () => void vi.advanceTimersByTime(DELAY))
+
+ expect(requestBodies()).toEqual([{ value: 'ab' }])
+ })
+
+ it('adopts a server value that changed from outside, without posting it back', async () => {
+ const { result, rerender } = renderAutosave()
+
+ await act(async () => rerender({ value: 'do servidor' }))
+
+ expect(result.current.value).toBe('do servidor')
+ expect(fetchMock).not.toHaveBeenCalled()
+
+ act(() => result.current.change('do servidor', DELAY))
+ await act(async () => void vi.advanceTimersByTime(DELAY))
+ expect(fetchMock).not.toHaveBeenCalled()
+ })
+})
diff --git a/tests/unit/campaignCellEditOverlay.unit.spec.ts b/tests/unit/campaignCellEditOverlay.unit.spec.ts
index 8c1c4cb9..910aec6d 100644
--- a/tests/unit/campaignCellEditOverlay.unit.spec.ts
+++ b/tests/unit/campaignCellEditOverlay.unit.spec.ts
@@ -5,6 +5,7 @@ import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest
import { MunicipalityListAdvisorsControl } from '@/components/campaign/municipality/MunicipalityListAdvisorsControl'
import { MunicipalityListExpectedVotesControl } from '@/components/campaign/municipality/MunicipalityListExpectedVotesControl'
import { MunicipalityListLevelControl } from '@/components/campaign/municipality/MunicipalityListLevelControl'
+import { MunicipalityListSignalControl } from '@/components/campaign/municipality/MunicipalityListSignalControl'
import { MunicipalityListTrendControl } from '@/components/campaign/municipality/MunicipalityListTrendControl'
import type { CampaignCellEditOverlayVariant } from '@/components/campaign/shared/CampaignCellEditOverlay'
import { MunicipalityPortfolioCell } from '@/components/campaign/shared/MunicipalityPortfolioCell'
@@ -31,6 +32,12 @@ type OverlayCase = {
triggerLabel: string
/** Text the Drawer header must carry beyond the município name. */
drawerTitle: string
+ /**
+ * Label of the sheet's primary footer action, for the cells whose commit is
+ * an explicit submit (B32+ F5). A footer REPLACES the default "Fechar", so
+ * the two assertions are exclusive.
+ */
+ footerLabel?: string
element: (variant: CampaignCellEditOverlayVariant) => ReactElement
}
@@ -80,6 +87,7 @@ const overlayCases: OverlayCase[] = [
name: 'nível de envolvimento',
triggerLabel: `Nível de envolvimento de ${MUNICIPALITY_NAME}: N3 · Rede + agenda`,
drawerTitle: 'Registrar nível de envolvimento',
+ footerLabel: 'Registrar movimento',
element: (variant) =>
createElement(MunicipalityListLevelControl, {
municipalityID: 1,
@@ -90,6 +98,25 @@ const overlayCases: OverlayCase[] = [
variant,
}),
},
+ {
+ name: 'sinal',
+ triggerLabel: `Registrar sinal em ${MUNICIPALITY_NAME} — Sem sinal`,
+ drawerTitle: 'Registrar sinal',
+ footerLabel: 'Registrar sinal',
+ element: (variant) =>
+ // `createElement`'s typing puts a required `children` in props, so the
+ // rest-argument form this rule prefers does not type-check here.
+ // eslint-disable-next-line react/no-children-prop
+ createElement(MunicipalityListSignalControl, {
+ municipalityID: 1,
+ municipalitySlug: 'feira-de-santana',
+ municipalityName: MUNICIPALITY_NAME,
+ lastSignalAt: null,
+ variant,
+ formAction: async () => ({}),
+ children: createElement('span', null, 'Sem sinal'),
+ }),
+ },
]
beforeAll(() => {
@@ -117,7 +144,7 @@ afterAll(() => {
describe('campaign cell edit overlay', () => {
it.each(overlayCases)(
'opens $name in a Drawer that names the município on touch',
- async ({ triggerLabel, drawerTitle, element }) => {
+ async ({ triggerLabel, drawerTitle, footerLabel, element }) => {
render(element('sheet'))
fireEvent.click(screen.getByRole('button', { name: triggerLabel }))
@@ -132,8 +159,16 @@ describe('campaign cell edit overlay', () => {
// the virtual keyboard covering the sheet before it can be read. Awaited
// because the advisors sheet mounts `cmdk`, which delays it by a tick.
await waitFor(() => expect(document.activeElement).toBe(title))
- // Swiping down is the gesture, but it is not an affordance anyone sees.
- expect(dialog.textContent).toContain('Fechar')
+ const footer = dialog.querySelector('[data-slot="drawer-footer"]')
+ if (footerLabel) {
+ // A footer replaces the default dismissal: the commit is the primary
+ // action and "Cancelar" is what stands in for "Fechar".
+ expect(footer?.textContent).toContain(footerLabel)
+ expect(footer?.textContent).not.toContain('Fechar')
+ } else {
+ // Swiping down is the gesture, but it is not an affordance anyone sees.
+ expect(footer?.textContent).toContain('Fechar')
+ }
expect(document.querySelector('[data-slot="popover-content"]')).toBeNull()
},
)
@@ -159,6 +194,121 @@ describe('campaign cell edit overlay', () => {
},
)
+ /**
+ * B32+ F4. The region used to live inside the overlay, and closing is exactly
+ * what commits a draft — so the announcement that mattered most was the one
+ * that unmounted before it could be read. It now sits beside the trigger,
+ * which outlives the dialog.
+ */
+ /**
+ * The other half of the same policy: a region mounted on every row would
+ * register ~250 polite live regions on one município page (5 controls × the
+ * card and table trees), so it appears with the first open — early enough for
+ * "Salvando…", which cannot happen before one.
+ */
+ it('does not mount the live region until the overlay has been opened once', () => {
+ render(
+ createElement(MunicipalityListTrendControl, {
+ municipalityID: 1,
+ municipalityName: MUNICIPALITY_NAME,
+ status: 'favoravel',
+ trendNote: null,
+ variant: 'popover',
+ }),
+ )
+
+ expect(document.querySelector('[aria-live="polite"]')).toBeNull()
+
+ fireEvent.click(
+ screen.getByRole('button', {
+ name: `Editar tendência política em ${MUNICIPALITY_NAME} — Favorável`,
+ }),
+ )
+
+ expect(document.querySelector('[aria-live="polite"]')).not.toBeNull()
+ })
+
+ it('keeps the live region mounted after the overlay closes', async () => {
+ const fetchMock = vi.fn(
+ () =>
+ new Promise(() => {
+ // Never settles: "Salvando…" is the state under test.
+ }),
+ )
+ vi.stubGlobal('fetch', fetchMock)
+
+ render(
+ createElement(MunicipalityListTrendControl, {
+ municipalityID: 1,
+ municipalityName: MUNICIPALITY_NAME,
+ status: 'favoravel',
+ trendNote: null,
+ variant: 'popover',
+ }),
+ )
+
+ const trigger = screen.getByRole('button', {
+ name: `Editar tendência política em ${MUNICIPALITY_NAME} — Favorável`,
+ })
+ fireEvent.click(trigger)
+
+ const select = await screen.findByLabelText('Tendência')
+ fireEvent.change(select, { target: { value: 'desfavoravel' } })
+ // Dismissing flushes the debounced save, and the dialog goes with it.
+ fireEvent.keyDown(document.activeElement ?? document.body, { key: 'Escape' })
+
+ await waitFor(() => expect(document.querySelector('[role="dialog"]')).toBeNull())
+ await waitFor(() => {
+ const region = document.querySelector('[aria-live="polite"]')
+ expect(region?.textContent).toBe('Salvando tendência.')
+ })
+ })
+
+ /**
+ * The sheet's submit sits in the footer, OUTSIDE the `` it belongs to —
+ * associated by the standard `form` attribute. This case is what says that
+ * association still reaches React's action (B32+ F5); without it the button
+ * would look right and do nothing.
+ */
+ it('submits the signal form from a button rendered in the sheet footer', async () => {
+ const formAction = vi.fn(async () => ({}))
+
+ render(
+ // `createElement`'s typing puts a required `children` in props, so the
+ // rest-argument form this rule prefers does not type-check here.
+ // eslint-disable-next-line react/no-children-prop
+ createElement(MunicipalityListSignalControl, {
+ municipalityID: 1,
+ municipalitySlug: 'feira-de-santana',
+ municipalityName: MUNICIPALITY_NAME,
+ lastSignalAt: null,
+ variant: 'sheet',
+ formAction,
+ children: createElement('span', null, 'Sem sinal'),
+ }),
+ )
+
+ fireEvent.click(
+ screen.getByRole('button', { name: `Registrar sinal em ${MUNICIPALITY_NAME} — Sem sinal` }),
+ )
+
+ const dialog = await screen.findByRole('dialog')
+ // Both selects are `required`: an invalid form never fires submit, and the
+ // case would pass for the wrong reason.
+ fireEvent.change(screen.getByLabelText('Tipo do sinal'), { target: { value: 'invasao' } })
+ fireEvent.change(screen.getByLabelText('Fonte'), { target: { value: 'Liderança local' } })
+
+ const submit = dialog.querySelector('[data-slot="drawer-footer"] button')
+ expect(submit?.textContent).toContain('Registrar sinal')
+ expect(submit?.getAttribute('form')).toBe(
+ dialog.querySelector('form')?.getAttribute('id') ?? null,
+ )
+
+ fireEvent.click(submit as HTMLButtonElement)
+
+ await waitFor(() => expect(formAction).toHaveBeenCalledTimes(1))
+ })
+
/**
* B34's cell is the one caller that is a sheet at EVERY viewport: it picks its
* surface by `pointer:`, not by width, so it has no `variant` to sweep and
diff --git a/tests/unit/campaignJsonMutationRoute.unit.spec.ts b/tests/unit/campaignJsonMutationRoute.unit.spec.ts
new file mode 100644
index 00000000..33629781
--- /dev/null
+++ b/tests/unit/campaignJsonMutationRoute.unit.spec.ts
@@ -0,0 +1,121 @@
+import { NextResponse } from 'next/server'
+import { describe, expect, it, vi } from 'vitest'
+import { z } from 'zod'
+
+import { CAMPAIGN_SESSION_EXPIRED_MESSAGE } from '@/utilities/campaignFormActionError'
+import { campaignJsonMutationRoute } from '@/utilities/campaignJsonMutationRoute'
+
+/**
+ * B32+ F2: the same-origin check used to be a line each of the five quick-edit
+ * routes wrote by hand, and the one that forgot it would have failed OPEN. It
+ * is now the wrapper's, so these cases guard it once for all of them — plus the
+ * error contract the routes had before the extraction, which must not have
+ * moved.
+ */
+const SAFE_MESSAGE = 'Você não administra este município.'
+const GENERIC_MESSAGE = 'Não foi possível salvar. Verifique seu acesso e tente novamente.'
+
+const bodySchema = z.object({ municipalityId: z.number().int().positive() })
+
+const buildRoute = (handler: (body: { municipalityId: number }) => Promise) =>
+ campaignJsonMutationRoute(
+ { bodySchema, safeMessages: [SAFE_MESSAGE], genericMessage: GENERIC_MESSAGE },
+ handler,
+ )
+
+const postRequest = (body: unknown, headers?: HeadersInit) =>
+ new Request('https://pt.jorgesolla.com.br/campanha/municipios/political-trend', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', ...headers },
+ body: typeof body === 'string' ? body : JSON.stringify(body),
+ })
+
+describe('campaignJsonMutationRoute', () => {
+ it('answers a cross-origin POST with 403 before parsing or calling the handler', async () => {
+ const handler = vi.fn()
+ const response = await buildRoute(handler)(
+ postRequest({ municipalityId: 1 }, { Origin: 'https://evil.example' }),
+ )
+
+ expect(response.status).toBe(403)
+ await expect(response.json()).resolves.toEqual({
+ status: 'error',
+ message: 'Requisição inválida.',
+ })
+ expect(handler).not.toHaveBeenCalled()
+ })
+
+ it('accepts an Origin matching the request URL', async () => {
+ const route = buildRoute(async ({ municipalityId }) =>
+ NextResponse.json({ status: 'success', municipalityId }),
+ )
+
+ const response = await route(
+ postRequest({ municipalityId: 7 }, { Origin: 'https://pt.jorgesolla.com.br' }),
+ )
+
+ expect(response.status).toBe(200)
+ await expect(response.json()).resolves.toEqual({ status: 'success', municipalityId: 7 })
+ })
+
+ it('answers a malformed body with the shared 400', async () => {
+ const handler = vi.fn()
+ const response = await buildRoute(handler)(postRequest('{ not json'))
+
+ expect(response.status).toBe(400)
+ await expect(response.json()).resolves.toEqual({
+ status: 'error',
+ message: 'Corpo da requisição inválido.',
+ })
+ expect(handler).not.toHaveBeenCalled()
+ })
+
+ it('collapses a schema violation into the route generic message, as before', async () => {
+ const handler = vi.fn()
+ const response = await buildRoute(handler)(postRequest({ municipalityId: 'abc' }))
+
+ expect(response.status).toBe(400)
+ await expect(response.json()).resolves.toEqual({
+ status: 'error',
+ message: GENERIC_MESSAGE,
+ })
+ expect(handler).not.toHaveBeenCalled()
+ })
+
+ it('lets a safe domain message through and collapses anything else', async () => {
+ const safe = await buildRoute(async () => {
+ throw new Error(SAFE_MESSAGE)
+ })(postRequest({ municipalityId: 1 }))
+
+ expect(safe.status).toBe(400)
+ await expect(safe.json()).resolves.toEqual({ status: 'error', message: SAFE_MESSAGE })
+
+ const leaky = await buildRoute(async () => {
+ throw new Error('column "engagement_level" does not exist')
+ })(postRequest({ municipalityId: 1 }))
+
+ expect(leaky.status).toBe(400)
+ await expect(leaky.json()).resolves.toEqual({ status: 'error', message: GENERIC_MESSAGE })
+ })
+
+ it('answers an expired session with 401', async () => {
+ const response = await buildRoute(async () => {
+ throw new Error(CAMPAIGN_SESSION_EXPIRED_MESSAGE)
+ })(postRequest({ municipalityId: 1 }))
+
+ expect(response.status).toBe(401)
+ await expect(response.json()).resolves.toEqual({
+ status: 'error',
+ message: CAMPAIGN_SESSION_EXPIRED_MESSAGE,
+ })
+ })
+
+ it('returns a handler response untouched, status included', async () => {
+ const response = await buildRoute(async () =>
+ NextResponse.json({ status: 'blocked', violations: [] }, { status: 409 }),
+ )(postRequest({ municipalityId: 1 }))
+
+ expect(response.status).toBe(409)
+ await expect(response.json()).resolves.toEqual({ status: 'blocked', violations: [] })
+ })
+})
diff --git a/tests/unit/codebaseConventions.unit.spec.ts b/tests/unit/codebaseConventions.unit.spec.ts
index 8a608161..abf9e44f 100644
--- a/tests/unit/codebaseConventions.unit.spec.ts
+++ b/tests/unit/codebaseConventions.unit.spec.ts
@@ -168,6 +168,46 @@ describe('campaign formActions convention', () => {
})
})
+describe('campaign JSON mutation route convention', () => {
+ // The cells' quick edits POST to cookie-authenticated route handlers, so each
+ // one needs the same-origin check. Written by hand it is a line a new route
+ // forgets, and forgetting it fails OPEN — hence the wrapper (B32+ F2), and
+ // hence this sweep, which is what stops the sixth route from opting out.
+ //
+ // It sweeps all of `src/app` and not just `(campaign)`: a cookie-authenticated
+ // endpoint written under `api/` would be exactly as exposed and exactly as
+ // invisible, so the exceptions are declared here with their reason instead of
+ // being implied by a folder.
+ const allowlist = new Map([
+ ['src/app/(payload)/api/[...slug]/route.ts', "re-export of Payload's own handler and auth"],
+ ['src/app/(payload)/api/graphql/route.ts', "re-export of Payload's own handler and auth"],
+ [
+ 'src/app/(frontend)/api/revalidate/route.ts',
+ 'server-to-server: authenticated by a secret header, deliberately callable cross-origin',
+ ],
+ ])
+
+ it('builds every POST route under src/app with campaignJsonMutationRoute', () => {
+ const offenders = walkSourceFiles(resolve(repoRoot, 'src/app'), ['.ts'])
+ .filter((file) => basename(file) === 'route.ts')
+ .map(repoPath)
+ .filter((path) => !allowlist.has(path))
+ .filter((path) => {
+ const source = readFileSync(resolve(repoRoot, path), 'utf8')
+ // GET-only handlers (manifest, service worker) have no cookie-authed
+ // mutation to guard — matched on the export and not the bare word, so a
+ // GET route whose comment says "POST" is not a false offender. On the
+ // positive side the binding is what is matched, not a mention of the
+ // wrapper, which stops a file from pairing one wrapped handler with a
+ // second hand-rolled one.
+ if (!/export (const|async function) POST\b/.test(source)) return false
+ return !/export const POST = campaignJsonMutationRoute\(/.test(source)
+ })
+
+ expect(offenders, 'build the handler with campaignJsonMutationRoute').toEqual([])
+ })
+})
+
describe('public site metadata global access', () => {
// An empty `metadata` global (fresh DB / poisoned unstable_cache) used to
// crash `next build` via raw field access. Every `getCachedGlobal('metadata')`