teqo-backup/tests/unit/municipalityRelationEditor.unit.spec.tsx
Francisco Solla 23a061098a feat(campaign): serializa mutações do editor de relações por célula (Closes #378)
Fila encadeada por instância no MunicipalityRelationEditor: delta otimista
imediato + transporte serializado (próximo elo só dispara quando o anterior
assenta), falha reverte só o delta do próprio elo e a fila segue, busy
derivado da fila (nunca preso), drain aplica o último selectedIDs confirmado
só quando a resposta final o traz (JSON) — sem ele (dobradinhas) confia no
RSC. Substitui requestSeqRef/latestConfirmedRef-seq por fila; tempID de
create em contador próprio. Contratos dos wrappers e call sites intactos.
2026-08-24 18:00:20 -03:00

494 lines
19 KiB
TypeScript

import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { createElement } from 'react'
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import {
MunicipalityRelationEditor,
type MunicipalityRelationMutationResult,
} from '@/components/campaign/shared/MunicipalityRelationEditor'
import { TooltipProvider } from '@/components/ui/tooltip'
beforeAll(() => {
vi.stubGlobal(
'ResizeObserver',
class {
observe() {}
unobserve() {}
disconnect() {}
},
)
Element.prototype.scrollIntoView = () => {}
})
afterEach(cleanup)
afterAll(() => {
vi.unstubAllGlobals()
Reflect.deleteProperty(Element.prototype, 'scrollIntoView')
})
describe('MunicipalityRelationEditor mutation queue (B160)', () => {
it('serializes same-cell mutations: the second transport only fires after the first settles', async () => {
const pending: Array<(result: MunicipalityRelationMutationResult) => void> = []
const onToggle = vi.fn(
() =>
new Promise<MunicipalityRelationMutationResult>((resolve) => {
pending.push(resolve)
}),
)
render(
createElement(
TooltipProvider,
null,
createElement(MunicipalityRelationEditor, {
municipalityName: 'Seabra',
currentIDs: [],
options: [
{ id: 1, label: 'Álvaro Lima' },
{ id: 2, label: 'Beatriz Souza' },
],
variant: 'popover',
title: 'Gerenciar relações',
description: 'Descrição.',
searchPlaceholder: 'Buscar…',
searchLabel: 'Buscar relação',
savingMessage: 'Salvando relações.',
saveErrorMessage: 'Falha ao salvar.',
triggerLabel: (entries) => `Editar relações — ${entries.map((entry) => entry.label)}`,
emptyState: createElement('span', null, 'Nenhuma'),
createLabel: (name) => `Criar “${name}”`,
onToggle,
onCreate: vi.fn(),
}),
),
)
fireEvent.click(screen.getByRole('button', { name: /Editar relações/ }))
fireEvent.change(screen.getByLabelText('Buscar relação'), { target: { value: 'alv' } })
fireEvent.click(await screen.findByRole('option', { name: 'Álvaro Lima' }))
fireEvent.change(screen.getByLabelText('Buscar relação'), { target: { value: 'bea' } })
fireEvent.click(await screen.findByRole('option', { name: 'Beatriz Souza' }))
expect(onToggle).toHaveBeenCalledTimes(1)
expect(onToggle).toHaveBeenCalledWith(1, true)
expect(screen.getByLabelText('Salvando relações.')).toBeTruthy()
pending[0]?.({ status: 'success', selectedIDs: [1] })
await waitFor(() => expect(onToggle).toHaveBeenCalledTimes(2))
expect(onToggle).toHaveBeenCalledWith(2, true)
pending[1]?.({ status: 'success', selectedIDs: [1, 2] })
await waitFor(() =>
expect(screen.getByRole('button', { name: /Álvaro Lima,Beatriz Souza/ })).toBeTruthy(),
)
expect(screen.queryByLabelText('Salvando relações.')).toBeNull()
})
it('reverts only the failed mutation delta; later mutations still fire and persist', async () => {
const pending: Array<(result: MunicipalityRelationMutationResult) => void> = []
const onToggle = vi.fn(
() =>
new Promise<MunicipalityRelationMutationResult>((resolve) => {
pending.push(resolve)
}),
)
render(
createElement(
TooltipProvider,
null,
createElement(MunicipalityRelationEditor, {
municipalityName: 'Seabra',
currentIDs: [],
options: [
{ id: 1, label: 'Álvaro Lima' },
{ id: 2, label: 'Beatriz Souza' },
],
variant: 'popover',
title: 'Gerenciar relações',
description: 'Descrição.',
searchPlaceholder: 'Buscar…',
searchLabel: 'Buscar relação',
savingMessage: 'Salvando relações.',
saveErrorMessage: 'Falha ao salvar.',
triggerLabel: (entries) => `Editar relações — ${entries.map((entry) => entry.label)}`,
emptyState: createElement('span', null, 'Nenhuma'),
createLabel: (name) => `Criar “${name}”`,
onToggle,
onCreate: vi.fn(),
}),
),
)
fireEvent.click(screen.getByRole('button', { name: /Editar relações/ }))
fireEvent.change(screen.getByLabelText('Buscar relação'), { target: { value: 'alv' } })
fireEvent.click(await screen.findByRole('option', { name: 'Álvaro Lima' }))
fireEvent.change(screen.getByLabelText('Buscar relação'), { target: { value: 'bea' } })
fireEvent.click(await screen.findByRole('option', { name: 'Beatriz Souza' }))
pending[0]?.({ status: 'error', message: 'Falha ao salvar.' })
await waitFor(() => expect(onToggle).toHaveBeenCalledTimes(2))
expect(onToggle).toHaveBeenCalledWith(2, true)
await waitFor(() => expect(screen.queryByRole('button', { name: /Álvaro Lima/ })).toBeNull())
pending[1]?.({ status: 'success', selectedIDs: [2] })
await waitFor(() =>
expect(screen.getByRole('button', { name: /Editar relações — Beatriz Souza/ })).toBeTruthy(),
)
expect(screen.getAllByText('Falha ao salvar.').length).toBeGreaterThan(0)
expect(screen.queryByLabelText('Salvando relações.')).toBeNull()
})
it('applies the last confirmed state when the FINAL link fails (drain over the revert)', async () => {
const pending: Array<(result: MunicipalityRelationMutationResult) => void> = []
const onToggle = vi.fn(
() =>
new Promise<MunicipalityRelationMutationResult>((resolve) => {
pending.push(resolve)
}),
)
render(
createElement(
TooltipProvider,
null,
createElement(MunicipalityRelationEditor, {
municipalityName: 'Seabra',
currentIDs: [],
options: [
{ id: 1, label: 'Álvaro Lima' },
{ id: 2, label: 'Beatriz Souza' },
],
variant: 'popover',
title: 'Gerenciar relações',
description: 'Descrição.',
searchPlaceholder: 'Buscar…',
searchLabel: 'Buscar relação',
savingMessage: 'Salvando relações.',
saveErrorMessage: 'Falha ao salvar.',
triggerLabel: (entries) => `Editar relações — ${entries.map((entry) => entry.label)}`,
emptyState: createElement('span', null, 'Nenhuma'),
createLabel: (name) => `Criar “${name}”`,
onToggle,
onCreate: vi.fn(),
}),
),
)
fireEvent.click(screen.getByRole('button', { name: /Editar relações/ }))
fireEvent.change(screen.getByLabelText('Buscar relação'), { target: { value: 'alv' } })
fireEvent.click(await screen.findByRole('option', { name: 'Álvaro Lima' }))
fireEvent.change(screen.getByLabelText('Buscar relação'), { target: { value: 'bea' } })
fireEvent.click(await screen.findByRole('option', { name: 'Beatriz Souza' }))
pending[0]?.({ status: 'success', selectedIDs: [1] })
await waitFor(() => expect(onToggle).toHaveBeenCalledTimes(2))
pending[1]?.({ status: 'error', message: 'Falha ao salvar.' })
await waitFor(() => {
expect(screen.queryByRole('button', { name: 'Editar relações — Álvaro Lima' })).toBeTruthy()
expect(screen.queryByRole('button', { name: /Álvaro Lima,Beatriz/ })).toBeNull()
})
expect(screen.getAllByText('Falha ao salvar.').length).toBeGreaterThan(0)
expect(screen.queryByLabelText('Salvando relações.')).toBeNull()
})
it('reverts a failed create mid-queue and still fires the later link in order', async () => {
const pending: Array<(result: MunicipalityRelationMutationResult) => void> = []
const onToggle = vi.fn(
() =>
new Promise<MunicipalityRelationMutationResult>((resolve) => {
pending.push(resolve)
}),
)
const onCreate = vi.fn(
() =>
new Promise<MunicipalityRelationMutationResult>((resolve) => {
pending.push(resolve)
}),
)
render(
createElement(
TooltipProvider,
null,
createElement(MunicipalityRelationEditor, {
municipalityName: 'Seabra',
currentIDs: [],
options: [{ id: 1, label: 'Álvaro Lima' }],
variant: 'popover',
title: 'Gerenciar relações',
description: 'Descrição.',
searchPlaceholder: 'Buscar…',
searchLabel: 'Buscar relação',
savingMessage: 'Salvando relações.',
saveErrorMessage: 'Falha ao salvar.',
createErrorMessage: 'Falha ao criar.',
triggerLabel: (entries) => `Editar relações — ${entries.map((entry) => entry.label)}`,
emptyState: createElement('span', null, 'Nenhuma'),
createLabel: (name) => `Criar “${name}”`,
onToggle,
onCreate,
}),
),
)
fireEvent.click(screen.getByRole('button', { name: /Editar relações/ }))
fireEvent.change(screen.getByLabelText('Buscar relação'), { target: { value: 'alv' } })
fireEvent.click(await screen.findByRole('option', { name: 'Álvaro Lima' }))
fireEvent.change(screen.getByLabelText('Buscar relação'), {
target: { value: 'Nova Pessoa' },
})
fireEvent.click(await screen.findByRole('option', { name: /Criar “Nova Pessoa”/ }))
expect(onToggle).toHaveBeenCalledTimes(1)
expect(onCreate).not.toHaveBeenCalled()
pending[0]?.({ status: 'success', selectedIDs: [1] })
await waitFor(() => expect(onCreate).toHaveBeenCalledTimes(1))
expect(onCreate).toHaveBeenCalledWith('Nova Pessoa')
pending[1]?.({ status: 'error', message: 'Falha ao criar.' })
await waitFor(() => {
expect(screen.queryByRole('button', { name: 'Editar relações — Álvaro Lima' })).toBeTruthy()
expect(screen.queryByRole('button', { name: /Nova Pessoa/ })).toBeNull()
})
expect(screen.getAllByText('Falha ao criar.').length).toBeGreaterThan(0)
expect(screen.queryByLabelText('Salvando relações.')).toBeNull()
})
it('keeps optimistic state when the final response carries no selectedIDs (dobradinhas rely on RSC)', async () => {
const pending: Array<(result: MunicipalityRelationMutationResult) => void> = []
const onToggle = vi.fn(
() =>
new Promise<MunicipalityRelationMutationResult>((resolve) => {
pending.push(resolve)
}),
)
const element = (currentIDs: number[]) =>
createElement(
TooltipProvider,
null,
createElement(MunicipalityRelationEditor, {
municipalityName: 'Seabra',
currentIDs,
options: [{ id: 1, label: 'Álvaro Lima' }],
variant: 'popover',
title: 'Gerenciar relações',
description: 'Descrição.',
searchPlaceholder: 'Buscar…',
searchLabel: 'Buscar relação',
savingMessage: 'Salvando relações.',
saveErrorMessage: 'Falha ao salvar.',
triggerLabel: (entries) => `Editar relações — ${entries.map((entry) => entry.label)}`,
emptyState: createElement('span', null, 'Nenhuma'),
createLabel: (name) => `Criar “${name}”`,
onToggle,
onCreate: vi.fn(),
}),
)
const view = render(element([]))
fireEvent.click(screen.getByRole('button', { name: /Editar relações/ }))
fireEvent.change(screen.getByLabelText('Buscar relação'), { target: { value: 'alv' } })
fireEvent.click(await screen.findByRole('option', { name: 'Álvaro Lima' }))
pending[0]?.({ status: 'success' })
await waitFor(() =>
expect(screen.getByRole('button', { name: /Editar relações — Álvaro Lima/ })).toBeTruthy(),
)
view.rerender(element([1]))
expect(screen.getByRole('button', { name: /Editar relações — Álvaro Lima/ })).toBeTruthy()
expect(screen.queryByLabelText('Salvando relações.')).toBeNull()
})
it('keeps independent cells in parallel: two editors fire transports concurrently', async () => {
const pending: Array<Array<(result: MunicipalityRelationMutationResult) => void>> = [[], []]
const onToggleA = vi.fn(
() =>
new Promise<MunicipalityRelationMutationResult>((resolve) => {
pending[0].push(resolve)
}),
)
const onToggleB = vi.fn(
() =>
new Promise<MunicipalityRelationMutationResult>((resolve) => {
pending[1].push(resolve)
}),
)
const editor = (onToggle: typeof onToggleA) =>
createElement(
TooltipProvider,
null,
createElement(MunicipalityRelationEditor, {
municipalityName: 'Seabra',
currentIDs: [],
options: [{ id: 1, label: 'Álvaro Lima' }],
variant: 'popover',
title: 'Gerenciar relações',
description: 'Descrição.',
searchPlaceholder: 'Buscar…',
searchLabel: 'Buscar relação',
savingMessage: 'Salvando relações.',
saveErrorMessage: 'Falha ao salvar.',
triggerLabel: (entries) => `Editar relações — ${entries.map((entry) => entry.label)}`,
emptyState: createElement('span', null, 'Nenhuma'),
createLabel: (name) => `Criar “${name}”`,
onToggle,
onCreate: vi.fn(),
}),
)
render(createElement('div', null, editor(onToggleA), editor(onToggleB)))
const triggers = screen.getAllByRole('button', { name: /Editar relações/ })
fireEvent.click(triggers[0])
fireEvent.click(await screen.findByRole('option', { name: 'Álvaro Lima' }))
fireEvent.click(triggers[0])
fireEvent.click(triggers[1])
fireEvent.click(await screen.findByRole('option', { name: 'Álvaro Lima' }))
expect(onToggleA).toHaveBeenCalledTimes(1)
expect(onToggleB).toHaveBeenCalledTimes(1)
pending[0][0]?.({ status: 'success', selectedIDs: [1] })
pending[1][0]?.({ status: 'success', selectedIDs: [1] })
await waitFor(() => {
expect(screen.getAllByRole('button', { name: /Editar relações — Álvaro Lima/ })).toHaveLength(
2,
)
})
})
it('keeps the pending confirmation when server props are adopted mid-flight (guard !drainingRef)', async () => {
const pending: Array<(result: MunicipalityRelationMutationResult) => void> = []
const onToggle = vi.fn(
() =>
new Promise<MunicipalityRelationMutationResult>((resolve) => {
pending.push(resolve)
}),
)
const element = (currentIDs: number[]) =>
createElement(
TooltipProvider,
null,
createElement(MunicipalityRelationEditor, {
municipalityName: 'Seabra',
currentIDs,
options: [{ id: 1, label: 'Álvaro Lima' }],
variant: 'popover',
title: 'Gerenciar relações',
description: 'Descrição.',
searchPlaceholder: 'Buscar…',
searchLabel: 'Buscar relação',
savingMessage: 'Salvando relações.',
saveErrorMessage: 'Falha ao salvar.',
triggerLabel: (entries) => `Editar relações — ${entries.map((entry) => entry.label)}`,
emptyState: createElement('span', null, 'Nenhuma'),
createLabel: (name) => `Criar “${name}”`,
onToggle,
onCreate: vi.fn(),
}),
)
const view = render(element([]))
fireEvent.click(screen.getByRole('button', { name: /Editar relações/ }))
fireEvent.change(screen.getByLabelText('Buscar relação'), { target: { value: 'alv' } })
fireEvent.click(await screen.findByRole('option', { name: 'Álvaro Lima' }))
view.rerender(element([9]))
pending[0]?.({ status: 'success', selectedIDs: [1] })
await waitFor(() =>
expect(screen.getByRole('button', { name: /Editar relações — Álvaro Lima/ })).toBeTruthy(),
)
})
it('does not duplicate a create adopted by server props before the request resolves', async () => {
let resolveCreate: ((result: MunicipalityRelationMutationResult) => void) | undefined
const onCreate = vi.fn(
() =>
new Promise<MunicipalityRelationMutationResult>((resolve) => {
resolveCreate = resolve
}),
)
const element = (currentIDs: number[], options: Array<{ id: number; label: string }>) =>
createElement(
TooltipProvider,
null,
createElement(MunicipalityRelationEditor, {
municipalityName: 'Seabra',
currentIDs,
options,
variant: 'popover',
title: 'Gerenciar relações',
description: 'Descrição.',
searchPlaceholder: 'Buscar…',
searchLabel: 'Buscar relação',
savingMessage: 'Salvando relações.',
saveErrorMessage: 'Falha ao salvar.',
triggerLabel: (entries) => `Editar relações — ${entries.map((entry) => entry.label)}`,
emptyState: createElement('span', null, 'Nenhuma'),
createLabel: (name) => `Criar “${name}”`,
onToggle: vi.fn(),
onCreate,
}),
)
const view = render(element([], []))
fireEvent.click(screen.getByRole('button', { name: /Editar relações/ }))
fireEvent.change(screen.getByLabelText('Buscar relação'), { target: { value: 'Nova Pessoa' } })
fireEvent.click(await screen.findByRole('option', { name: /Criar “Nova Pessoa”/ }))
view.rerender(element([7], [{ id: 7, label: 'Nova Pessoa' }]))
resolveCreate?.({
status: 'success',
selectedIDs: [7],
createdEntry: { id: 7, label: 'Nova Pessoa' },
})
await waitFor(() => {
const trigger = screen.getByRole('button', { name: /Editar relações — Nova Pessoa/ })
expect(trigger.querySelectorAll('[data-slot="avatar"]')).toHaveLength(1)
})
})
it('uses known entries for display without making them selectable', async () => {
render(
createElement(
TooltipProvider,
null,
createElement(MunicipalityRelationEditor, {
municipalityName: 'Seabra',
currentIDs: [7],
knownEntries: [{ id: 7, label: 'Fora da elegibilidade' }],
options: [],
variant: 'popover',
title: 'Gerenciar relações',
description: 'Descrição.',
searchPlaceholder: 'Buscar…',
searchLabel: 'Buscar relação',
savingMessage: 'Salvando relações.',
saveErrorMessage: 'Falha ao salvar.',
triggerLabel: (entries) => `Editar relações — ${entries.map((entry) => entry.label)}`,
emptyState: createElement('span', null, 'Nenhuma'),
createLabel: (name) => `Criar “${name}”`,
onToggle: vi.fn(),
onCreate: vi.fn(),
}),
),
)
fireEvent.click(screen.getByRole('button', { name: /Fora da elegibilidade/ }))
fireEvent.change(screen.getByLabelText('Buscar relação'), {
target: { value: 'Fora da elegibilidade' },
})
expect(screen.queryByRole('option', { name: 'Fora da elegibilidade' })).toBeNull()
})
})