Files
TREK/server/tests/unit/services/airtrailMapper.test.ts
T
Maurice 4cab17a6b9 fix(airtrail): read and write the passenger list under both its names
AirTrail 3.12.0 renamed the flight's passenger list from `seats` to
`passengers` and moved flightReason from the flight onto each passenger. There
is no alias in either direction: a 3.12.0 instance returns `passengers` and
`null` for `seats`, and its save schema accepts only `passengers`.

TREK read `raw.seats` everywhere, so against a 3.12.0 instance it found nothing.
The seat never made it into the import, and because a pull rebuilds the metadata
from the AirTrail payload, flights that had already been imported lost the seat
they had. That is the report. Flights whose sync is switched off were never
re-pulled, which is why theirs survived.

Two more breaks came from the same rename and were not reported. The flight
reason stopped importing for the same reason. And the writeback pushed the seat
under `seats`, which the new schema strips, so a seat edited in TREK was quietly
dropped on the way out; it did not fail loudly because the spread of the fetched
flight carried AirTrail's own `passengers` through untouched.

Both shapes are handled rather than switched between, because instances on 3.11
and older are still out there: reads take `passengers ?? seats`, and the save
body carries the manifest under both keys. Neither schema is strict, so each
version keeps the key it knows and strips the other.

The snapshot hash deliberately keeps feeding the manifest in under the same key.
An unchanged flight on an older instance therefore hashes exactly as before and
does not re-sync, while a 3.12.0 flight hashes differently once and pulls the
missing seat back in. There is a pinned-hash test guarding the first half.

Verified against real instances rather than the docs: a 3.11.1 and a 3.12.0
container, seat imported from each, a seat edited in TREK written back and read
out of AirTrail again, and the pre-fix build failing the same checks on 3.12.0.

Closes #1931
2026-08-14 14:32:35 +02:00

377 lines
17 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import { canonicalHash, mapFlightToReservation, mapFlightsToMultiLegReservation, normalizeFlight } from '../../../src/nest/integrations/airtrail.mapper';
import type { AirtrailFlightRaw } from '../../../src/nest/integrations/airtrail.client';
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));
});
});