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; function airport(over: Partial = {}): 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 & Record = {}, ): AirtrailFlightRaw & Record { 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 = {}): Record { 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 & Record = {}) => 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: '', seatNumber: '12A' }); }); });