mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 13:36:48 +00:00
460 lines
18 KiB
TypeScript
460 lines
18 KiB
TypeScript
import type { AirtrailFlightRaw } from '../../../src/nest/integrations/airtrail.client';
|
|
import {
|
|
canonicalHash,
|
|
mapFlightToReservation,
|
|
mapFlightsToMultiLegReservation,
|
|
normalizeFlight,
|
|
} from '../../../src/nest/integrations/airtrail.mapper';
|
|
|
|
import { describe, it, expect } from 'vitest';
|
|
|
|
function airport(over: Partial<AirtrailFlightRaw['from']> = {}): NonNullable<AirtrailFlightRaw['from']> {
|
|
return {
|
|
id: 1,
|
|
icao: 'KJFK',
|
|
iata: 'JFK',
|
|
name: 'John F. Kennedy Intl.',
|
|
lat: 40.6413,
|
|
lon: -73.7781,
|
|
tz: 'America/New_York',
|
|
country: 'US',
|
|
...over,
|
|
};
|
|
}
|
|
|
|
function flight(over: Partial<AirtrailFlightRaw> = {}): AirtrailFlightRaw {
|
|
return {
|
|
id: 42,
|
|
from: airport(),
|
|
to: airport({
|
|
id: 2,
|
|
icao: 'EGLL',
|
|
iata: 'LHR',
|
|
name: 'London Heathrow',
|
|
lat: 51.4706,
|
|
lon: -0.4619,
|
|
tz: 'Europe/London',
|
|
}),
|
|
date: '2021-09-01',
|
|
datePrecision: 'day',
|
|
// Actual times (delayed) — TREK must IGNORE these and read the scheduled times.
|
|
departure: '2021-09-01T23:42:00.000+00:00',
|
|
arrival: '2021-09-02T07:42:00.000+00:00',
|
|
departureScheduled: '2021-09-01T23:00:00.000+00:00', // 19:00 local at JFK (EDT, UTC-4)
|
|
arrivalScheduled: '2021-09-02T07:00:00.000+00:00', // 08:00 local at LHR (BST, UTC+1)
|
|
airline: { id: 1, icao: 'BAW', iata: 'BA', name: 'British Airways' },
|
|
flightNumber: 'BA178',
|
|
aircraft: { id: 1, icao: 'B772', name: 'Boeing 777' },
|
|
aircraftReg: 'G-VIIL',
|
|
flightReason: 'leisure',
|
|
note: 'window seat',
|
|
seats: [{ userId: 'u1', guestName: null, seat: 'window', seatNumber: '12A', seatClass: 'economy' }],
|
|
...over,
|
|
};
|
|
}
|
|
|
|
describe('airtrailMapper.normalizeFlight', () => {
|
|
it('prefers IATA codes and exposes the picker fields', () => {
|
|
const n = normalizeFlight(flight());
|
|
expect(n).toMatchObject({
|
|
id: '42',
|
|
fromCode: 'JFK',
|
|
toCode: 'LHR',
|
|
date: '2021-09-01',
|
|
airline: 'British Airways',
|
|
flightNumber: 'BA178',
|
|
seatClass: 'economy',
|
|
});
|
|
// The picker preview prefers the scheduled times over the actual ones.
|
|
expect(n.departure).toBe('2021-09-01T23:00:00.000+00:00');
|
|
expect(n.arrival).toBe('2021-09-02T07:00:00.000+00:00');
|
|
});
|
|
|
|
it('#1336 surfaces the primary departure/arrival when there is no scheduled time', () => {
|
|
const n = normalizeFlight(flight({ departureScheduled: null, arrivalScheduled: null }));
|
|
expect(n.departure).toBe('2021-09-01T23:42:00.000+00:00');
|
|
expect(n.arrival).toBe('2021-09-02T07:42:00.000+00:00');
|
|
});
|
|
|
|
it('falls back to ICAO when IATA is missing and tolerates null airports', () => {
|
|
const n = normalizeFlight(flight({ from: airport({ iata: null }), to: null }));
|
|
expect(n.fromCode).toBe('KJFK');
|
|
expect(n.toCode).toBeNull();
|
|
expect(n.toName).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('airtrailMapper.mapFlightToReservation', () => {
|
|
it('composes airport-local times from the SCHEDULED instant + airport tz', () => {
|
|
const m = mapFlightToReservation(flight());
|
|
// Scheduled 23:00 UTC at JFK in September is 19:00 EDT; date stays the AirTrail local date.
|
|
// (The actual times in the fixture are 23:42/07:42 — proving they are ignored.)
|
|
expect(m.reservation_time).toBe('2021-09-01T19:00');
|
|
// Scheduled 07:00 UTC at LHR in September is 08:00 BST.
|
|
expect(m.reservation_end_time).toBe('2021-09-02T08:00');
|
|
});
|
|
|
|
it('leaves the clock blank (date only) when the flight has no time at all', () => {
|
|
const m = mapFlightToReservation(
|
|
flight({ departure: null, arrival: null, departureScheduled: null, arrivalScheduled: null }),
|
|
);
|
|
// Date is preserved from the AirTrail canonical date; no fabricated 00:00.
|
|
expect(m.reservation_time).toBe('2021-09-01');
|
|
expect(m.reservation_end_time).toBeNull();
|
|
expect(m.endpoints.find((e) => e.role === 'from')?.local_time).toBeNull();
|
|
expect(m.endpoints.find((e) => e.role === 'to')?.local_time).toBeNull();
|
|
});
|
|
|
|
it('#1336 falls back to the primary departure/arrival when AirTrail has no scheduled times', () => {
|
|
// Manually-entered AirTrail flights set only departure/arrival; *Scheduled stays null.
|
|
const m = mapFlightToReservation(flight({ departureScheduled: null, arrivalScheduled: null }));
|
|
// departure 23:42 UTC at JFK (EDT) = 19:42 local; arrival 07:42 UTC at LHR (BST) = 08:42.
|
|
expect(m.reservation_time).toBe('2021-09-01T19:42');
|
|
expect(m.reservation_end_time).toBe('2021-09-02T08:42');
|
|
expect(m.endpoints.find((e) => e.role === 'from')?.local_time).toBe('19:42');
|
|
expect(m.endpoints.find((e) => e.role === 'to')?.local_time).toBe('08:42');
|
|
expect(m.endpoints.find((e) => e.role === 'to')?.local_date).toBe('2021-09-02');
|
|
});
|
|
|
|
it('builds two endpoints with codes, coords and timezones', () => {
|
|
const m = mapFlightToReservation(flight());
|
|
expect(m.endpoints).toHaveLength(2);
|
|
expect(m.endpoints[0]).toMatchObject({
|
|
role: 'from',
|
|
code: 'JFK',
|
|
lat: 40.6413,
|
|
timezone: 'America/New_York',
|
|
local_date: '2021-09-01',
|
|
local_time: '19:00',
|
|
});
|
|
expect(m.endpoints[1]).toMatchObject({ role: 'to', code: 'LHR', timezone: 'Europe/London', local_time: '08:00' });
|
|
expect(m.needs_review).toBe(0);
|
|
});
|
|
|
|
it('titles from the flight number, else the route', () => {
|
|
expect(mapFlightToReservation(flight()).title).toBe('BA178');
|
|
expect(mapFlightToReservation(flight({ airline: null, flightNumber: null })).title).toBe('JFK → LHR');
|
|
});
|
|
|
|
it('carries flight metadata', () => {
|
|
const m = mapFlightToReservation(flight());
|
|
// #1334: display the airline name, keep the code in airline_code for the writeback.
|
|
expect(m.metadata).toMatchObject({
|
|
airline: 'British Airways',
|
|
airline_code: 'BAW',
|
|
flight_number: 'BA178',
|
|
aircraft: 'B772',
|
|
aircraft_reg: 'G-VIIL',
|
|
flight_reason: 'leisure',
|
|
seat: '12A',
|
|
});
|
|
expect(m.type).toBe('flight');
|
|
expect(m.status).toBe('confirmed');
|
|
expect(m.notes).toBe('window seat');
|
|
});
|
|
|
|
it('#1334 falls back to the airline code when AirTrail provides no name', () => {
|
|
const a = { id: 9, icao: 'EWG', iata: 'EW' };
|
|
expect(normalizeFlight(flight({ airline: a })).airline).toBe('EWG');
|
|
expect(mapFlightToReservation(flight({ airline: a })).metadata).toMatchObject({
|
|
airline: 'EWG',
|
|
airline_code: 'EWG',
|
|
});
|
|
});
|
|
|
|
it('uses only the seat number for the seat, not the cabin class (#1246)', () => {
|
|
// AirTrail often has a class but no seat number until check-in; the class
|
|
// must not leak into the seat field.
|
|
const m = mapFlightToReservation(
|
|
flight({ seats: [{ userId: 'u1', guestName: null, seat: null, seatNumber: null, seatClass: 'economy' }] }),
|
|
);
|
|
expect(m.metadata).not.toHaveProperty('seat');
|
|
});
|
|
|
|
it('keeps the seat number when present even with no class', () => {
|
|
const m = mapFlightToReservation(
|
|
flight({ seats: [{ userId: 'u1', guestName: null, seat: null, seatNumber: '3F', seatClass: null }] }),
|
|
);
|
|
expect(m.metadata).toMatchObject({ seat: '3F' });
|
|
});
|
|
|
|
it('omits the seat for a flight with no seats', () => {
|
|
expect(mapFlightToReservation(flight({ seats: [] })).metadata).not.toHaveProperty('seat');
|
|
});
|
|
|
|
it('flags needs_review for a non-day date precision', () => {
|
|
expect(mapFlightToReservation(flight({ datePrecision: 'month' })).needs_review).toBe(1);
|
|
});
|
|
|
|
it('flags needs_review and drops the endpoint when an airport has no coordinates', () => {
|
|
const m = mapFlightToReservation(flight({ from: airport({ lat: null, lon: null }) }));
|
|
expect(m.needs_review).toBe(1);
|
|
expect(m.endpoints.find((e) => e.role === 'from')).toBeUndefined();
|
|
expect(m.endpoints.find((e) => e.role === 'to')).toBeDefined();
|
|
});
|
|
|
|
it('leaves the end time null for a partial flight with no arrival time at all', () => {
|
|
const m = mapFlightToReservation(flight({ arrival: null, arrivalScheduled: null }));
|
|
expect(m.reservation_end_time).toBeNull();
|
|
expect(m.reservation_time).toBe('2021-09-01T19:00');
|
|
});
|
|
});
|
|
|
|
describe('airtrailMapper.canonicalHash', () => {
|
|
it('is stable for the same flight', () => {
|
|
expect(canonicalHash(flight())).toBe(canonicalHash(flight()));
|
|
});
|
|
|
|
it('changes when a meaningful field changes', () => {
|
|
expect(canonicalHash(flight())).not.toBe(canonicalHash(flight({ flightNumber: 'BA179' })));
|
|
expect(canonicalHash(flight())).not.toBe(canonicalHash(flight({ note: 'aisle seat' })));
|
|
});
|
|
|
|
it('tracks the scheduled time and ignores actual-time changes', () => {
|
|
// A scheduled-time change is what TREK imports, so it must re-sync...
|
|
expect(canonicalHash(flight())).not.toBe(
|
|
canonicalHash(flight({ departureScheduled: '2021-09-01T22:00:00.000+00:00' })),
|
|
);
|
|
// ...but a change to the actual time alone must not (TREK shows the scheduled one).
|
|
expect(canonicalHash(flight())).toBe(
|
|
canonicalHash(flight({ departure: '2021-09-01T20:00:00.000+00:00', arrival: '2021-09-02T05:00:00.000+00:00' })),
|
|
);
|
|
});
|
|
|
|
it('#1336 tracks the primary departure when there is no scheduled time', () => {
|
|
// With no scheduled time, departure IS what TREK imports, so a change must re-sync.
|
|
const manual = flight({ departureScheduled: null, arrivalScheduled: null });
|
|
expect(canonicalHash(manual)).not.toBe(
|
|
canonicalHash(
|
|
flight({ departureScheduled: null, arrivalScheduled: null, departure: '2021-09-01T20:00:00.000+00:00' }),
|
|
),
|
|
);
|
|
});
|
|
|
|
it('is independent of seat ordering', () => {
|
|
const a = flight({
|
|
seats: [
|
|
{ userId: 'u1', guestName: null, seat: null, seatNumber: '1A', seatClass: 'economy' },
|
|
{ userId: 'u2', guestName: null, seat: null, seatNumber: '1B', seatClass: 'economy' },
|
|
],
|
|
});
|
|
const b = flight({
|
|
seats: [
|
|
{ userId: 'u2', guestName: null, seat: null, seatNumber: '1B', seatClass: 'economy' },
|
|
{ userId: 'u1', guestName: null, seat: null, seatNumber: '1A', seatClass: 'economy' },
|
|
],
|
|
});
|
|
expect(canonicalHash(a)).toBe(canonicalHash(b));
|
|
});
|
|
});
|
|
|
|
describe('airtrailMapper.mapFlightsToMultiLegReservation (#1535)', () => {
|
|
const BRU = () =>
|
|
airport({ id: 10, icao: 'EBBR', iata: 'BRU', name: 'Brussels', lat: 50.9014, lon: 4.4844, tz: 'Europe/Brussels' });
|
|
const HEL = (over: Partial<AirtrailFlightRaw['from']> = {}) =>
|
|
airport({
|
|
id: 11,
|
|
icao: 'EFHK',
|
|
iata: 'HEL',
|
|
name: 'Helsinki-Vantaa',
|
|
lat: 60.3172,
|
|
lon: 24.9633,
|
|
tz: 'Europe/Helsinki',
|
|
...over,
|
|
});
|
|
|
|
const leg1 = (over: Partial<AirtrailFlightRaw> = {}) =>
|
|
flight({
|
|
id: 101,
|
|
from: BRU(),
|
|
to: HEL(),
|
|
departure: null,
|
|
arrival: null,
|
|
departureScheduled: '2021-09-01T06:00:00.000+00:00', // 08:00 CEST in Brussels
|
|
arrivalScheduled: '2021-09-01T09:30:00.000+00:00', // 12:30 EEST in Helsinki
|
|
airline: { id: 2, icao: 'FIN', iata: 'AY', name: 'Finnair' },
|
|
flightNumber: 'AY1502',
|
|
note: 'first hop',
|
|
seats: [{ userId: 'u1', guestName: null, seat: 'window', seatNumber: '12A', seatClass: 'economy' }],
|
|
...over,
|
|
});
|
|
const leg2 = (over: Partial<AirtrailFlightRaw> = {}) =>
|
|
flight({
|
|
id: 102,
|
|
from: HEL(),
|
|
to: airport(), // JFK
|
|
departure: null,
|
|
arrival: null,
|
|
departureScheduled: '2021-09-01T11:00:00.000+00:00', // 14:00 EEST in Helsinki
|
|
arrivalScheduled: '2021-09-01T19:00:00.000+00:00', // 15:00 EDT at JFK
|
|
airline: { id: 2, icao: 'FIN', iata: 'AY', name: 'Finnair' },
|
|
flightNumber: 'AY15',
|
|
note: null,
|
|
seats: [{ userId: 'u1', guestName: null, seat: 'aisle', seatNumber: '22C', seatClass: 'economy' }],
|
|
...over,
|
|
});
|
|
|
|
it('builds one from → stop → to chain, the stop carrying the onward departure', () => {
|
|
const m = mapFlightsToMultiLegReservation([leg1(), leg2()]);
|
|
expect(m.endpoints.map((e) => [e.role, e.code, e.sequence])).toEqual([
|
|
['from', 'BRU', 0],
|
|
['stop', 'HEL', 1],
|
|
['to', 'JFK', 2],
|
|
]);
|
|
// The connection endpoint stores the departure of the leg LEAVING it, like
|
|
// the manual multi-leg form; only the final endpoint keeps its arrival.
|
|
expect(m.endpoints[1].local_time).toBe('14:00');
|
|
expect(m.endpoints[1].local_date).toBe('2021-09-01');
|
|
expect(m.endpoints[2].local_time).toBe('15:00');
|
|
expect(m.reservation_time).toBe('2021-09-01T08:00');
|
|
expect(m.reservation_end_time).toBe('2021-09-01T15:00');
|
|
expect(m.title).toBe('BRU → HEL → JFK');
|
|
});
|
|
|
|
it('stores per-leg details in metadata.legs and mirrors the first/last leg flat', () => {
|
|
const m = mapFlightsToMultiLegReservation([leg1(), leg2()]);
|
|
expect(m.metadata.legs).toEqual([
|
|
{
|
|
from: 'BRU',
|
|
to: 'HEL',
|
|
airline: 'Finnair',
|
|
flight_number: 'AY1502',
|
|
dep_day_id: null,
|
|
dep_time: '08:00',
|
|
arr_day_id: null,
|
|
arr_time: '12:30',
|
|
seat: '12A',
|
|
},
|
|
{
|
|
from: 'HEL',
|
|
to: 'JFK',
|
|
airline: 'Finnair',
|
|
flight_number: 'AY15',
|
|
dep_day_id: null,
|
|
dep_time: '14:00',
|
|
arr_day_id: null,
|
|
arr_time: '15:00',
|
|
seat: '22C',
|
|
},
|
|
]);
|
|
expect(m.metadata).toMatchObject({
|
|
airline: 'Finnair',
|
|
airline_code: 'FIN',
|
|
flight_number: 'AY1502',
|
|
departure_airport: 'BRU',
|
|
arrival_airport: 'JFK',
|
|
seat: '12A',
|
|
airtrail_ids: ['101', '102'],
|
|
});
|
|
expect(m.notes).toBe('first hop');
|
|
});
|
|
|
|
it('flags needs_review but keeps the rest of the chain when the connection airport has no coordinates', () => {
|
|
const m = mapFlightsToMultiLegReservation([
|
|
leg1({ to: HEL({ lat: null, lon: null }) }),
|
|
leg2({ from: HEL({ lat: null, lon: null }) }),
|
|
]);
|
|
expect(m.needs_review).toBe(1);
|
|
expect(m.endpoints.map((e) => [e.role, e.code])).toEqual([
|
|
['from', 'BRU'],
|
|
['to', 'JFK'],
|
|
]);
|
|
});
|
|
|
|
it('files each leg on its own day via the resolver — an overnight connection must not inherit day 1', () => {
|
|
const overnight = leg2({
|
|
date: '2021-09-02',
|
|
departureScheduled: '2021-09-02T06:00:00.000+00:00', // 09:00 EEST next morning
|
|
arrivalScheduled: '2021-09-02T14:00:00.000+00:00', // 10:00 EDT
|
|
});
|
|
const dayIds: Record<string, number> = { '2021-09-01': 11, '2021-09-02': 12 };
|
|
const m = mapFlightsToMultiLegReservation([leg1(), overnight], (d) => (d ? (dayIds[d] ?? null) : null));
|
|
const legs = m.metadata.legs as any[];
|
|
expect(legs[0]).toMatchObject({ dep_day_id: 11, arr_day_id: 11 });
|
|
expect(legs[1]).toMatchObject({ dep_day_id: 12, arr_day_id: 12 });
|
|
});
|
|
|
|
it('falls back to the single-flight mapping for a one-flight chain', () => {
|
|
expect(mapFlightsToMultiLegReservation([flight()])).toEqual(mapFlightToReservation(flight()));
|
|
});
|
|
});
|
|
|
|
// AirTrail 3.12.0 renamed the passenger list from `seats` to `passengers` and
|
|
// moved flightReason from the flight onto each passenger, with no alias either
|
|
// way (#1931). The shapes below are the ones a real 3.12.0 and a real 3.11.1
|
|
// return: each sends its own key and null for the other.
|
|
describe('airtrailMapper — AirTrail 3.12.0 passengers', () => {
|
|
const newShape = (over: Partial<AirtrailFlightRaw> = {}) =>
|
|
flight({
|
|
seats: null as never,
|
|
flightReason: null,
|
|
passengers: [
|
|
{
|
|
userId: 'u1',
|
|
guestName: null,
|
|
seat: 'window',
|
|
seatNumber: '12A',
|
|
seatClass: 'economy',
|
|
flightReason: 'business',
|
|
},
|
|
],
|
|
...over,
|
|
});
|
|
|
|
it('reads the seat off passengers', () => {
|
|
expect(mapFlightToReservation(newShape()).metadata.seat).toBe('12A');
|
|
});
|
|
|
|
it('reads the seat class off passengers', () => {
|
|
expect(normalizeFlight(newShape()).seatClass).toBe('economy');
|
|
});
|
|
|
|
it('takes the flight reason off the passenger now that the flight has none', () => {
|
|
expect(mapFlightToReservation(newShape()).metadata.flight_reason).toBe('business');
|
|
});
|
|
|
|
it('prefers the entry belonging to the key owner over a co-passenger', () => {
|
|
const m = mapFlightToReservation(
|
|
newShape({
|
|
passengers: [
|
|
{ userId: null, guestName: 'Plus One', seat: null, seatNumber: '30C', seatClass: null },
|
|
{ userId: 'u1', guestName: null, seat: 'window', seatNumber: '12A', seatClass: 'economy' },
|
|
],
|
|
}),
|
|
);
|
|
expect(m.metadata.seat).toBe('12A');
|
|
});
|
|
|
|
it('still reads the old shape, so an older instance keeps working', () => {
|
|
const m = mapFlightToReservation(flight());
|
|
expect(m.metadata.seat).toBe('12A');
|
|
expect(m.metadata.flight_reason).toBe('leisure');
|
|
});
|
|
|
|
it('carries the per-leg seat through the multi-leg mapping', () => {
|
|
const legs = mapFlightsToMultiLegReservation([
|
|
newShape({ id: 1 }),
|
|
newShape({
|
|
id: 2,
|
|
passengers: [{ userId: 'u1', guestName: null, seat: null, seatNumber: '3F', seatClass: null }],
|
|
}),
|
|
]).metadata.legs as any[];
|
|
expect(legs.map((l) => l.seat)).toEqual(['12A', '3F']);
|
|
});
|
|
|
|
// The snapshot hash decides whether a pull re-imports a flight. Feeding it from
|
|
// either shape under the same key keeps an unchanged old-shape flight hashing
|
|
// exactly as before, so upgrading TREK must not re-sync every flight.
|
|
it('leaves the hash of an unchanged old-shape flight alone', () => {
|
|
// Pinned to what the pre-#1931 snapshot produced for this fixture. If this
|
|
// ever has to be re-pinned, every existing AirTrail flight re-syncs once.
|
|
expect(canonicalHash(flight())).toBe('81d00fb3b22dffa5575221f73c959f5762e50e20e9c5cf7ecc5a485a1845b358');
|
|
});
|
|
|
|
it('hashes the new shape as the same flight as the old one', () => {
|
|
const asOld = flight({ flightReason: 'business' });
|
|
const asNew = newShape();
|
|
expect(canonicalHash(asNew)).toBe(canonicalHash(asOld));
|
|
});
|
|
});
|