Files
TREK/client/src/components/Planner/AirTrailImportModal.test.tsx
T
Maurice 64ceecefb3 fix(bookings): bookings, transports and stays saved offline wait in the queue and sync once the connection is back
Every booking and stay write went straight to the API, so offline a new
booking failed with "Network Error" and was lost, and a status tick, the
travellers or a moved transport snapped back. Bookings (add, edit, status,
travellers, delete, day order) and stays (add, edit, delete) now go through
reservationRepo and accommodationRepo and the mutation queue like places and
costs: a temporary row offline, held behind an older queued write online,
since neither carries a version token. Desktop, the phone sheets, the day
plan, the road trip nights and the PDF all write and read through them.

The replay reads the booking from `reservation` only, so the travellers
answer ({ travelers, reservation }) lands on the booking it names. A booking
or its stay that names a place or stay created offline is sent with the real
id once that one has synced, inside create_accommodation too, so an imported
hotel reviewed offline can be linked to a place made for it.

What only works online says so instead of failing: an AirTrail import, a
booking file import, the AI retry and an attachment on a saved booking show
that they need a connection, and a save with newly picked files is stopped
with a hint, since an upload cannot wait in the queue.

Refs #2601
2026-10-12 19:07:33 +02:00

362 lines
16 KiB
TypeScript

