Files
TREK/client/tests/unit/mobile/trip/MFilesTab.test.tsx
T
Maurice 885896aba2 feat(files): FILE_UPLOAD_LIMIT_MB sets the upload limit for files and notes
The 50 MB limit for files, booking attachments and note attachments becomes FILE_UPLOAD_LIMIT_MB, 50 by default. The server hands it to the client through the app config, so the upload dialogs refuse a file over it up front and say what the limit is; on desktop a file over it no longer vanishes without a word. (#1364)
2026-10-01 11:56:14 +02:00

403 lines
18 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import MFilesTab from '../../../../src/mobile/screens/trip/tabs/MFilesTab'
import { useAuthStore } from '../../../../src/store/authStore'
import { filesApi } from '../../../../src/api/client'
import { openFile } from '../../../../src/utils/fileDownload'
import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
import type { Place, TripFile } from '../../../../src/types'
import { buildPlanner, buildShell, buildTripActions } from '../../../helpers/mobileTrip'
import { act, fireEvent, render, screen, waitFor } from '../../../helpers/render'
// FE-MOB-FTAB-001 to FE-MOB-FTAB-025
vi.mock('../../../../src/utils/fileDownload', () => ({
downloadFile: vi.fn(async () => undefined),
openFile: vi.fn(async () => undefined),
}))
// The four overlays are covered by their own suites — stubbed here so this one
// asserts the tab's own wiring.
vi.mock('../../../../src/mobile/screens/trip/tabs/MFileMenuSheet', () => ({
default: ({ file, onClose, onOpenLinks }: {
file: TripFile | null
onClose: () => void
onOpenLinks: (f: TripFile) => void
}) => file ? (
<div data-testid="menu-sheet" data-file={String(file.id)}>
<button type="button" onClick={onClose}>menu-close</button>
<button type="button" onClick={() => onOpenLinks(file)}>menu-links</button>
</div>
) : null,
}))
vi.mock('../../../../src/mobile/screens/trip/tabs/MFileLinkSheet', () => ({
default: ({ file, onClose }: { file: TripFile | null; onClose: () => void }) => file ? (
<div data-testid="link-sheet" data-file={String(file.id)}>
<button type="button" onClick={onClose}>link-close</button>
</div>
) : null,
}))
vi.mock('../../../../src/mobile/screens/trip/tabs/MFileTrashSheet', () => ({
default: ({ open, onClose }: { open: boolean; onClose: () => void }) => open ? (
<div data-testid="trash-sheet">
<button type="button" onClick={onClose}>trash-close</button>
</div>
) : null,
}))
vi.mock('../../../../src/mobile/screens/trip/tabs/MFileLightbox', () => ({
default: ({ files, index, onIndexChange, onClose }: {
files: TripFile[]
index: number
onIndexChange: (i: number) => void
onClose: () => void
}) => (
<div data-testid="lightbox" data-index={String(index)} data-count={String(files.length)}>
<span>{files[index]?.original_name}</span>
<button type="button" onClick={() => onIndexChange(index + 1)}>lb-next</button>
<button type="button" onClick={onClose}>lb-close</button>
</div>
),
}))
const PLACES = [{ id: 11, name: 'Fushimi Inari' }] as unknown as Place[]
function tripFile(overrides: Partial<TripFile>): TripFile {
return {
trip_id: 1, filename: 'f', original_name: 'f', mime_type: 'application/octet-stream',
url: '/api/trips/1/files/0/download', created_at: '2026-05-01T12:00:00.000Z',
...overrides,
} as unknown as TripFile
}
const GUIDE = tripFile({
id: 1, filename: 'guide.pdf', original_name: 'guide.pdf', mime_type: 'application/pdf',
file_size: 2048, uploaded_by_name: 'ada', place_id: 11, url: '/api/trips/1/files/1/download',
})
const BEACH = tripFile({
id: 2, filename: 'beach.jpg', original_name: 'beach.jpg', mime_type: 'image/jpeg',
starred: 1, url: '/api/trips/1/files/2/download',
})
const NOTES = tripFile({ id: 3, filename: 'notes.txt', original_name: 'notes.txt', mime_type: 'text/plain' })
const PASS = tripFile({ id: 4, filename: 'board.pkpass', original_name: 'board.pkpass', mime_type: '' })
const IDEA = tripFile({
id: 5, filename: 'idea.png', original_name: 'idea.png', mime_type: 'image/png', note_id: 9,
})
const FILES = [GUIDE, BEACH, NOTES, PASS, IDEA]
const NAME_RE = /^(guide\.pdf|beach\.jpg|notes\.txt|board\.pkpass|idea\.png)$/
function renderTab(plannerOverrides: Partial<TripPlanner> = {}, shellOverrides: Partial<MTripShellApi> = {}) {
const planner = buildPlanner({ files: FILES, places: PLACES, ...plannerOverrides } as unknown as Partial<TripPlanner>)
const shell = buildShell(shellOverrides)
const view = render(<MFilesTab planner={planner} shell={shell} />)
const rerenderWith = (nextShell: Partial<MTripShellApi>) =>
view.rerender(<MFilesTab planner={planner} shell={buildShell({ ...shellOverrides, ...nextShell })} />)
return { ...view, planner, shell, rerenderWith }
}
function tile(labelKey: string): HTMLButtonElement {
return screen.getByText(labelKey).closest('button') as HTMLButtonElement
}
function rowNames(): string[] {
return screen.queryAllByText(NAME_RE).map(n => n.textContent as string)
}
function hiddenInput(container: HTMLElement): HTMLInputElement {
return container.querySelector('input[type="file"]') as HTMLInputElement
}
function pasteTarget(container: HTMLElement): HTMLElement {
return container.querySelector('[tabindex="-1"]') as HTMLElement
}
function makeFile(name: string, type: string, size = 10): File {
const f = new File(['x'], name, { type })
Object.defineProperty(f, 'size', { value: size })
return f
}
describe('MFilesTab', () => {
beforeEach(() => {
vi.spyOn(filesApi, 'toggleStar').mockResolvedValue({})
vi.mocked(openFile).mockClear()
vi.mocked(openFile).mockResolvedValue(undefined)
})
afterEach(() => {
vi.restoreAllMocks()
})
it('FE-MOB-FTAB-001: shows the mascot empty state and no filter grid without files', () => {
renderTab({ files: [] } as unknown as Partial<TripPlanner>)
expect(screen.getByText('mobileTrip.filesEmpty')).toBeInTheDocument()
expect(screen.queryByText('files.filterAll')).not.toBeInTheDocument()
})
it('FE-MOB-FTAB-002: counts every filter tile over the whole file list', () => {
renderTab()
expect(tile('files.filterAll').textContent).toBe('files.filterAll5')
expect(tile('files.filterPdf').textContent).toBe('files.filterPdf1')
expect(tile('files.filterImages').textContent).toBe('files.filterImages2')
expect(tile('files.filterDocs').textContent).toBe('files.filterDocs2')
expect(tile('files.filterStarred').textContent).toBe('files.filterStarred1')
expect(tile('files.filterCollab').textContent).toBe('files.filterCollab1')
})
it('FE-MOB-FTAB-003: drops the collab tile when no file came from a note', () => {
renderTab({ files: [GUIDE, BEACH] } as unknown as Partial<TripPlanner>)
expect(screen.queryByText('files.filterCollab')).not.toBeInTheDocument()
expect(screen.getByText('files.filterAll')).toBeInTheDocument()
})
it('FE-MOB-FTAB-004: sorts starred files to the top', () => {
renderTab()
expect(rowNames()).toEqual(['beach.jpg', 'guide.pdf', 'notes.txt', 'board.pkpass', 'idea.png'])
})
it('FE-MOB-FTAB-005: a filter narrows the list to its bucket', () => {
renderTab()
fireEvent.click(tile('files.filterDocs'))
expect(rowNames()).toEqual(['notes.txt', 'board.pkpass'])
fireEvent.click(tile('files.filterImages'))
expect(rowNames()).toEqual(['beach.jpg', 'idea.png'])
fireEvent.click(tile('files.filterStarred'))
expect(rowNames()).toEqual(['beach.jpg'])
fireEvent.click(tile('files.filterCollab'))
expect(rowNames()).toEqual(['idea.png'])
})
it('FE-MOB-FTAB-006: an empty filter result keeps the grid and shows the hint', () => {
renderTab({ files: [BEACH] } as unknown as Partial<TripPlanner>)
fireEvent.click(tile('files.filterPdf'))
expect(rowNames()).toEqual([])
expect(screen.getByText('mobileTrip.filesEmpty')).toBeInTheDocument()
expect(screen.getByText('files.filterPdf')).toBeInTheDocument()
})
it('FE-MOB-FTAB-007: a row carries size, date, uploader and its link labels', () => {
renderTab()
expect(screen.getByText('2.0 KB')).toBeInTheDocument()
expect(screen.getAllByText('May 1').length).toBeGreaterThan(0)
expect(screen.getByText('ada')).toBeInTheDocument()
expect(screen.getByText('A')).toBeInTheDocument()
expect(screen.getByText('files.sourcePlan · Fushimi Inari')).toBeInTheDocument()
expect(screen.getByText('files.sourceCollab')).toBeInTheDocument()
})
it('FE-MOB-FTAB-008: starring calls the api and refreshes the store', async () => {
const { planner } = renderTab()
expect(screen.getByRole('button', { name: 'files.unstar' })).toBeInTheDocument()
fireEvent.click(screen.getAllByRole('button', { name: 'files.star' })[0])
expect(filesApi.toggleStar).toHaveBeenCalledWith(1, 1)
await waitFor(() => expect(planner.tripActions.loadFiles).toHaveBeenCalledWith(1))
})
it('FE-MOB-FTAB-009: a failing star toggle is reported', async () => {
vi.mocked(filesApi.toggleStar).mockRejectedValue(new Error('boom'))
const { planner } = renderTab()
fireEvent.click(screen.getAllByRole('button', { name: 'files.star' })[0])
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('files.toast.assignError'))
})
it('FE-MOB-FTAB-010: opening a media row puts it in the lightbox at its media index', () => {
renderTab()
expect(screen.queryByTestId('lightbox')).not.toBeInTheDocument()
fireEvent.click(screen.getByText('idea.png'))
const box = screen.getByTestId('lightbox')
expect(box).toHaveAttribute('data-index', '1')
expect(box).toHaveAttribute('data-count', '2')
fireEvent.click(screen.getByText('lb-close'))
expect(screen.queryByTestId('lightbox')).not.toBeInTheDocument()
})
it('FE-MOB-FTAB-011: the lightbox index is clamped to the media it is given', () => {
renderTab()
fireEvent.click(screen.getByText('beach.jpg'))
expect(screen.getByTestId('lightbox')).toHaveAttribute('data-index', '0')
fireEvent.click(screen.getByText('lb-next'))
expect(screen.getByTestId('lightbox')).toHaveAttribute('data-index', '1')
// Past the end the panel clamps instead of unmounting the viewer.
fireEvent.click(screen.getByText('lb-next'))
expect(screen.getByTestId('lightbox')).toHaveAttribute('data-index', '1')
})
it('FE-MOB-FTAB-012: a non-media row opens through the browser-native helper', () => {
renderTab()
fireEvent.click(screen.getByText('guide.pdf'))
expect(openFile).toHaveBeenCalledWith('/api/trips/1/files/1/download', 'guide.pdf')
expect(screen.queryByTestId('lightbox')).not.toBeInTheDocument()
})
it('FE-MOB-FTAB-013: a failing open is reported', async () => {
vi.mocked(openFile).mockRejectedValue(new Error('offline'))
const { planner } = renderTab()
fireEvent.click(screen.getByText('guide.pdf'))
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('files.openError'))
})
it('FE-MOB-FTAB-014: the kebab opens the menu sheet and hands over to the link picker', () => {
renderTab()
fireEvent.click(screen.getAllByRole('button', { name: 'files.menu' })[0])
expect(screen.getByTestId('menu-sheet')).toHaveAttribute('data-file', '2')
fireEvent.click(screen.getByText('menu-links'))
expect(screen.queryByTestId('menu-sheet')).not.toBeInTheDocument()
expect(screen.getByTestId('link-sheet')).toHaveAttribute('data-file', '2')
fireEvent.click(screen.getByText('link-close'))
expect(screen.queryByTestId('link-sheet')).not.toBeInTheDocument()
})
it('FE-MOB-FTAB-015: closing the menu sheet clears the selection', () => {
renderTab()
fireEvent.click(screen.getAllByRole('button', { name: 'files.menu' })[1])
expect(screen.getByTestId('menu-sheet')).toHaveAttribute('data-file', '1')
fireEvent.click(screen.getByText('menu-close'))
expect(screen.queryByTestId('menu-sheet')).not.toBeInTheDocument()
})
it('FE-MOB-FTAB-016: the header upload signal opens the file picker', () => {
const click = vi.spyOn(HTMLInputElement.prototype, 'click').mockImplementation(() => {})
const { rerenderWith } = renderTab()
expect(click).not.toHaveBeenCalled()
rerenderWith({ uploadFilesSignal: 1 })
expect(click).toHaveBeenCalledTimes(1)
// Re-render without a new signal must not re-open the picker.
rerenderWith({ uploadFilesSignal: 1 })
expect(click).toHaveBeenCalledTimes(1)
})
it('FE-MOB-FTAB-017: uploads every picked file and reports the count', async () => {
const { container, planner } = renderTab()
const input = hiddenInput(container)
fireEvent.change(input, {
target: { files: [makeFile('a.pdf', 'application/pdf'), makeFile('b.pdf', 'application/pdf')] },
})
await waitFor(() => expect(planner.toast.success).toHaveBeenCalledWith('files.uploaded:2'))
expect(planner.tripActions.addFile).toHaveBeenCalledTimes(2)
expect(planner.tripActions.addFile).toHaveBeenCalledWith(1, expect.any(FormData))
expect(input.value).toBe('')
})
it('FE-MOB-FTAB-018: an empty pick does nothing', () => {
const { container, planner } = renderTab()
fireEvent.change(hiddenInput(container), { target: { files: [] } })
expect(planner.tripActions.addFile).not.toHaveBeenCalled()
})
it('FE-MOB-FTAB-019: a file over the 50 MB cap is rejected outright', () => {
const { container, planner } = renderTab()
fireEvent.change(hiddenInput(container), {
target: { files: [makeFile('huge.pdf', 'application/pdf', 60 * 1024 * 1024)] },
})
expect(planner.toast.error).toHaveBeenCalledWith('files.uploadErrorSize:50')
expect(planner.tripActions.addFile).not.toHaveBeenCalled()
})
it('FE-MOB-FTAB-019b: a raised FILE_UPLOAD_LIMIT_MB lets the same file through (#1364)', async () => {
useAuthStore.setState({ maxUploadMb: 100 })
const { container, planner } = renderTab()
fireEvent.change(hiddenInput(container), {
target: { files: [makeFile('huge.pdf', 'application/pdf', 60 * 1024 * 1024)] },
})
expect(planner.toast.error).not.toHaveBeenCalled()
await waitFor(() => expect(planner.tripActions.addFile).toHaveBeenCalledTimes(1))
useAuthStore.setState({ maxUploadMb: 50 })
})
it('FE-MOB-FTAB-020: an oversized file in a batch is skipped, the rest still uploads', async () => {
const { container, planner } = renderTab()
fireEvent.change(hiddenInput(container), {
target: {
files: [
makeFile('huge.pdf', 'application/pdf', 60 * 1024 * 1024),
makeFile('small.pdf', 'application/pdf'),
],
},
})
expect(planner.toast.error).toHaveBeenCalledWith('files.uploadErrorSize:50')
await waitFor(() => expect(planner.toast.success).toHaveBeenCalledWith('files.uploaded:1'))
expect(planner.tripActions.addFile).toHaveBeenCalledTimes(1)
})
it('FE-MOB-FTAB-021: a rejected upload falls back to the generic upload error', async () => {
const tripActions = buildTripActions()
tripActions.addFile.mockRejectedValue(new Error('server-500'))
const { container, planner } = renderTab({ tripActions } as unknown as Partial<TripPlanner>)
fireEvent.change(hiddenInput(container), { target: { files: [makeFile('a.pdf', 'application/pdf')] } })
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('files.uploadError'))
expect(planner.toast.success).not.toHaveBeenCalled()
})
it('FE-MOB-FTAB-021b: one rejected file does not drop the rest of the batch', async () => {
const tripActions = buildTripActions()
tripActions.addFile
.mockRejectedValueOnce(new Error('server-500'))
.mockResolvedValueOnce(undefined)
const { container, planner } = renderTab({ tripActions } as unknown as Partial<TripPlanner>)
fireEvent.change(hiddenInput(container), {
target: { files: [makeFile('a.pdf', 'application/pdf'), makeFile('b.pdf', 'application/pdf')] },
})
await waitFor(() => expect(planner.toast.success).toHaveBeenCalledWith('files.uploaded:1'))
expect(planner.tripActions.addFile).toHaveBeenCalledTimes(2)
expect(planner.toast.error).toHaveBeenCalledWith('files.uploadError')
})
it('FE-MOB-FTAB-022: the uploading pill shows while the request is in flight', async () => {
let release: () => void = () => {}
const tripActions = buildTripActions()
tripActions.addFile.mockImplementation(() => new Promise(resolve => { release = () => resolve(undefined) }))
const { container } = renderTab({ tripActions } as unknown as Partial<TripPlanner>)
fireEvent.change(hiddenInput(container), { target: { files: [makeFile('a.pdf', 'application/pdf')] } })
await waitFor(() => expect(screen.getByText('files.uploading')).toBeInTheDocument())
await act(async () => { release() })
await waitFor(() => expect(screen.queryByText('files.uploading')).not.toBeInTheDocument())
})
it('FE-MOB-FTAB-023: pasted files upload, and nothing happens without the permission', async () => {
const { container, planner } = renderTab()
const pasted = makeFile('screenshot.png', 'image/png')
fireEvent.paste(pasteTarget(container), {
clipboardData: {
items: [
{ kind: 'file', getAsFile: () => pasted },
{ kind: 'file', getAsFile: () => null },
{ kind: 'string', getAsFile: () => null },
],
},
})
await waitFor(() => expect(planner.tripActions.addFile).toHaveBeenCalledTimes(1))
const readOnly = renderTab({ canUploadFiles: false } as unknown as Partial<TripPlanner>)
fireEvent.paste(pasteTarget(readOnly.container), {
clipboardData: { items: [{ kind: 'file', getAsFile: () => pasted }] },
})
expect(readOnly.planner.tripActions.addFile).not.toHaveBeenCalled()
})
it('FE-MOB-FTAB-024: a paste that carries no file at all is left to the browser', () => {
const { container, planner } = renderTab()
fireEvent.paste(pasteTarget(container), { clipboardData: { items: [{ kind: 'string', getAsFile: () => null }] } })
expect(planner.tripActions.addFile).not.toHaveBeenCalled()
// A paste event without clipboardData at all must not throw either.
fireEvent.paste(pasteTarget(container))
expect(planner.tripActions.addFile).not.toHaveBeenCalled()
})
it('FE-MOB-FTAB-025: the header trash signal opens the trash sheet', () => {
const { rerenderWith } = renderTab()
expect(screen.queryByTestId('trash-sheet')).not.toBeInTheDocument()
rerenderWith({ openFilesTrashSignal: 1 })
expect(screen.getByTestId('trash-sheet')).toBeInTheDocument()
fireEvent.click(screen.getByText('trash-close'))
expect(screen.queryByTestId('trash-sheet')).not.toBeInTheDocument()
})
})