teqo-backup/tests/unit/campaignCellAutosave.unit.spec.ts
Francisco Ignácio Fontoura Solla 5f11960c3a B32+: share the campaign JSON route wrapper, fetch helper and cell auto-save.
`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.
2026-07-28 08:07:29 -03:00

269 lines
9.3 KiB
TypeScript

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<string, TestResponse>({
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()
})
})