// FE-PLANNER-AIRTRAIL-001 to FE-PLANNER-AIRTRAIL-022
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 { useAuthStore } from '../../store/authStore';
import { useTripStore } from '../../store/tripStore';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildUser, buildTrip, buildReservation } from '../../../tests/helpers/factories';
import type { AirtrailFlight } from '@trek/shared';
import { _resetNetworkMode, setForcedOffline } from '../../sync/networkMode';
import AirTrailImportModal, { detectConnections } from './AirTrailImportModal';
const toasts: Array<[string, string]> = [];
vi.mock('../shared/Toast', () => ({
useToast: () => ({
success: (m: string) => { toasts.push(['success', m]); return 0; },
warning: (m: string) => { toasts.push(['warning', m]); return 0; },
error: (m: string) => { toasts.push(['error', m]); return 0; },
info: (m: string) => { toasts.push(['info', m]); return 0; },
}),
}));
const flight = (over: Partial<AirtrailFlight> = {}): AirtrailFlight => ({
id: '101',
fromCode: 'BRU',
fromName: 'Brussels',
toCode: 'HEL',
toName: 'Helsinki-Vantaa',
date: '2026-08-01',
departure: '2026-08-01T06:00:00.000+00:00',
arrival: '2026-08-01T09:30:00.000+00:00',
airline: 'Finnair',
flightNumber: 'AY1502',
aircraft: null,
seatClass: 'economy',
...over,
});
const legHelJfk = (): AirtrailFlight =>
flight({
id: '102',
fromCode: 'HEL',
fromName: 'Helsinki-Vantaa',
toCode: 'JFK',
toName: 'John F. Kennedy Intl.',
departure: '2026-08-01T11:00:00.000+00:00',
arrival: '2026-08-01T19:00:00.000+00:00',
flightNumber: 'AY15',
});
const unrelatedFlight = (): AirtrailFlight =>
flight({
id: '103',
fromCode: 'LHR',
fromName: 'London Heathrow',
toCode: 'JFK',
toName: 'John F. Kennedy Intl.',
date: '2026-08-05',
departure: '2026-08-05T10:00:00.000+00:00',
arrival: '2026-08-05T18:00:00.000+00:00',
flightNumber: 'BA117',
});
describe('detectConnections (#1535)', () => {
it('FE-PLANNER-AIRTRAIL-001: chains flights that connect at the same airport within 24h', () => {
const chains = detectConnections([legHelJfk(), flight(), unrelatedFlight()]);
expect(chains).toHaveLength(1);
expect(chains[0].map(f => f.id)).toEqual(['101', '102']);
});
it('FE-PLANNER-AIRTRAIL-002: does not chain when the layover exceeds 24h', () => {
const late = { ...legHelJfk(), date: '2026-08-03', departure: '2026-08-03T11:00:00.000+00:00', arrival: '2026-08-03T19:00:00.000+00:00' };
expect(detectConnections([flight(), late])).toHaveLength(0);
});
it('FE-PLANNER-AIRTRAIL-003: skips flights without instants instead of guessing', () => {
expect(detectConnections([flight({ departure: null, arrival: null }), legHelJfk()])).toHaveLength(0);
});
it('FE-PLANNER-AIRTRAIL-009: does not chain an out-and-back return into a bogus connection', () => {
const returnFlight = flight({
id: '105',
fromCode: 'HEL',
fromName: 'Helsinki-Vantaa',
toCode: 'BRU',
toName: 'Brussels',
departure: '2026-08-01T18:00:00.000+00:00',
arrival: '2026-08-01T21:30:00.000+00:00',
flightNumber: 'AY1503',
});
expect(detectConnections([flight(), returnFlight])).toHaveLength(0);
});
});
describe('AirTrailImportModal', () => {
const defaultProps = { isOpen: true, onClose: vi.fn(), tripId: 1 };
beforeEach(() => {
toasts.length = 0;
resetAllStores();
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
seedStore(useTripStore, {
trip: buildTrip({ id: 1, start_date: '2026-08-01', end_date: '2026-08-10' }),
reservations: [],
});
server.use(
http.get('/api/integrations/airtrail/flights', () =>
HttpResponse.json({ flights: [flight(), legHelJfk(), unrelatedFlight()] }),
),
http.get('/api/trips/1/reservations', () => HttpResponse.json({ reservations: [] })),
);
});
it('FE-PLANNER-AIRTRAIL-011: opens after being mounted closed (#1602)', async () => {
// The modal stays mounted with isOpen=false until the AirTrail button is
// clicked — every hook must run on the closed renders too, or the open
// render throws "Rendered more hooks than during the previous render".
const { rerender } = render(<AirTrailImportModal {...defaultProps} isOpen={false} />);
rerender(<AirTrailImportModal {...defaultProps} isOpen={true} />);
expect(await screen.findByText('Import from AirTrail')).toBeInTheDocument();
});
it('FE-PLANNER-AIRTRAIL-004: offers to join a detected connection, on by default', async () => {
render(<AirTrailImportModal {...defaultProps} />);
const joinRow = await screen.findByText(/one flight with a layover in HEL/i);
expect(joinRow).toBeInTheDocument();
// No join offer for the unrelated flight.
expect(screen.queryByText(/layover in JFK/i)).not.toBeInTheDocument();
});
it('FE-PLANNER-AIRTRAIL-005: sends the chain as a connection on import', async () => {
const user = userEvent.setup();
let body: any = null;
server.use(
http.post('/api/trips/1/reservations/import/airtrail', async ({ request }) => {
body = await request.json();
return HttpResponse.json({ imported: body.flightIds, skipped: [] });
}),
);
render(<AirTrailImportModal {...defaultProps} />);
await screen.findByText(/one flight with a layover in HEL/i);
await user.click(screen.getByRole('button', { name: /Import 3/i }));
await waitFor(() => expect(body).not.toBeNull());
expect([...body.flightIds].sort()).toEqual(['101', '102', '103']);
expect(body.connections).toEqual([['101', '102']]);
});
it('FE-PLANNER-AIRTRAIL-006: sends no connection when the join is toggled off', async () => {
const user = userEvent.setup();
let body: any = null;
server.use(
http.post('/api/trips/1/reservations/import/airtrail', async ({ request }) => {
body = await request.json();
return HttpResponse.json({ imported: body.flightIds, skipped: [] });
}),
);
render(<AirTrailImportModal {...defaultProps} />);
await user.click(await screen.findByText(/one flight with a layover in HEL/i));
await user.click(screen.getByRole('button', { name: /Import 3/i }));
await waitFor(() => expect(body).not.toBeNull());
expect(body.connections).toBeUndefined();
});
it('FE-PLANNER-AIRTRAIL-007: re-enabling the join selects all of its legs', async () => {
const user = userEvent.setup();
render(<AirTrailImportModal {...defaultProps} />);
const joinRow = await screen.findByText(/one flight with a layover in HEL/i);
// Deselect one member — the join reads unchecked, one click brings both back.
await user.click(screen.getByText('Finnair AY15'));
expect(screen.getByRole('button', { name: /Import 2/i })).toBeInTheDocument();
await user.click(joinRow);
expect(screen.getByRole('button', { name: /Import 3/i })).toBeInTheDocument();
});
it('FE-PLANNER-AIRTRAIL-010: a surviving sub-chain keeps its join offer when a later leg was already imported', async () => {
const legJfkLax = flight({
id: '106',
fromCode: 'JFK',
fromName: 'John F. Kennedy Intl.',
toCode: 'LAX',
toName: 'Los Angeles Intl.',
departure: '2026-08-01T21:00:00.000+00:00',
arrival: '2026-08-02T00:30:00.000+00:00',
flightNumber: 'AY99',
});
seedStore(useTripStore, {
trip: buildTrip({ id: 1, start_date: '2026-08-01', end_date: '2026-08-10' }),
reservations: [
buildReservation({ type: 'flight', external_source: 'airtrail', external_id: '106' }) as any,
],
});
server.use(
http.get('/api/integrations/airtrail/flights', () =>
HttpResponse.json({ flights: [flight(), legHelJfk(), legJfkLax] }),
),
);
render(<AirTrailImportModal {...defaultProps} />);
// BRU→HEL→JFK still connects even though JFK→LAX is gone from the pool.
expect(await screen.findByText(/one flight with a layover in HEL/i)).toBeInTheDocument();
expect(screen.getByText(/^Imported$/)).toBeInTheDocument();
});
it('FE-PLANNER-AIRTRAIL-008: legs of a joined import are marked imported via metadata.airtrail_ids', async () => {
seedStore(useTripStore, {
trip: buildTrip({ id: 1, start_date: '2026-08-01', end_date: '2026-08-10' }),
reservations: [
buildReservation({
type: 'flight',
external_source: 'airtrail',
external_id: '101',
metadata: JSON.stringify({ airtrail_ids: ['101', '102'] }),
}) as any,
],
});
render(<AirTrailImportModal {...defaultProps} />);
await screen.findByText('Finnair AY1502');
// Both legs disabled — including the one whose id only lives in the metadata.
expect(screen.getAllByText(/^Imported$/)).toHaveLength(2);
// A chain with an imported member cannot be joined again.
expect(screen.queryByText(/one flight with a layover/i)).not.toBeInTheDocument();
});
it('FE-PLANNER-AIRTRAIL-012: a failing flight list shows the server error and no rows', async () => {
server.use(
http.get('/api/integrations/airtrail/flights', () =>
HttpResponse.json({ error: 'AirTrail token expired' }, { status: 401 })),
);
render(<AirTrailImportModal {...defaultProps} />);
expect(await screen.findByText('AirTrail token expired')).toBeInTheDocument();
expect(screen.queryByText('Finnair AY1502')).not.toBeInTheDocument();
});
it('FE-PLANNER-AIRTRAIL-013: a transport error without a message falls back to the generic load error', async () => {
server.use(http.get('/api/integrations/airtrail/flights', () => HttpResponse.error()));
render(<AirTrailImportModal {...defaultProps} />);
expect(await screen.findByText('Could not load your AirTrail flights.')).toBeInTheDocument();
});
it('FE-PLANNER-AIRTRAIL-014: an empty account shows the empty hint and a disabled import button', async () => {
server.use(http.get('/api/integrations/airtrail/flights', () => HttpResponse.json({ flights: [] })));
render(<AirTrailImportModal {...defaultProps} />);
expect(await screen.findByText('No flights found in your AirTrail account.')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Import 0/i })).toBeDisabled();
});
it('FE-PLANNER-AIRTRAIL-015: flights outside the trip range land in "Other flights" and start unselected', async () => {
const user = userEvent.setup();
const later = flight({ id: '110', date: '2026-09-20', departure: '2026-09-20T06:00:00.000+00:00', arrival: '2026-09-20T09:30:00.000+00:00', flightNumber: 'AY900' });
server.use(
http.get('/api/integrations/airtrail/flights', () => HttpResponse.json({ flights: [flight(), later] })),
);
render(<AirTrailImportModal {...defaultProps} />);
expect(await screen.findByText('During this trip')).toBeInTheDocument();
expect(screen.getByText('Other flights')).toBeInTheDocument();
// Only the in-range flight is pre-selected.
expect(screen.getByRole('button', { name: /Import 1/i })).toBeInTheDocument();
// Selecting the out-of-range one manually brings the count up.
await user.click(screen.getByText('Finnair AY900'));
expect(screen.getByRole('button', { name: /Import 2/i })).toBeInTheDocument();
});
it('FE-PLANNER-AIRTRAIL-016: a flight without a date shows its route as the label and no date suffix', async () => {
server.use(
http.get('/api/integrations/airtrail/flights', () =>
HttpResponse.json({ flights: [flight({ id: '201', flightNumber: null, airline: null, date: null, departure: null, arrival: null })] })),
);
render(<AirTrailImportModal {...defaultProps} />);
// Label falls back to the route, and the sub-line carries no ` · <date>` suffix.
const rows = await screen.findAllByText('BRU → HEL');
expect(rows.length).toBe(2);
expect(rows[1].textContent).toBe('BRU → HEL');
});
it('FE-PLANNER-AIRTRAIL-017: a successful import toasts, registers an undo and deletes the created bookings again', async () => {
const user = userEvent.setup();
const pushUndo = vi.fn();
const deleted: number[] = [];
server.use(
http.get('/api/integrations/airtrail/flights', () => HttpResponse.json({ flights: [flight()] })),
http.post('/api/trips/1/reservations/import/airtrail', () =>
HttpResponse.json({ imported: ['101'], skipped: [{ id: '999', reason: 'already-in-trip' }] })),
http.get('/api/trips/1/reservations', () => HttpResponse.json({
reservations: [buildReservation({ id: 55, type: 'flight', external_source: 'airtrail', external_id: '101' } as any)],
})),
http.delete('/api/trips/1/reservations/55', () => { deleted.push(55); return HttpResponse.json({ success: true }); }),
);
render(<AirTrailImportModal {...defaultProps} pushUndo={pushUndo} />);
await user.click(await screen.findByRole('button', { name: /Import 1/i }));
await waitFor(() => expect(pushUndo).toHaveBeenCalled());
expect(toasts).toContainEqual(['success', '1 flight imported']);
expect(toasts).toContainEqual(['warning', '1 already in this trip, skipped']);
expect(pushUndo.mock.calls[0][0]).toBe('Import from AirTrail');
await pushUndo.mock.calls[0][1]();
expect(deleted).toEqual([55]);
});
it('FE-PLANNER-AIRTRAIL-018: an import that creates nothing warns instead of claiming success', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/integrations/airtrail/flights', () => HttpResponse.json({ flights: [flight()] })),
http.post('/api/trips/1/reservations/import/airtrail', () => HttpResponse.json({ imported: [], skipped: [] })),
);
render(<AirTrailImportModal {...defaultProps} />);
await user.click(await screen.findByRole('button', { name: /Import 1/i }));
await waitFor(() => expect(toasts).toContainEqual(['warning', 'Nothing to import.']));
});
it('FE-PLANNER-AIRTRAIL-019: a failing import keeps the dialog open with the server error', async () => {
const user = userEvent.setup();
const onClose = vi.fn();
server.use(
http.get('/api/integrations/airtrail/flights', () => HttpResponse.json({ flights: [flight()] })),
http.post('/api/trips/1/reservations/import/airtrail', () =>
HttpResponse.json({ error: 'AirTrail unreachable' }, { status: 502 })),
);
render(<AirTrailImportModal {...defaultProps} onClose={onClose} />);
await user.click(await screen.findByRole('button', { name: /Import 1/i }));
expect(await screen.findByText('AirTrail unreachable')).toBeInTheDocument();
expect(onClose).not.toHaveBeenCalled();
});
it('FE-PLANNER-AIRTRAIL-020: a backdrop press-and-release closes, a press inside does not', async () => {
const onClose = vi.fn();
render(<AirTrailImportModal {...defaultProps} onClose={onClose} />);
await screen.findByText('Import from AirTrail');
const backdrop = document.querySelector('[style*="z-index: 99999"]') as HTMLElement;
fireEvent.mouseDown(screen.getByText('Import from AirTrail'));
fireEvent.click(backdrop);
expect(onClose).not.toHaveBeenCalled();
fireEvent.mouseDown(backdrop);
fireEvent.click(backdrop);
expect(onClose).toHaveBeenCalledTimes(1);
});
it('FE-PLANNER-AIRTRAIL-021: reservations from other sources are not treated as AirTrail imports', async () => {
seedStore(useTripStore, {
trip: buildTrip({ id: 1, start_date: '2026-08-01', end_date: '2026-08-10' }),
reservations: [buildReservation({ type: 'flight', external_source: 'kitinerary', external_id: '101' }) as any],
});
render(<AirTrailImportModal {...defaultProps} />);
await screen.findByText('Finnair AY1502');
expect(screen.queryByText(/^Imported$/)).not.toBeInTheDocument();
});
it('FE-PLANNER-AIRTRAIL-022: offline the dialog says it needs a connection instead of asking AirTrail (#2601)', async () => {
const flights = vi.fn();
server.use(http.get('/api/integrations/airtrail/flights', () => { flights(); return HttpResponse.json({ flights: [flight()] }); }));
setForcedOffline(true);
try {
render(<AirTrailImportModal {...defaultProps} />);
expect(await screen.findByText('This needs a connection. Try again once TREK is back online.')).toBeInTheDocument();
} finally {
_resetNetworkMode();
}
expect(flights).not.toHaveBeenCalled();
});
});