// FE-PLANNER-BOOKIMP-001 to FE-PLANNER-BOOKIMP-021
import { render, screen, fireEvent, waitFor } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server';
import { readMultipart } from '../../../tests/helpers/multipart';
import { useBackgroundTasksStore } from '../../store/backgroundTasksStore';
import { resetAllStores } from '../../../tests/helpers/store';
import { saveImportFiles } from '../../db/offlineDb';
import BookingImportModal from './BookingImportModal';
vi.mock('../../db/offlineDb', () => ({ saveImportFiles: vi.fn(async () => {}) }));
const defaultProps = { isOpen: true, onClose: vi.fn(), tripId: 4 };
// A file that is actually uploaded has to carry as many bytes as it reports —
// a size that lies leaves the multipart body and its length disagreeing, and the
// upload then fails to parse. Only the oversize case fakes the number, and that
// file is rejected by validateFile before it is ever sent.
const eml = (name = 'booking.eml', size = 100) => {
const f = new File(['x'.repeat(Math.min(size, 4096))], name, { type: 'message/rfc822' });
if (size > 4096) Object.defineProperty(f, 'size', { value: size });
return f;
};
const fileInput = () => document.querySelector('input[type="file"]') as HTMLInputElement;
const dropZone = () => screen.getByText(/Drop booking confirmation files here/).parentElement as HTMLElement;
beforeEach(() => {
resetAllStores();
vi.mocked(saveImportFiles).mockClear();
server.use(
http.get('/api/health/features', () => HttpResponse.json({ bookingImport: true, aiParsing: false })),
http.post('/api/trips/4/reservations/import/booking/async', () => HttpResponse.json({ jobId: 'job-1' })),
);
});
describe('BookingImportModal', () => {
it('FE-PLANNER-BOOKIMP-001: renders nothing while closed', () => {
render();
expect(screen.queryByText('Import booking confirmations')).not.toBeInTheDocument();
});
it('FE-PLANNER-BOOKIMP-002: shows title, accepted formats and a disabled import button', () => {
render();
expect(screen.getByText('Import booking confirmations')).toBeInTheDocument();
expect(screen.getByText(/Accepted: EML, PDF, PKPass/)).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Import' })).toBeDisabled();
});
it('FE-PLANNER-BOOKIMP-003: selecting a supported file lists its name', async () => {
render();
await userEvent.upload(fileInput(), eml('flight.pdf'));
expect(await screen.findByText('flight.pdf')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Import' })).toBeEnabled();
});
it('FE-PLANNER-BOOKIMP-004: an unsupported extension is rejected with a message', () => {
render();
// userEvent.upload honours the input's accept filter, so drive the change directly.
fireEvent.change(fileInput(), { target: { files: [new File(['x'], 'photo.png', { type: 'image/png' })] } });
expect(screen.getByText(/Unsupported file format/)).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Import' })).toBeDisabled();
});
it('FE-PLANNER-BOOKIMP-005: a file over 10 MB is rejected by name', () => {
render();
fireEvent.change(fileInput(), { target: { files: [eml('huge.eml', 11 * 1024 * 1024)] } });
expect(screen.getByText('File "huge.eml" exceeds 10 MB limit.')).toBeInTheDocument();
});
it('FE-PLANNER-BOOKIMP-006: a mixed selection keeps the valid files and still surfaces the error', () => {
render();
fireEvent.change(fileInput(), { target: { files: [eml('ok.eml'), new File(['x'], 'bad.png')] } });
expect(screen.getByText('ok.eml')).toBeInTheDocument();
expect(screen.getByText(/Unsupported file format/)).toBeInTheDocument();
});
it('FE-PLANNER-BOOKIMP-007: only the first five files are taken', () => {
render();
const many = Array.from({ length: 7 }, (_, i) => eml(`b${i}.eml`));
fireEvent.change(fileInput(), { target: { files: many } });
expect(screen.getByText('b0.eml, b1.eml, b2.eml, b3.eml, b4.eml')).toBeInTheDocument();
expect(screen.queryByText(/b5\.eml/)).not.toBeInTheDocument();
});
it('FE-PLANNER-BOOKIMP-008: dragging over the drop zone switches to the drop label', () => {
render();
const zone = dropZone();
fireEvent.dragOver(zone);
expect(screen.getByText('Drop files to import')).toBeInTheDocument();
fireEvent.dragLeave(zone);
expect(screen.getByText(/Drop booking confirmation files here/)).toBeInTheDocument();
});
it('FE-PLANNER-BOOKIMP-016: 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-BOOKIMP-009: dropping files selects them', () => {
render();
fireEvent.drop(dropZone(), { dataTransfer: { files: [eml('dropped.eml')] } });
expect(screen.getByText('dropped.eml')).toBeInTheDocument();
});
it('FE-PLANNER-BOOKIMP-010: importing queues a background task, stores the files and closes', async () => {
const onClose = vi.fn();
let sentMode: string | null = null;
server.use(
http.post('/api/trips/4/reservations/import/booking/async', async ({ request }) => {
sentMode = (await readMultipart(request)).fields.mode ?? null;
return HttpResponse.json({ jobId: 'job-42' });
}),
);
render();
fireEvent.change(fileInput(), { target: { files: [eml('a.eml'), eml('b.pdf')] } });
fireEvent.click(screen.getByRole('button', { name: 'Import' }));
await waitFor(() => expect(onClose).toHaveBeenCalled());
expect(sentMode).toBe('no-ai');
expect(vi.mocked(saveImportFiles).mock.calls[0][0]).toBe('job-42');
const task = useBackgroundTasksStore.getState().tasks.find(t => t.id === 'job-42');
expect(task).toMatchObject({ tripId: '4', label: 'a.eml, b.pdf', total: 2, mode: 'no-ai' });
});
it('FE-PLANNER-BOOKIMP-011: with AI parsing enabled the mode falls back on empty results', async () => {
let sentMode: string | null = null;
server.use(
http.get('/api/health/features', () => HttpResponse.json({ bookingImport: true, aiParsing: true })),
http.post('/api/trips/4/reservations/import/booking/async', async ({ request }) => {
sentMode = (await readMultipart(request)).fields.mode ?? null;
return HttpResponse.json({ jobId: 'job-ai' });
}),
);
render();
// Wait for the feature probe to land before kicking off the import.
await waitFor(() => expect(screen.getByText('Import booking confirmations')).toBeInTheDocument());
fireEvent.change(fileInput(), { target: { files: [eml()] } });
fireEvent.click(screen.getByRole('button', { name: 'Import' }));
await waitFor(() => expect(sentMode).toBe('fallback-on-empty'));
});
it('FE-PLANNER-BOOKIMP-012: a failing upload surfaces the server error and keeps the modal open', async () => {
const onClose = vi.fn();
server.use(
http.post('/api/trips/4/reservations/import/booking/async', () =>
HttpResponse.json({ error: 'Storage full' }, { status: 500 })),
);
render();
fireEvent.change(fileInput(), { target: { files: [eml()] } });
fireEvent.click(screen.getByRole('button', { name: 'Import' }));
expect(await screen.findByText('Storage full')).toBeInTheDocument();
expect(onClose).not.toHaveBeenCalled();
expect(screen.getByRole('button', { name: 'Import' })).toBeEnabled();
});
it('FE-PLANNER-BOOKIMP-013: an error without a server message falls back to the generic parse error', async () => {
server.use(
http.post('/api/trips/4/reservations/import/booking/async', () => HttpResponse.error()),
);
render();
fireEvent.change(fileInput(), { target: { files: [eml()] } });
fireEvent.click(screen.getByRole('button', { name: 'Import' }));
expect(await screen.findByText(/Parsing failed/)).toBeInTheDocument();
});
it('FE-PLANNER-BOOKIMP-014: Cancel closes and reopening clears the previous selection', async () => {
const onClose = vi.fn();
const { rerender } = render();
fireEvent.change(fileInput(), { target: { files: [eml('stale.eml')] } });
expect(screen.getByText('stale.eml')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
expect(onClose).toHaveBeenCalled();
rerender();
rerender();
await waitFor(() => expect(screen.queryByText('stale.eml')).not.toBeInTheDocument());
});
it('FE-PLANNER-BOOKIMP-015: a backdrop press-and-release closes, a press inside does not', () => {
const onClose = vi.fn();
render();
const backdrop = document.querySelector('[style*="z-index: 99999"]') as HTMLElement;
const card = screen.getByText('Import booking confirmations').closest('div')!.parentElement!;
// A drag that starts inside the dialog and ends on the backdrop must not close it.
fireEvent.mouseDown(card);
fireEvent.click(backdrop);
expect(onClose).not.toHaveBeenCalled();
fireEvent.mouseDown(backdrop);
fireEvent.click(backdrop);
expect(onClose).toHaveBeenCalledTimes(1);
});
});
describe('BookingImportModal with a model that reads images', () => {
const jpg = () => new File(['jpeg'], 'ticket.jpg', { type: 'image/jpeg' });
it('FE-PLANNER-BOOKIMP-020: offers photos, and imports one with AI, when the model reads images', async () => {
let mode: string | null = null;
server.use(
http.get('/api/health/features', () => HttpResponse.json({ bookingImport: true, aiParsing: true })),
http.get('/api/llm/capabilities', () => HttpResponse.json({ images: true })),
http.post('/api/trips/4/reservations/import/booking/async', async ({ request }) => {
mode = (await readMultipart(request)).fields.mode ?? null;
return HttpResponse.json({ jobId: 'job-2' });
}),
);
render();
expect(await screen.findByText(/Photos \(JPG, PNG, WEBP\) are read by the AI model\./)).toBeInTheDocument();
expect(fileInput().accept).toContain('.jpg');
fireEvent.change(fileInput(), { target: { files: [jpg()] } });
expect(screen.getByText('ticket.jpg')).toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: 'Import' }));
await waitFor(() => expect(useBackgroundTasksStore.getState().tasks.find(t => t.id === 'job-2')).toMatchObject({ kind: 'transports' }));
expect(mode).toBe('fallback-on-empty');
});
it('FE-PLANNER-BOOKIMP-021: refuses a photo while the model reads no images', async () => {
server.use(http.get('/api/llm/capabilities', () => HttpResponse.json({ images: false })));
render();
await waitFor(() => expect(fileInput().accept).not.toContain('.jpg'));
fireEvent.change(fileInput(), { target: { files: [jpg()] } });
expect(screen.getByText('Unsupported file format. Use EML, PDF, PKPass, HTML, or TXT.')).toBeInTheDocument();
expect(screen.queryByText(/Photos \(JPG/)).not.toBeInTheDocument();
});
});