// FE-PLANNER-FILEIMP-001 to FE-PLANNER-FILEIMP-018
import { render, screen, fireEvent, waitFor } from '../../../tests/helpers/render';
import { http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server';
import { readMultipart } from '../../../tests/helpers/multipart';
import { useTripStore } from '../../store/tripStore';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildTrip } from '../../../tests/helpers/factories';
import FileImportModal from './FileImportModal';
const toastCalls: Array<[string, string]> = [];
vi.mock('../shared/Toast', () => ({
useToast: () => ({
success: (m: string) => { toastCalls.push(['success', m]); return 0; },
error: (m: string) => { toastCalls.push(['error', m]); return 0; },
warning: (m: string) => { toastCalls.push(['warning', m]); return 0; },
info: (m: string) => { toastCalls.push(['info', m]); return 0; },
}),
}));
const defaultProps = { isOpen: true, onClose: vi.fn(), tripId: 3 };
// See BookingImportModal.test.tsx: a file that is really uploaded must carry as
// many bytes as it reports, otherwise the multipart body and its length disagree.
const gpx = (name = 'route.gpx', size = 100) => {
const f = new File([''.padEnd(Math.min(size, 4096), ' ')], name, { type: 'application/gpx+xml' });
if (size > 4096) Object.defineProperty(f, 'size', { value: size });
return f;
};
const kml = (name = 'places.kml') => new File([''], name, { type: 'application/vnd.google-earth.kml+xml' });
const fileInput = () => document.querySelector('input[type="file"]') as HTMLInputElement;
const dropZone = () => screen.getByText(/Click to select a file or drag and drop here/).parentElement as HTMLElement;
const importBtn = () => screen.getByRole('button', { name: 'Import' });
beforeEach(() => {
toastCalls.length = 0;
resetAllStores();
seedStore(useTripStore, { trip: buildTrip({ id: 3 }) });
server.use(
http.get('/api/trips/3', () => HttpResponse.json({ trip: buildTrip({ id: 3 }), days: [], places: [], assignments: {} })),
);
});
describe('FileImportModal', () => {
it('FE-PLANNER-FILEIMP-001: renders nothing while closed', () => {
render();
expect(screen.queryByText('Import file')).not.toBeInTheDocument();
});
it('FE-PLANNER-FILEIMP-002: shows title, hint and a disabled import button', () => {
render();
expect(screen.getByText('Import file')).toBeInTheDocument();
expect(screen.getByText(/Import \.gpx, \.kml or \.kmz files/)).toBeInTheDocument();
expect(importBtn()).toBeDisabled();
});
it('FE-PLANNER-FILEIMP-003: an initialFile is pre-selected and shows the GPX options', () => {
render();
expect(screen.getByText('route.gpx')).toBeInTheDocument();
expect(screen.getByText('What do you want to import?')).toBeInTheDocument();
expect(screen.getByText('Waypoints')).toBeInTheDocument();
expect(screen.getByText('Tracks (with path geometry)')).toBeInTheDocument();
});
it('FE-PLANNER-FILEIMP-004: an unsupported initialFile is rejected on open', () => {
render();
expect(screen.getByText('Unsupported file type. Use .gpx, .kml or .kmz.')).toBeInTheDocument();
expect(importBtn()).toBeDisabled();
});
it('FE-PLANNER-FILEIMP-005: an oversized file is rejected with the size limit message', () => {
render();
fireEvent.change(fileInput(), { target: { files: [gpx('big.gpx', 11 * 1024 * 1024)] } });
expect(screen.getByText('File is too large. Maximum upload size is 10 MB.')).toBeInTheDocument();
});
it('FE-PLANNER-FILEIMP-006: a KML selection shows the point/path options instead', () => {
render();
fireEvent.change(fileInput(), { target: { files: [kml()] } });
expect(screen.getByText('Points (Placemarks)')).toBeInTheDocument();
expect(screen.getByText('Paths (LineStrings)')).toBeInTheDocument();
expect(screen.queryByText('Waypoints')).not.toBeInTheDocument();
});
it('FE-PLANNER-FILEIMP-007: unticking every GPX type blocks the import', () => {
render();
fireEvent.click(screen.getByText('Waypoints'));
fireEvent.click(screen.getByText('Routes'));
fireEvent.click(screen.getByText('Tracks (with path geometry)'));
expect(screen.getByText('Select at least one type to import.')).toBeInTheDocument();
expect(importBtn()).toBeDisabled();
});
it('FE-PLANNER-FILEIMP-008: unticking every KML type blocks the import', () => {
render();
fireEvent.click(screen.getByText('Points (Placemarks)'));
fireEvent.click(screen.getByText('Paths (LineStrings)'));
expect(screen.getByText('Select at least one type to import.')).toBeInTheDocument();
expect(importBtn()).toBeDisabled();
});
it('FE-PLANNER-FILEIMP-009: dragging over the drop zone switches to the drop label', () => {
render();
const zone = dropZone();
fireEvent.dragEnter(zone);
expect(screen.getByText('Drop file to select')).toBeInTheDocument();
fireEvent.dragLeave(zone);
expect(screen.getByText(/Click to select a file/)).toBeInTheDocument();
});
it('FE-PLANNER-FILEIMP-019: clicking the drop zone opens the hidden file input', () => {
render();
const click = vi.spyOn(fileInput(), 'click').mockImplementation(() => {});
fireEvent.click(dropZone());
expect(click).toHaveBeenCalled();
click.mockRestore();
});
it('FE-PLANNER-FILEIMP-010: dropping a file selects it', () => {
render();
fireEvent.drop(dropZone(), { dataTransfer: { files: [gpx('dropped.gpx')] } });
expect(screen.getByText('dropped.gpx')).toBeInTheDocument();
});
it('FE-PLANNER-FILEIMP-011: a GPX import posts the selected type flags, toasts and closes', async () => {
const onClose = vi.fn();
let flags: Record = {};
server.use(
http.post('/api/trips/3/places/import/gpx', async ({ request }) => {
const { fields } = await readMultipart(request);
flags = {
waypoints: fields.importWaypoints,
routes: fields.importRoutes,
tracks: fields.importTracks,
};
return HttpResponse.json({ count: 4, skipped: 0, places: [{ id: 11 }, { id: 12 }] });
}),
);
render();
fireEvent.click(screen.getByText('Routes'));
fireEvent.click(importBtn());
await waitFor(() => expect(onClose).toHaveBeenCalled());
expect(flags).toEqual({ waypoints: 'true', routes: 'false', tracks: 'true' });
expect(toastCalls).toContainEqual(['success', '4 places imported from GPX']);
});
it('FE-PLANNER-FILEIMP-012: a successful GPX import registers an undo that bulk-deletes the new places', async () => {
const pushUndo = vi.fn();
let deleted: number[] = [];
server.use(
http.post('/api/trips/3/places/import/gpx', () =>
HttpResponse.json({ count: 2, skipped: 0, places: [{ id: 21 }, { id: 22 }] })),
http.post('/api/trips/3/places/bulk-delete', async ({ request }) => {
deleted = ((await request.json()) as { ids: number[] }).ids;
return HttpResponse.json({ deleted: deleted.length });
}),
);
render();
fireEvent.click(importBtn());
await waitFor(() => expect(pushUndo).toHaveBeenCalled());
expect(pushUndo.mock.calls[0][0]).toBe('GPX import');
await pushUndo.mock.calls[0][1]();
expect(deleted).toEqual([21, 22]);
});
it('FE-PLANNER-FILEIMP-013: a KML import keeps the modal open to show the summary', async () => {
const onClose = vi.fn();
server.use(
http.post('/api/trips/3/places/import/map', () =>
HttpResponse.json({
count: 3,
places: [{ id: 31 }],
summary: { totalPlacemarks: 5, createdCount: 3, skippedCount: 2, warnings: ['2 placemarks had no coordinates'], errors: [] },
})),
);
render();
fireEvent.click(importBtn());
expect(await screen.findByText('Placemarks: 5 • Imported: 3 • Skipped: 2')).toBeInTheDocument();
expect(screen.getByText('2 placemarks had no coordinates')).toBeInTheDocument();
expect(onClose).not.toHaveBeenCalled();
expect(toastCalls).toContainEqual(['success', '3 places imported from KMZ/KML']);
});
it('FE-PLANNER-FILEIMP-014: summaries of several KML files are merged', async () => {
server.use(
http.post('/api/trips/3/places/import/map', () =>
HttpResponse.json({
count: 1,
places: [{ id: 41 }],
summary: { totalPlacemarks: 2, createdCount: 1, skippedCount: 1, warnings: ['w'], errors: [] },
})),
);
render();
fireEvent.change(fileInput(), { target: { files: [kml('a.kml'), kml('b.kmz')] } });
fireEvent.click(importBtn());
expect(await screen.findByText('Placemarks: 4 • Imported: 2 • Skipped: 2')).toBeInTheDocument();
});
it('FE-PLANNER-FILEIMP-015: an import where everything was skipped warns instead of claiming success', async () => {
server.use(
http.post('/api/trips/3/places/import/gpx', () => HttpResponse.json({ count: 0, skipped: 3, places: [] })),
);
render();
fireEvent.click(importBtn());
await waitFor(() => expect(toastCalls).toContainEqual(['warning', 'All places were already in the trip.']));
});
it('FE-PLANNER-FILEIMP-016: a failing import shows the server error and keeps the modal open', async () => {
const onClose = vi.fn();
server.use(
http.post('/api/trips/3/places/import/gpx', () => HttpResponse.json({ error: 'Malformed GPX' }, { status: 400 })),
);
render();
fireEvent.click(importBtn());
expect(await screen.findByText('Malformed GPX')).toBeInTheDocument();
expect(onClose).not.toHaveBeenCalled();
expect(toastCalls).toContainEqual(['error', 'Malformed GPX']);
});
it('FE-PLANNER-FILEIMP-017: with several files each error is prefixed with its file name', async () => {
server.use(
http.post('/api/trips/3/places/import/gpx', () => HttpResponse.error()),
);
render();
fireEvent.change(fileInput(), { target: { files: [gpx('one.gpx'), gpx('two.gpx')] } });
fireEvent.click(importBtn());
const box = await screen.findByText(/one\.gpx: Import failed/);
expect(box.textContent).toContain('two.gpx: Import failed');
});
it('FE-PLANNER-FILEIMP-018: Cancel closes without importing, and a backdrop click closes too', () => {
const onClose = vi.fn();
render();
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
expect(onClose).toHaveBeenCalledTimes(1);
fireEvent.click(document.querySelector('[style*="z-index: 99999"]') as HTMLElement);
expect(onClose).toHaveBeenCalledTimes(2);
// A click inside the card is swallowed.
fireEvent.click(screen.getByText('Import file'));
expect(onClose).toHaveBeenCalledTimes(2);
});
});