// FE-COMP-FILEMANAGER-001 to FE-COMP-FILEMANAGER-012
import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server';
import { useAuthStore } from '../../store/authStore';
import { useTripStore } from '../../store/tripStore';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildUser, buildTrip } from '../../../tests/helpers/factories';
import FileManager from './FileManager';
// Mock getAuthUrl
vi.mock('../../api/authUrl', () => ({
getAuthUrl: vi.fn().mockResolvedValue('http://localhost/signed-url'),
}));
// Mock filesApi
vi.mock('../../api/client', async (importOriginal) => {
const original = (await importOriginal()) as any;
return {
...original,
filesApi: {
list: vi.fn().mockResolvedValue({ files: [] }),
toggleStar: vi.fn().mockResolvedValue({}),
restore: vi.fn().mockResolvedValue({}),
permanentDelete: vi.fn().mockResolvedValue({}),
emptyTrash: vi.fn().mockResolvedValue({}),
upload: vi.fn().mockResolvedValue({ file: { id: 99 } }),
update: vi.fn().mockResolvedValue({}),
addLink: vi.fn().mockResolvedValue({}),
removeLink: vi.fn().mockResolvedValue({}),
getLinks: vi.fn().mockResolvedValue({ links: [] }),
},
};
});
import { filesApi } from '../../api/client';
const buildFile = (overrides = {}) => ({
id: 1,
original_name: 'report.pdf',
mime_type: 'application/pdf',
file_size: 51200,
created_at: '2025-01-10T08:00:00Z',
url: '/uploads/trips/1/report.pdf',
starred: false,
deleted_at: null,
place_id: null,
reservation_id: null,
day_id: null,
uploaded_by: 1,
uploader_name: 'Alice',
...overrides,
});
const defaultProps = {
files: [],
onUpload: vi.fn().mockResolvedValue({}),
onDelete: vi.fn().mockResolvedValue(undefined),
onUpdate: vi.fn().mockResolvedValue(undefined),
places: [],
days: [],
assignments: {},
reservations: [],
tripId: 1,
allowedFileTypes: null,
};
beforeEach(() => {
resetAllStores();
vi.clearAllMocks();
// Seed auth as admin so useCanDo() returns true for all permissions
seedStore(useAuthStore, { user: buildUser({ role: 'admin' }), isAuthenticated: true });
seedStore(useTripStore, { trip: buildTrip({ id: 1 }) });
// Default trash endpoint
server.use(
http.get('/api/trips/:tripId/files', ({ request }) => {
const url = new URL(request.url);
if (url.searchParams.get('trash') === 'true') {
return HttpResponse.json({ files: [] });
}
return HttpResponse.json({ files: [] });
}),
);
// Stub window.confirm
vi.spyOn(window, 'confirm').mockReturnValue(true);
});
describe('FileManager', () => {
it('FE-COMP-FILEMANAGER-001: renders empty state when no files', async () => {
render();
// The dropzone should be visible (Upload icon area)
expect(screen.getByText(/drop/i)).toBeInTheDocument();
// No file rows
expect(screen.queryByText('report.pdf')).not.toBeInTheDocument();
});
it('FE-COMP-FILEMANAGER-002: renders file list when files are provided', async () => {
render();
expect(screen.getByText('report.pdf')).toBeInTheDocument();
});
it('FE-COMP-FILEMANAGER-003: file type filter tabs are present', async () => {
render();
// Filter tabs should be present — match the button elements specifically
expect(screen.getByRole('button', { name: /^all$/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /^pdfs$/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /^images$/i })).toBeInTheDocument();
});
it('FE-COMP-FILEMANAGER-004: images tab filters to image files only', async () => {
const files = [
buildFile({ id: 1, mime_type: 'image/jpeg', original_name: 'photo.jpg' }),
buildFile({ id: 2, mime_type: 'application/pdf', original_name: 'doc.pdf' }),
];
render();
// Both should be visible initially
expect(screen.getByText('photo.jpg')).toBeInTheDocument();
expect(screen.getByText('doc.pdf')).toBeInTheDocument();
// Click Images filter tab
const user = userEvent.setup();
const imageTab = screen.getByRole('button', { name: /^images$/i });
await user.click(imageTab);
// Only photo should be visible
expect(screen.getByText('photo.jpg')).toBeInTheDocument();
expect(screen.queryByText('doc.pdf')).not.toBeInTheDocument();
});
it('FE-COMP-FILEMANAGER-005: star button calls filesApi.toggleStar', async () => {
render();
const user = userEvent.setup();
// Find the star button by its title
const starBtn = screen.getByTitle(/star/i);
await user.click(starBtn);
expect(filesApi.toggleStar).toHaveBeenCalledWith(1, 1);
});
it('FE-COMP-FILEMANAGER-006: trash toggle loads and displays trashed files', async () => {
// filesApi.list is mocked — configure it to return trash files when called with trash=true
(filesApi.list as ReturnType).mockImplementation((_tripId, trash) => {
if (trash) return Promise.resolve({ files: [buildFile({ id: 5, original_name: 'old.pdf', deleted_at: '2025-02-01' })] });
return Promise.resolve({ files: [] });
});
render();
const user = userEvent.setup();
// Click trash toggle button
const trashBtn = screen.getByText(/trash/i);
await user.click(trashBtn);
// Trashed file should appear
await screen.findByText('old.pdf');
});
it('FE-COMP-FILEMANAGER-007: restore button calls filesApi.restore', async () => {
(filesApi.list as ReturnType).mockImplementation((_tripId, trash) => {
if (trash) return Promise.resolve({ files: [buildFile({ id: 5, original_name: 'old.pdf', deleted_at: '2025-02-01' })] });
return Promise.resolve({ files: [] });
});
render();
const user = userEvent.setup();
// Open trash
const trashBtn = screen.getByText(/trash/i);
await user.click(trashBtn);
await screen.findByText('old.pdf');
// Click restore button
const restoreBtn = screen.getByTitle(/restore/i);
await user.click(restoreBtn);
expect(filesApi.restore).toHaveBeenCalledWith(1, 5);
});
it('FE-COMP-FILEMANAGER-008: permanent delete calls filesApi.permanentDelete after confirm', async () => {
(filesApi.list as ReturnType).mockImplementation((_tripId, trash) => {
if (trash) return Promise.resolve({ files: [buildFile({ id: 5, original_name: 'old.pdf', deleted_at: '2025-02-01' })] });
return Promise.resolve({ files: [] });
});
render();
const user = userEvent.setup();
// Open trash
await user.click(screen.getByText(/trash/i));
await screen.findByText('old.pdf');
// Click permanent delete (the Trash2 icon button in trash view)
const deleteBtn = screen.getByTitle(/delete/i);
await user.click(deleteBtn);
expect(filesApi.permanentDelete).toHaveBeenCalledWith(1, 5);
});
it('FE-COMP-FILEMANAGER-009: empty trash calls filesApi.emptyTrash', async () => {
(filesApi.list as ReturnType).mockImplementation((_tripId, trash) => {
if (trash) return Promise.resolve({ files: [buildFile({ id: 5, original_name: 'old.pdf', deleted_at: '2025-02-01' })] });
return Promise.resolve({ files: [] });
});
render();
const user = userEvent.setup();
// Open trash
await user.click(screen.getByText(/trash/i));
await screen.findByText('old.pdf');
// Click "Empty Trash" button
const emptyTrashBtn = await screen.findByText(/empty trash/i);
await user.click(emptyTrashBtn);
expect(filesApi.emptyTrash).toHaveBeenCalledWith(1);
});
it('FE-COMP-FILEMANAGER-010: image file click opens lightbox', async () => {
const files = [
buildFile({ id: 1, mime_type: 'image/jpeg', original_name: 'photo.jpg' }),
];
render();
const user = userEvent.setup();
// Click the file name to open lightbox
await user.click(screen.getByText('photo.jpg'));
// Lightbox should appear — it has a fixed position overlay with the filename and a counter
await waitFor(() => {
// The lightbox header shows the filename and "1 / 1"
expect(screen.getByText('1 / 1')).toBeInTheDocument();
});
});
it('FE-COMP-FILEMANAGER-011: escape key closes lightbox', async () => {
const files = [
buildFile({ id: 1, mime_type: 'image/jpeg', original_name: 'photo.jpg' }),
];
render();
const user = userEvent.setup();
// Open lightbox
await user.click(screen.getByText('photo.jpg'));
await waitFor(() => {
expect(screen.getByText('1 / 1')).toBeInTheDocument();
});
// Press Escape
await user.keyboard('{Escape}');
// Lightbox should be gone
await waitFor(() => {
expect(screen.queryByText('1 / 1')).not.toBeInTheDocument();
});
});
it('FE-COMP-FILEMANAGER-013: soft-delete button calls onDelete', async () => {
const onDelete = vi.fn().mockResolvedValue(undefined);
render();
const user = userEvent.setup();
// The delete (trash) button on a non-trash row is titled 'Delete'
const deleteBtn = screen.getByTitle(/delete/i);
await user.click(deleteBtn);
expect(onDelete).toHaveBeenCalledWith(1);
});
it('FE-COMP-FILEMANAGER-014: PDF file click opens preview modal', async () => {
const files = [buildFile({ id: 1, mime_type: 'application/pdf', original_name: 'report.pdf' })];
render();
const user = userEvent.setup();
// Click the file name — for a non-image this opens the PDF preview modal
await user.click(screen.getByText('report.pdf'));
// PDF preview modal should appear with the filename in the header
await waitFor(() => {
// The preview modal header shows the filename
const headers = screen.getAllByText('report.pdf');
expect(headers.length).toBeGreaterThanOrEqual(2); // in list + in modal header
});
});
it('FE-COMP-FILEMANAGER-015: file with uploader name shows avatar chip initials', () => {
const files = [buildFile({ uploaded_by_name: 'Alice Smith' })];
render();
// The AvatarChip shows the first letter of the name
expect(screen.getByText('A')).toBeInTheDocument();
});
it('FE-COMP-FILEMANAGER-016: multiple images in lightbox shows thumbnail strip', async () => {
const files = [
buildFile({ id: 1, mime_type: 'image/jpeg', original_name: 'photo1.jpg' }),
buildFile({ id: 2, mime_type: 'image/jpeg', original_name: 'photo2.jpg' }),
];
render();
const user = userEvent.setup();
// Open lightbox on first image
await user.click(screen.getByText('photo1.jpg'));
// Lightbox shows "1 / 2" counter
await waitFor(() => {
expect(screen.getByText('1 / 2')).toBeInTheDocument();
});
});
it('FE-COMP-FILEMANAGER-017: file size is displayed', () => {
const files = [buildFile({ file_size: 51200 })];
render();
expect(screen.getByText('50.0 KB')).toBeInTheDocument();
});
it('FE-COMP-FILEMANAGER-018: starred filter shows only starred files', async () => {
const files = [
buildFile({ id: 1, original_name: 'starred.pdf', starred: true }),
buildFile({ id: 2, original_name: 'normal.pdf', starred: false }),
];
render();
const user = userEvent.setup();
// The starred filter tab only appears when there are starred files
const starredTab = screen.getByRole('button', { name: '' }); // Star icon button in filter tabs
await user.click(starredTab);
expect(screen.getByText('starred.pdf')).toBeInTheDocument();
expect(screen.queryByText('normal.pdf')).not.toBeInTheDocument();
});
it('FE-COMP-FILEMANAGER-019: clicking assign button opens assign modal', async () => {
render();
const user = userEvent.setup();
// Pencil/assign button
const assignBtn = screen.getByTitle(/assign/i);
await user.click(assignBtn);
// Assign modal should appear (it has a title and a close button)
await waitFor(() => {
expect(screen.getByText(/assign/i, { selector: 'div' })).toBeInTheDocument();
});
});
it('FE-COMP-FILEMANAGER-020: assign modal shows places list', async () => {
const { buildPlace } = await import('../../../tests/helpers/factories');
const place = buildPlace({ id: 10, name: 'Eiffel Tower' });
render();
const user = userEvent.setup();
const assignBtn = screen.getByTitle(/assign/i);
await user.click(assignBtn);
await screen.findByText('Eiffel Tower');
});
it('FE-COMP-FILEMANAGER-021: file description is shown when present', () => {
const files = [buildFile({ description: 'A very important document' })];
render();
expect(screen.getByText('A very important document')).toBeInTheDocument();
});
it('FE-COMP-FILEMANAGER-022: PDF preview modal can be closed', async () => {
const files = [buildFile({ id: 1, mime_type: 'application/pdf', original_name: 'report.pdf' })];
render();
const user = userEvent.setup();
// Open preview
await user.click(screen.getByText('report.pdf'));
// Multiple 'report.pdf' elements now (list + modal header)
await waitFor(() => {
expect(screen.getAllByText('report.pdf').length).toBeGreaterThanOrEqual(2);
});
// Close via X button in the modal (second X button — first might be something else)
const closeButtons = screen.getAllByRole('button', { name: '' });
// Find a close button near the modal header — click the last X-like button
const xBtn = closeButtons.find(btn => btn.closest('[style*="z-index: 10000"]'));
if (xBtn) await user.click(xBtn);
});
it('FE-COMP-FILEMANAGER-023: assign modal shows reservations list', async () => {
const { buildReservation } = await import('../../../tests/helpers/factories');
const reservation = buildReservation({ id: 20, name: 'Hotel Paris' });
render();
const user = userEvent.setup();
const assignBtn = screen.getByTitle(/assign/i);
await user.click(assignBtn);
await screen.findByText('Hotel Paris');
});
it('FE-COMP-FILEMANAGER-024: clicking a place in assign modal calls filesApi.update', async () => {
const { buildPlace } = await import('../../../tests/helpers/factories');
const place = buildPlace({ id: 10, name: 'Louvre Museum' });
const file = buildFile({ id: 1 });
const onUpdate = vi.fn().mockResolvedValue(undefined);
render();
const user = userEvent.setup();
// Open assign modal
await user.click(screen.getByTitle(/assign/i));
await screen.findByText('Louvre Museum');
// Click on the place button to link it
await user.click(screen.getByText('Louvre Museum'));
expect(filesApi.update).toHaveBeenCalledWith(1, 1, { place_id: 10 });
});
it('FE-COMP-FILEMANAGER-025: clicking a reservation in assign modal calls filesApi.update', async () => {
const { buildReservation } = await import('../../../tests/helpers/factories');
const reservation = buildReservation({ id: 20, name: 'Train Ticket' });
const file = buildFile({ id: 1 });
render();
const user = userEvent.setup();
// Open assign modal
await user.click(screen.getByTitle(/assign/i));
await screen.findByText('Train Ticket');
// Click on the reservation button to link it
await user.click(screen.getByText('Train Ticket'));
expect(filesApi.update).toHaveBeenCalledWith(1, 1, { reservation_id: 20 });
});
it('FE-COMP-FILEMANAGER-026: assign modal with both places and reservations shows both sections', async () => {
const { buildPlace, buildReservation } = await import('../../../tests/helpers/factories');
const place = buildPlace({ id: 10, name: 'Notre Dame' });
const reservation = buildReservation({ id: 20, name: 'Airbnb' });
render();
const user = userEvent.setup();
await user.click(screen.getByTitle(/assign/i));
await screen.findByText('Notre Dame');
await screen.findByText('Airbnb');
});
it('FE-COMP-FILEMANAGER-027: paste event uploads file when user can upload', async () => {
const onUpload = vi.fn().mockResolvedValue({ file: { id: 55 } });
render();
const container = document.querySelector('.flex.flex-col') as HTMLElement;
const file = new File(['data'], 'pasted.png', { type: 'image/png' });
// Manually build a paste event with a mock clipboardData.items
const mockItem = { kind: 'file', getAsFile: () => file };
const pasteEvent = new Event('paste', { bubbles: true });
Object.defineProperty(pasteEvent, 'clipboardData', {
value: { items: [mockItem] },
});
await fireEvent(container, pasteEvent);
await waitFor(() => {
expect(onUpload).toHaveBeenCalled();
});
});
it('FE-COMP-FILEMANAGER-028: upload with places open assign modal after upload', async () => {
const { buildPlace } = await import('../../../tests/helpers/factories');
const place = buildPlace({ id: 10, name: 'Sagrada Familia' });
const onUpload = vi.fn().mockResolvedValue({ file: { id: 77 } });
render();
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
const file = new File(['data'], 'photo.jpg', { type: 'image/jpeg' });
await userEvent.upload(input, file);
// After successful upload with places present, assign modal opens
await waitFor(() => {
expect(onUpload).toHaveBeenCalled();
});
});
it('FE-COMP-FILEMANAGER-029: assign modal with days+assignments shows day group', async () => {
const { buildPlace, buildDay } = await import('../../../tests/helpers/factories');
const place = buildPlace({ id: 10, name: 'Arc de Triomphe' });
const day = buildDay({ id: 5, date: '2025-06-01', day_number: 1 });
const assignments = { '5': [{ id: 1, day_id: 5, place_id: 10, order_index: 0, place }] };
render();
const user = userEvent.setup();
await user.click(screen.getByTitle(/assign/i));
await screen.findByText('Arc de Triomphe');
});
it('FE-COMP-FILEMANAGER-030: file with linked place shows source badge', async () => {
const { buildPlace } = await import('../../../tests/helpers/factories');
const place = buildPlace({ id: 10, name: 'Colosseum' });
const file = buildFile({ place_id: 10 });
render();
// Source badge text includes place name
await screen.findByText(/Colosseum/);
});
it('FE-COMP-FILEMANAGER-031: unlink place from assign modal calls filesApi.update', async () => {
const { buildPlace } = await import('../../../tests/helpers/factories');
const place = buildPlace({ id: 10, name: 'Venice Beach' });
// File already has place_id set to 10 (linked)
const file = buildFile({ id: 1, place_id: 10 });
render();
const user = userEvent.setup();
// Open assign modal
await user.click(screen.getByTitle(/assign/i));
await screen.findByText('Venice Beach');
// Clicking the linked place should unlink it
await user.click(screen.getByText('Venice Beach'));
expect(filesApi.update).toHaveBeenCalledWith(1, 1, { place_id: null });
});
it('FE-COMP-FILEMANAGER-032: unlink reservation from assign modal calls filesApi.update', async () => {
const { buildReservation } = await import('../../../tests/helpers/factories');
const reservation = buildReservation({ id: 20, name: 'Museum Pass' });
// File already has reservation_id set to 20
const file = buildFile({ id: 1, reservation_id: 20 });
render();
const user = userEvent.setup();
await user.click(screen.getByTitle(/assign/i));
await screen.findByText('Museum Pass');
// Clicking the linked reservation should unlink it
await user.click(screen.getByText('Museum Pass'));
expect(filesApi.update).toHaveBeenCalledWith(1, 1, { reservation_id: null });
});
it('FE-COMP-FILEMANAGER-033: opening PDF preview and closing via backdrop', async () => {
const files = [buildFile({ id: 1, mime_type: 'application/pdf', original_name: 'doc.pdf' })];
render();
const user = userEvent.setup();
await user.click(screen.getByText('doc.pdf'));
// Modal opens (multiple occurrences of doc.pdf)
await waitFor(() => {
expect(screen.getAllByText('doc.pdf').length).toBeGreaterThanOrEqual(2);
});
// Click the backdrop to close
const backdrop = document.querySelector('[style*="z-index: 10000"]') as HTMLElement;
if (backdrop) await user.click(backdrop);
await waitFor(() => {
expect(screen.getAllByText('doc.pdf').length).toBeLessThan(2);
});
});
it('FE-COMP-FILEMANAGER-012: upload via dropzone calls onUpload', async () => {
const onUpload = vi.fn().mockResolvedValue({ file: { id: 99 } });
render();
// Find the hidden file input from the dropzone
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
expect(input).toBeTruthy();
const file = new File(['hello'], 'test.pdf', { type: 'application/pdf' });
await userEvent.upload(input, file);
await waitFor(() => {
expect(onUpload).toHaveBeenCalled();
const call = onUpload.mock.calls[0];
expect(call[0]).toBeInstanceOf(FormData);
});
});
});