Files
Maurice 724c88c201 style: format server and shared with prettier
A mechanical prettier --write over server/src, server/tests, server/scripts
and shared/src with the workspaces' .prettierrc; nothing but layout and
import order changes, and every file keeps the same set of imports. shared
is formatted throughout and its format baseline is empty. The server
baseline keeps the 35 files that Prettier would grow past their lint:size
limit (they need a split first, which is not a formatting change), and the
generated entity files stay as their generator writes them.
2026-10-10 21:23:37 +02:00

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' });
});
});