mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 06:43:06 +00:00
`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.
269 lines
9.3 KiB
TypeScript
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()
|
|
})
|
|
})
|