mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 13:36:48 +00:00
279 lines
11 KiB
TypeScript
279 lines
11 KiB
TypeScript
import { buildSavePayload } from '../../../src/nest/integrations/airtrail-sync.service';
|
|
import type {
|
|
AirtrailAirport,
|
|
AirtrailFlightRaw,
|
|
AirtrailSavePayload,
|
|
} from '../../../src/nest/integrations/airtrail.client';
|
|
|
|
import { describe, it, expect } from 'vitest';
|
|
|
|
/**
|
|
* 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' });
|
|
});
|
|
});
|