mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
A lista /campanha/comunicacao/conteudos ganha um modo de seleção opt-in que envolve os dois corpos (tabela e card list) com um estado client compartilhado, chaveado pela URL canônica (a seleção morre em página/filtro e nunca cresce para além da página visível). Com seleção ativa, a barra flutuante aplica o mesmo veredito unitário à seleção: Publicar/Despublicar agem direto com recibo honesto; Apagar sempre confirma no CampaignDeleteDialog reusado (novo caminho onConfirm aditivo), avisando os links públicos só quando a seleção carrega publicadas. O transporte é uma rota única (POST .../conteudos/lote, campaignJsonMutationRoute, teto de 50 ids) que itera server-side as ações donas: cada peça mantém a própria transação, o gate fresco e a limpeza best-effort de mídia; a falha de uma nunca reverte as demais e o recibo separa afetadas x falhas (2 de 3 peças apagadas. 1 falhou.). Sem migration, sem Consent novo, sem rota pública (S27) e sem mudar a semântica do C222. Cobertura: int do lote (papéis, dedupe, mídia, evento, tag, falha parcial, schema), unit do builder/barra/seleção e e2e HTTP. Design tier: DEGRADED (artefato de origem sem certificação frontier) — crítica do designer aplicada no escopo do item; entrega bloqueada para sign-off humano antes do PR. Related #1388
270 lines
9.3 KiB
TypeScript
270 lines
9.3 KiB
TypeScript
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
const { routerMock } = vi.hoisted(() => ({
|
|
routerMock: { push: vi.fn(), refresh: vi.fn() },
|
|
}))
|
|
|
|
vi.mock('next/navigation', () => ({ useRouter: () => routerMock }))
|
|
|
|
import { ContentPieceBatchReceipt } from '@/components/campaign/content/ContentPieceBatchReceipt'
|
|
import { ContentPieceSelectionBar } from '@/components/campaign/content/ContentPieceSelectionBar'
|
|
import {
|
|
ContentPieceCardSelectionFrame,
|
|
ContentPieceRowCheckbox,
|
|
ContentPieceSelectAllCheckbox,
|
|
ContentPieceSelectionModeControl,
|
|
} from '@/components/campaign/content/ContentPieceSelectionControls'
|
|
import { ContentPieceSelectionProvider } from '@/components/campaign/content/ContentPieceSelectionProvider'
|
|
import { CAMPAIGN_CONTENT_PIECE_BATCH_HREF } from '@/lib/campaignPaths'
|
|
import type { ContentPieceSelectionRow } from '@/lib/contentPieceBatch'
|
|
|
|
const harnessRows = [
|
|
{ id: 1, title: 'Peça publicada', status: 'publicado', publicPath: '/conteudos/peca-publicada' },
|
|
{ id: 2, title: 'Peça em rascunho', status: 'rascunho', publicPath: null },
|
|
{ id: 3, title: 'Peça com falha', status: 'rascunho', publicPath: null },
|
|
] as const
|
|
|
|
const rows: ContentPieceSelectionRow[] = harnessRows.map(({ id, status, publicPath }) => ({
|
|
id,
|
|
status,
|
|
publicPath,
|
|
}))
|
|
|
|
const fetchMock = vi.fn()
|
|
const jsonResponse = (payload: unknown, ok = true) => ({ ok, json: async () => payload })
|
|
|
|
const renderHarness = () => {
|
|
render(
|
|
<ContentPieceSelectionProvider rows={rows}>
|
|
<ContentPieceSelectionModeControl />
|
|
<ContentPieceBatchReceipt />
|
|
<ContentPieceSelectAllCheckbox />
|
|
{harnessRows.map((row) => (
|
|
<ContentPieceRowCheckbox key={row.id} contentPieceId={row.id} title={row.title} />
|
|
))}
|
|
<ContentPieceSelectionBar />
|
|
</ContentPieceSelectionProvider>,
|
|
)
|
|
}
|
|
|
|
const enterSelection = () => fireEvent.click(screen.getByRole('button', { name: 'Selecionar' }))
|
|
|
|
const check = (name: string) => fireEvent.click(screen.getByRole('checkbox', { name }))
|
|
|
|
const press = (name: string) => fireEvent.click(screen.getByRole('button', { name }))
|
|
|
|
const openDeleteDialog = async () => {
|
|
press('Apagar')
|
|
return screen.findByRole('alertdialog')
|
|
}
|
|
|
|
const selectionStatus = () => screen.getByRole('status').textContent
|
|
|
|
beforeEach(() => {
|
|
vi.stubGlobal(
|
|
'ResizeObserver',
|
|
class {
|
|
observe() {}
|
|
unobserve() {}
|
|
disconnect() {}
|
|
},
|
|
)
|
|
vi.stubGlobal('fetch', fetchMock)
|
|
routerMock.push.mockReset()
|
|
routerMock.refresh.mockReset()
|
|
fetchMock.mockReset()
|
|
})
|
|
|
|
afterEach(() => {
|
|
cleanup()
|
|
vi.unstubAllGlobals()
|
|
})
|
|
|
|
describe('ContentPieceSelection (C236)', () => {
|
|
it('enters selection mode opt-in, counts the selection and opens the bar only with one', () => {
|
|
renderHarness()
|
|
|
|
expect(screen.queryByText('Modo de seleção')).toBeNull()
|
|
enterSelection()
|
|
expect(screen.getByText('Modo de seleção')).toBeDefined()
|
|
expect(screen.queryByRole('status')).toBeNull()
|
|
|
|
check('Selecionar Peça publicada')
|
|
expect(selectionStatus()).toContain('1 selecionada')
|
|
|
|
check('Selecionar Peça em rascunho')
|
|
expect(selectionStatus()).toContain('2 selecionadas')
|
|
})
|
|
|
|
it('selects and clears the whole visible page from the header checkbox', () => {
|
|
renderHarness()
|
|
enterSelection()
|
|
|
|
fireEvent.click(
|
|
screen.getByRole('checkbox', { name: 'Selecionar todas as peças desta página' }),
|
|
)
|
|
expect(selectionStatus()).toContain('3 selecionadas')
|
|
|
|
fireEvent.click(
|
|
screen.getByRole('checkbox', { name: 'Selecionar todas as peças desta página' }),
|
|
)
|
|
expect(screen.queryByRole('status')).toBeNull()
|
|
})
|
|
|
|
it('publishes the selection in one request, shows the receipt and clears the selection', async () => {
|
|
fetchMock.mockResolvedValue(
|
|
jsonResponse({
|
|
status: 'success',
|
|
outcome: { action: 'publicar', affected: 2, failures: [] },
|
|
}),
|
|
)
|
|
renderHarness()
|
|
enterSelection()
|
|
check('Selecionar Peça em rascunho')
|
|
check('Selecionar Peça com falha')
|
|
|
|
press('Publicar')
|
|
|
|
expect(await screen.findByText('2 peças publicadas')).toBeDefined()
|
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
CAMPAIGN_CONTENT_PIECE_BATCH_HREF,
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
body: JSON.stringify({ action: 'publicar', contentPieceIds: [2, 3] }),
|
|
}),
|
|
)
|
|
await waitFor(() => expect(routerMock.refresh).toHaveBeenCalled())
|
|
expect(screen.queryByText('2 selecionadas')).toBeNull()
|
|
expect(screen.getByText('Modo de seleção')).toBeDefined()
|
|
})
|
|
|
|
it('keeps the selection and answers in the list when the whole request fails', async () => {
|
|
fetchMock.mockResolvedValue({
|
|
ok: false,
|
|
json: async () => ({ status: 'error', message: 'Sessão expirada. Entre novamente.' }),
|
|
})
|
|
renderHarness()
|
|
enterSelection()
|
|
check('Selecionar Peça publicada')
|
|
|
|
press('Publicar')
|
|
|
|
expect(await screen.findByRole('alert')).toHaveProperty(
|
|
'textContent',
|
|
expect.stringContaining('Sessão expirada. Entre novamente.'),
|
|
)
|
|
expect(selectionStatus()).toContain('1 selecionada')
|
|
expect(routerMock.refresh).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('warns about the public links only when the selection carries published pieces', async () => {
|
|
renderHarness()
|
|
enterSelection()
|
|
|
|
check('Selecionar Peça publicada')
|
|
let dialog = await openDeleteDialog()
|
|
expect(within(dialog).getByText('Apagar esta peça?')).toBeDefined()
|
|
expect(within(dialog).getByText('/conteudos/peca-publicada')).toBeDefined()
|
|
fireEvent.click(within(dialog).getByRole('button', { name: 'Cancelar' }))
|
|
|
|
check('Selecionar Peça em rascunho')
|
|
dialog = await openDeleteDialog()
|
|
expect(within(dialog).getByText('Apagar 2 peças selecionadas?')).toBeDefined()
|
|
expect(within(dialog).getByText(/deixam de funcionar para quem já recebeu/)).toBeDefined()
|
|
fireEvent.click(within(dialog).getByRole('button', { name: 'Cancelar' }))
|
|
|
|
check('Selecionar Peça publicada')
|
|
check('Selecionar Peça com falha')
|
|
dialog = await openDeleteDialog()
|
|
expect(within(dialog).getByText('Apagar 2 peças selecionadas?')).toBeDefined()
|
|
expect(within(dialog).getByText('Esta ação não pode ser desfeita.')).toBeDefined()
|
|
})
|
|
|
|
it('deletes the selection after the confirmation and names the partial failure', async () => {
|
|
fetchMock.mockResolvedValue(
|
|
jsonResponse({
|
|
status: 'success',
|
|
outcome: {
|
|
action: 'apagar',
|
|
affected: 2,
|
|
failures: [{ contentPieceId: 3, message: 'Peça não encontrada.' }],
|
|
},
|
|
}),
|
|
)
|
|
renderHarness()
|
|
enterSelection()
|
|
check('Selecionar Peça em rascunho')
|
|
check('Selecionar Peça com falha')
|
|
|
|
const dialog = await openDeleteDialog()
|
|
fireEvent.click(within(dialog).getByRole('button', { name: 'Apagar' }))
|
|
|
|
expect(await screen.findByRole('alert')).toHaveProperty(
|
|
'textContent',
|
|
expect.stringContaining('2 de 3 peças apagadas. 1 falhou.'),
|
|
)
|
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
CAMPAIGN_CONTENT_PIECE_BATCH_HREF,
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
body: JSON.stringify({ action: 'apagar', contentPieceIds: [2, 3] }),
|
|
}),
|
|
)
|
|
await waitFor(() => expect(routerMock.refresh).toHaveBeenCalled())
|
|
})
|
|
|
|
it('keeps the delete failure inside the dialog, with the selection intact', async () => {
|
|
fetchMock.mockResolvedValue(
|
|
jsonResponse({ status: 'error', message: 'Peça não encontrada.' }, false),
|
|
)
|
|
renderHarness()
|
|
enterSelection()
|
|
check('Selecionar Peça publicada')
|
|
check('Selecionar Peça em rascunho')
|
|
|
|
const dialog = await openDeleteDialog()
|
|
fireEvent.click(within(dialog).getByRole('button', { name: 'Apagar' }))
|
|
|
|
expect(await within(dialog).findByText('Peça não encontrada.')).toBeDefined()
|
|
expect(screen.queryByText('2 de 3 peças apagadas. 1 falhou.')).toBeNull()
|
|
expect(within(dialog).getByText('Apagar 2 peças selecionadas?')).toBeDefined()
|
|
expect(routerMock.refresh).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('leaves the selection mode from the top control', () => {
|
|
renderHarness()
|
|
enterSelection()
|
|
check('Selecionar Peça publicada')
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Cancelar seleção' }))
|
|
|
|
expect(screen.queryByText('Modo de seleção')).toBeNull()
|
|
expect(screen.queryByRole('status')).toBeNull()
|
|
expect(screen.getByRole('button', { name: 'Selecionar' })).toBeDefined()
|
|
})
|
|
|
|
it('selects from the mobile card frame with the one shared state', () => {
|
|
render(
|
|
<ContentPieceSelectionProvider rows={rows}>
|
|
<ContentPieceSelectionModeControl />
|
|
<ContentPieceCardSelectionFrame
|
|
contentPieceId={1}
|
|
title="Peça publicada"
|
|
header={<p>Peça publicada</p>}
|
|
>
|
|
<p>Corpo do card</p>
|
|
</ContentPieceCardSelectionFrame>
|
|
<ContentPieceSelectionBar />
|
|
</ContentPieceSelectionProvider>,
|
|
)
|
|
|
|
// At rest the frame is the exact card, with no checkbox to scan past.
|
|
expect(screen.queryByRole('checkbox')).toBeNull()
|
|
expect(screen.getByText('Corpo do card')).toBeDefined()
|
|
|
|
enterSelection()
|
|
check('Selecionar Peça publicada')
|
|
expect(selectionStatus()).toContain('1 selecionada')
|
|
})
|
|
})
|