Files
TREK/client/tests/unit/mobile/trip/MFileTrashSheet.test.tsx
T
2026-07-29 11:55:31 +02:00

200 lines
9.2 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import MFileTrashSheet from '../../../../src/mobile/screens/trip/tabs/MFileTrashSheet'
import { filesApi } from '../../../../src/api/client'
import type { TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
import type { TripFile } from '../../../../src/types'
import { buildPlanner } from '../../../helpers/mobileTrip'
import { act, fireEvent, render, screen, waitFor, within } from '../../../helpers/render'
// FE-MOB-FTRASH-001 to FE-MOB-FTRASH-016
const TRASHED = [
{
id: 31, trip_id: 1, filename: 'old.pdf', original_name: 'old.pdf', mime_type: 'application/pdf',
file_size: 1024, url: '/api/trips/1/files/31/download', created_at: '2026-05-01T12:00:00.000Z',
},
{
id: 32, trip_id: 1, filename: 'shot.png', original_name: 'shot.png', mime_type: 'image/png',
url: '/api/trips/1/files/32/download', created_at: '2026-05-02T12:00:00.000Z',
},
] as unknown as TripFile[]
function renderSheet(open = true, plannerOverrides: Partial<TripPlanner> = {}) {
const planner = buildPlanner(plannerOverrides)
const onClose = vi.fn()
const view = render(<MFileTrashSheet planner={planner} open={open} onClose={onClose} />)
return { ...view, planner, onClose }
}
/** Waits for the lazy trash fetch to settle. */
async function settled() {
await waitFor(() => expect(document.querySelector('.animate-spin')).toBeNull())
}
describe('MFileTrashSheet', () => {
beforeEach(() => {
vi.spyOn(filesApi, 'list').mockResolvedValue({ files: TRASHED })
vi.spyOn(filesApi, 'restore').mockResolvedValue({})
vi.spyOn(filesApi, 'permanentDelete').mockResolvedValue({})
vi.spyOn(filesApi, 'emptyTrash').mockResolvedValue({})
})
afterEach(() => {
vi.restoreAllMocks()
})
it('FE-MOB-FTRASH-001: fetches nothing while closed', () => {
renderSheet(false)
expect(filesApi.list).not.toHaveBeenCalled()
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
it('FE-MOB-FTRASH-002: loads the trashed files on open and lists them with size and date', async () => {
renderSheet()
expect(filesApi.list).toHaveBeenCalledWith(1, true)
expect(document.querySelector('.animate-spin')).not.toBeNull()
await settled()
expect(screen.getByRole('dialog', { name: 'files.trash' })).toBeInTheDocument()
expect(screen.getByText('old.pdf')).toBeInTheDocument()
expect(screen.getByText('1.0 KB · May 1')).toBeInTheDocument()
// No file_size → only the date remains.
expect(screen.getByText('May 2')).toBeInTheDocument()
})
it('FE-MOB-FTRASH-003: shows the empty hint for an empty trash', async () => {
vi.mocked(filesApi.list).mockResolvedValue({ files: [] })
renderSheet()
await settled()
expect(screen.getByText('files.trashEmpty')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'files.emptyTrash' })).not.toBeInTheDocument()
})
it('FE-MOB-FTRASH-004: survives a failing fetch', async () => {
vi.mocked(filesApi.list).mockRejectedValue(new Error('offline'))
renderSheet()
await settled()
expect(screen.getByText('files.trashEmpty')).toBeInTheDocument()
})
it('FE-MOB-FTRASH-005: tolerates a response without a files array', async () => {
vi.mocked(filesApi.list).mockResolvedValue({})
renderSheet()
await settled()
expect(screen.getByText('files.trashEmpty')).toBeInTheDocument()
})
it('FE-MOB-FTRASH-006: restores a file, drops the row and refreshes the store', async () => {
const { planner } = renderSheet()
await settled()
fireEvent.click(screen.getAllByRole('button', { name: 'files.restore' })[0])
expect(filesApi.restore).toHaveBeenCalledWith(1, 31)
await waitFor(() => expect(screen.queryByText('old.pdf')).not.toBeInTheDocument())
expect(planner.tripActions.loadFiles).toHaveBeenCalledWith(1)
expect(planner.toast.success).toHaveBeenCalledWith('files.toast.restored')
})
it('FE-MOB-FTRASH-007: reports a failed restore and keeps the row', async () => {
vi.mocked(filesApi.restore).mockRejectedValue(new Error('nope'))
const { planner } = renderSheet()
await settled()
fireEvent.click(screen.getAllByRole('button', { name: 'files.restore' })[0])
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('files.toast.restoreError'))
expect(screen.getByText('old.pdf')).toBeInTheDocument()
})
it('FE-MOB-FTRASH-008: permanently deletes only after the confirm', async () => {
const { planner } = renderSheet()
await settled()
fireEvent.click(screen.getAllByRole('button', { name: 'common.delete' })[0])
expect(filesApi.permanentDelete).not.toHaveBeenCalled()
const confirm = screen.getByRole('dialog', { name: 'common.delete' })
expect(within(confirm).getByText('files.confirm.permanentDelete')).toBeInTheDocument()
fireEvent.click(within(confirm).getByRole('button', { name: 'common.delete' }))
expect(filesApi.permanentDelete).toHaveBeenCalledWith(1, 31)
await waitFor(() => expect(screen.queryByText('old.pdf')).not.toBeInTheDocument())
expect(planner.toast.success).toHaveBeenCalledWith('files.toast.deleted')
expect(planner.tripActions.loadFiles).not.toHaveBeenCalled()
})
it('FE-MOB-FTRASH-009: cancelling the confirm keeps the file', async () => {
renderSheet()
await settled()
fireEvent.click(screen.getAllByRole('button', { name: 'common.delete' })[0])
const confirm = screen.getByRole('dialog', { name: 'common.delete' })
fireEvent.click(within(confirm).getByRole('button', { name: 'common.cancel' }))
expect(filesApi.permanentDelete).not.toHaveBeenCalled()
expect(screen.getByText('old.pdf')).toBeInTheDocument()
})
it('FE-MOB-FTRASH-010: reports a failed permanent delete', async () => {
vi.mocked(filesApi.permanentDelete).mockRejectedValue(new Error('nope'))
const { planner } = renderSheet()
await settled()
fireEvent.click(screen.getAllByRole('button', { name: 'common.delete' })[0])
const confirm = screen.getByRole('dialog', { name: 'common.delete' })
fireEvent.click(within(confirm).getByRole('button', { name: 'common.delete' }))
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('files.toast.deleteError'))
expect(screen.getByText('old.pdf')).toBeInTheDocument()
})
it('FE-MOB-FTRASH-011: empties the whole trash after the confirm', async () => {
const { planner } = renderSheet()
await settled()
fireEvent.click(screen.getByRole('button', { name: 'files.emptyTrash' }))
const confirm = screen.getByRole('dialog', { name: 'files.emptyTrash' })
expect(within(confirm).getByText('files.confirm.emptyTrash')).toBeInTheDocument()
fireEvent.click(within(confirm).getByRole('button', { name: 'files.emptyTrash' }))
expect(filesApi.emptyTrash).toHaveBeenCalledWith(1)
await waitFor(() => expect(screen.getByText('files.trashEmpty')).toBeInTheDocument())
expect(planner.toast.success).toHaveBeenCalledWith('files.toast.trashEmptied')
})
it('FE-MOB-FTRASH-012: reports a failed empty', async () => {
vi.mocked(filesApi.emptyTrash).mockRejectedValue(new Error('nope'))
const { planner } = renderSheet()
await settled()
fireEvent.click(screen.getByRole('button', { name: 'files.emptyTrash' }))
const confirm = screen.getByRole('dialog', { name: 'files.emptyTrash' })
fireEvent.click(within(confirm).getByRole('button', { name: 'files.emptyTrash' }))
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('files.toast.deleteError'))
expect(screen.getByText('old.pdf')).toBeInTheDocument()
})
it('FE-MOB-FTRASH-013: a member without file_delete only gets to look', async () => {
renderSheet(true, { can: (perm: string) => perm !== 'file_delete' } as unknown as Partial<TripPlanner>)
await settled()
expect(screen.getByText('old.pdf')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'files.restore' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'common.delete' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'files.emptyTrash' })).not.toBeInTheDocument()
})
it('FE-MOB-FTRASH-014: closes through the header button', async () => {
const { onClose } = renderSheet()
await settled()
fireEvent.click(screen.getByRole('button', { name: 'common.close' }))
expect(onClose).toHaveBeenCalledTimes(1)
})
it('FE-MOB-FTRASH-015: a fetch that lands after unmount is dropped', async () => {
let release: (data: { files: TripFile[] }) => void = () => {}
vi.mocked(filesApi.list).mockImplementation(() => new Promise(resolve => { release = resolve }))
const { unmount } = renderSheet()
unmount()
await act(async () => { release({ files: TRASHED }) })
expect(screen.queryByText('old.pdf')).not.toBeInTheDocument()
})
it('FE-MOB-FTRASH-016: cancelling the empty-trash confirm keeps everything', async () => {
renderSheet()
await settled()
fireEvent.click(screen.getByRole('button', { name: 'files.emptyTrash' }))
const confirm = screen.getByRole('dialog', { name: 'files.emptyTrash' })
fireEvent.click(within(confirm).getByRole('button', { name: 'common.cancel' }))
expect(filesApi.emptyTrash).not.toHaveBeenCalled()
expect(screen.getByText('old.pdf')).toBeInTheDocument()
})
})