mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
4cab17a6b9
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
247 lines
10 KiB
TypeScript
247 lines
10 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import { buildSavePayload } from '../../../src/nest/integrations/airtrail-sync.service';
|
|
import type { AirtrailAirport, AirtrailFlightRaw, AirtrailSavePayload } from '../../../src/nest/integrations/airtrail.client';
|
|
|
|
/**
|
|
* buildSavePayload spreads the raw flight, so the body also carries the
|
|
* AirTrail-owned keys the save type deliberately leaves unmodelled (terminal,
|
|
* gate, actual times, customFields, track). Read those through this view.
|
|
*/
|
|
type SavePayloadWithPassthrough = AirtrailSavePayload & Record<string, unknown>;
|
|
|
|
function airport(over: Partial<AirtrailAirport> = {}): AirtrailAirport {
|
|
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,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* An AirTrail flight as GET returns it, including the fields TREK doesn't model.
|
|
* Typed as the raw object (known shape + arbitrary passthrough keys) because the
|
|
* push spreads it wholesale rather than mapping each field — see buildSavePayload.
|
|
*/
|
|
function existingFlight(
|
|
over: Partial<AirtrailFlightRaw> & Record<string, unknown> = {},
|
|
): AirtrailFlightRaw & Record<string, unknown> {
|
|
return {
|
|
id: 42,
|
|
from: airport(),
|
|
to: airport({ id: 2, icao: 'EGLL', iata: 'LHR', name: 'London Heathrow', tz: 'Europe/London' }),
|
|
date: '2021-09-01',
|
|
datePrecision: 'day',
|
|
departure: '2021-09-01T23:00:00.000+00:00',
|
|
arrival: '2021-09-02T07:00:00.000+00:00',
|
|
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' }],
|
|
// AirTrail-owned detail TREK never surfaces — must survive a writeback (#1240).
|
|
departureScheduled: '2021-09-01',
|
|
departureScheduledTime: '18:45',
|
|
arrivalScheduled: '2021-09-02',
|
|
arrivalScheduledTime: '08:10',
|
|
takeoffActual: '2021-09-01',
|
|
takeoffActualTime: '19:12',
|
|
landingActual: '2021-09-02',
|
|
landingActualTime: '07:55',
|
|
departureTerminal: '7',
|
|
departureGate: 'B22',
|
|
arrivalTerminal: '5',
|
|
arrivalGate: 'A10',
|
|
customFields: { confirmation: 'ABC123' },
|
|
track: [{ lat: 40.6, lon: -73.7 }],
|
|
...over,
|
|
};
|
|
}
|
|
|
|
/** A linked TREK reservation (the shape getReservationWithJoins returns). */
|
|
function reservation(over: Record<string, unknown> = {}): Record<string, unknown> {
|
|
return {
|
|
external_id: '42',
|
|
reservation_time: '2021-09-01T19:00',
|
|
reservation_end_time: '2021-09-02T08:00',
|
|
notes: 'window seat',
|
|
metadata: JSON.stringify({ airline: 'BAW', flight_number: 'BA178', aircraft: 'B772', aircraft_reg: 'G-VIIL', flight_reason: 'leisure', seat: '12A' }),
|
|
endpoints: [
|
|
{ role: 'from', code: 'JFK' },
|
|
{ role: 'to', code: 'LHR' },
|
|
],
|
|
...over,
|
|
};
|
|
}
|
|
|
|
describe('airtrailSync.buildSavePayload', () => {
|
|
it('round-trips the AirTrail-owned fields TREK does not model (issue #1240)', () => {
|
|
const payload = buildSavePayload(reservation(), existingFlight());
|
|
expect(payload).not.toBeNull();
|
|
expect(payload).toMatchObject({
|
|
takeoffActual: '2021-09-01',
|
|
takeoffActualTime: '19:12',
|
|
landingActual: '2021-09-02',
|
|
landingActualTime: '07:55',
|
|
departureTerminal: '7',
|
|
departureGate: 'B22',
|
|
arrivalTerminal: '5',
|
|
arrivalGate: 'A10',
|
|
customFields: { confirmation: 'ABC123' },
|
|
track: [{ lat: 40.6, lon: -73.7 }],
|
|
});
|
|
});
|
|
|
|
it('writes the TREK time to the SCHEDULED fields so it round-trips on the next pull', () => {
|
|
// Import reads the scheduled time, so a TREK edit must be pushed back there
|
|
// (mirroring the read), overwriting AirTrail's stored scheduled value.
|
|
const payload = buildSavePayload(reservation(), existingFlight());
|
|
expect(payload).toMatchObject({
|
|
departureScheduled: '2021-09-01T00:00:00.000Z',
|
|
departureScheduledTime: '19:00',
|
|
arrivalScheduled: '2021-09-02T00:00:00.000Z',
|
|
arrivalScheduledTime: '08:00',
|
|
});
|
|
});
|
|
|
|
it('blanks the scheduled time when the TREK reservation has only a date', () => {
|
|
const payload = buildSavePayload(reservation({ reservation_time: '2021-09-01', reservation_end_time: null }), existingFlight());
|
|
// A date carrier with no HH:MM leaves AirTrail's scheduled instant unset.
|
|
expect(payload?.departureScheduledTime).toBeNull();
|
|
expect(payload?.arrivalScheduled).toBeNull();
|
|
expect(payload?.arrivalScheduledTime).toBeNull();
|
|
});
|
|
|
|
it('preserves a non-day date precision instead of resetting it to day', () => {
|
|
const payload = buildSavePayload(reservation(), existingFlight({ datePrecision: 'month' }));
|
|
expect(payload?.datePrecision).toBe('month');
|
|
});
|
|
|
|
it('still applies the TREK-owned edits on top of the preserved fields', () => {
|
|
const payload = buildSavePayload(
|
|
reservation({
|
|
reservation_time: '2021-09-01T20:30',
|
|
notes: 'changed in TREK',
|
|
metadata: JSON.stringify({ airline: 'BAW', flight_number: 'BA999', seat: '3C' }),
|
|
}),
|
|
existingFlight(),
|
|
) as SavePayloadWithPassthrough | null;
|
|
expect(payload).toMatchObject({
|
|
id: 42,
|
|
from: 'JFK',
|
|
to: 'LHR',
|
|
departure: '2021-09-01',
|
|
departureTime: '20:30',
|
|
departureScheduled: '2021-09-01T00:00:00.000Z',
|
|
departureScheduledTime: '20:30',
|
|
flightNumber: 'BA999',
|
|
note: 'changed in TREK',
|
|
});
|
|
// The user's seat number is pushed onto their own AirTrail seat.
|
|
expect(payload?.seats[0].seatNumber).toBe('3C');
|
|
// …without disturbing the preserved AirTrail detail.
|
|
expect(payload?.departureTerminal).toBe('7');
|
|
});
|
|
|
|
it('preserves AirTrail aircraft/airline/reason when TREK metadata omits them (#1240)', () => {
|
|
// A TREK edit can drop these AirTrail-owned fields from metadata; the writeback
|
|
// must fall back to AirTrail's current values rather than nulling them.
|
|
const payload = buildSavePayload(reservation({ metadata: JSON.stringify({}) }), existingFlight());
|
|
expect(payload).toMatchObject({
|
|
airline: 'BAW', // entityCode(existing.airline) — icao preferred
|
|
aircraft: 'B772',
|
|
aircraftReg: 'G-VIIL',
|
|
flightReason: 'leisure',
|
|
flightNumber: 'BA178',
|
|
note: 'window seat',
|
|
});
|
|
});
|
|
|
|
it('keeps the existing seat manifest rather than replacing it', () => {
|
|
const payload = buildSavePayload(
|
|
reservation({ metadata: JSON.stringify({}) }),
|
|
existingFlight({
|
|
seats: [
|
|
{ userId: 'u1', guestName: null, seat: 'window', seatNumber: '12A', seatClass: 'business' },
|
|
{ userId: null, guestName: 'Guest', seat: 'aisle', seatNumber: '12B', seatClass: 'business' },
|
|
],
|
|
}),
|
|
);
|
|
expect(payload?.seats).toHaveLength(2);
|
|
expect(payload?.seats[1]).toMatchObject({ guestName: 'Guest', seatNumber: '12B' });
|
|
});
|
|
|
|
it('returns null when an endpoint code is missing and no fallback exists', () => {
|
|
const payload = buildSavePayload(reservation({ endpoints: [] }), existingFlight({ from: airport({ iata: null, icao: null }) }));
|
|
expect(payload).toBeNull();
|
|
});
|
|
});
|
|
|
|
// AirTrail 3.12.0 renamed the passenger list from `seats` to `passengers`, with
|
|
// no alias on the save endpoint either (#1931). Neither schema is strict, so the
|
|
// payload carries both names and each version keeps the one it knows.
|
|
describe('airtrailSync.buildSavePayload — AirTrail 3.12.0 passengers', () => {
|
|
const newShape = (over: Partial<AirtrailFlightRaw> & Record<string, unknown> = {}) =>
|
|
existingFlight({
|
|
seats: undefined,
|
|
flightReason: undefined,
|
|
passengers: [{ userId: 'u1', guestName: null, seat: 'window', seatNumber: '12A', seatClass: 'economy', flightReason: 'leisure' }],
|
|
...over,
|
|
});
|
|
|
|
const own = (list: unknown) => (list as Array<{ userId: string | null }>).find(s => s.userId);
|
|
|
|
it('sends the manifest under both names', () => {
|
|
const payload = buildSavePayload(reservation(), newShape()) as SavePayloadWithPassthrough;
|
|
expect(payload.passengers).toEqual(payload.seats);
|
|
expect(own(payload.passengers)).toMatchObject({ seatNumber: '12A', seatClass: 'economy' });
|
|
});
|
|
|
|
it('reads the existing manifest off passengers and pushes the TREK seat onto it', () => {
|
|
const res = reservation({ metadata: JSON.stringify({ seat: '22F' }) });
|
|
const payload = buildSavePayload(res, newShape()) as SavePayloadWithPassthrough;
|
|
expect(own(payload.passengers)).toMatchObject({ seatNumber: '22F' });
|
|
expect(own(payload.seats)).toMatchObject({ seatNumber: '22F' });
|
|
// A co-passenger's seat is not TREK's to touch.
|
|
const withGuest = buildSavePayload(res, newShape({
|
|
passengers: [
|
|
{ userId: null, guestName: 'Plus One', seat: null, seatNumber: '30C', seatClass: null },
|
|
{ userId: 'u1', guestName: null, seat: 'window', seatNumber: '12A', seatClass: 'economy' },
|
|
],
|
|
})) as SavePayloadWithPassthrough;
|
|
const guest = (withGuest.passengers as Array<{ guestName: string | null; seatNumber: string | null }>).find(s => s.guestName);
|
|
expect(guest?.seatNumber).toBe('30C');
|
|
});
|
|
|
|
it('writes the flight reason both on the flight and on the passenger', () => {
|
|
const payload = buildSavePayload(reservation(), newShape()) as SavePayloadWithPassthrough;
|
|
expect(payload.flightReason).toBe('leisure');
|
|
expect(own(payload.passengers)).toMatchObject({ flightReason: 'leisure' });
|
|
});
|
|
|
|
it('recovers the reason from the passenger when the flight no longer carries one', () => {
|
|
const res = reservation({ metadata: JSON.stringify({ seat: '12A' }) });
|
|
const payload = buildSavePayload(res, newShape()) as SavePayloadWithPassthrough;
|
|
expect(payload.flightReason).toBe('leisure');
|
|
});
|
|
|
|
it('still fills the manifest from the old shape', () => {
|
|
const payload = buildSavePayload(reservation(), existingFlight()) as SavePayloadWithPassthrough;
|
|
expect(own(payload.passengers)).toMatchObject({ seatNumber: '12A' });
|
|
expect(own(payload.seats)).toMatchObject({ seatNumber: '12A' });
|
|
});
|
|
|
|
it('falls back to the key-owner placeholder when the flight has no manifest at all', () => {
|
|
const payload = buildSavePayload(reservation(), newShape({ passengers: [] })) as SavePayloadWithPassthrough;
|
|
expect(payload.passengers).toHaveLength(1);
|
|
expect(own(payload.passengers)).toMatchObject({ userId: '<USER_ID>', seatNumber: '12A' });
|
|
});
|
|
});
|