mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 21:46:49 +00:00
349 lines
16 KiB
TypeScript
349 lines
16 KiB
TypeScript
// FE-PLANNER-AIRTRAIL-001 to FE-PLANNER-AIRTRAIL-020
|
|
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 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(s) 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();
|
|
});
|
|
});
|