Files
TREK/server/tests/unit/nest/integrations/airtrailWriteGate.test.ts
T
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

246 lines
11 KiB
TypeScript

import type { AppSettingsRepository } from '../../../../src/db/repositories/AppSettings.repository';
import type { ReservationEndpointsRepository } from '../../../../src/db/repositories/ReservationEndpoints.repository';
import type { ReservationsRepository } from '../../../../src/db/repositories/Reservations.repository';
import type { AddonsService } from '../../../../src/nest/addons/addons.service';
import { AirtrailLinkService } from '../../../../src/nest/integrations/airtrail-link.service';
import { AirtrailSyncService } from '../../../../src/nest/integrations/airtrail-sync.service';
import { AirtrailAuthError } from '../../../../src/nest/integrations/airtrail.client';
import type { AirtrailClient } from '../../../../src/nest/integrations/airtrail.client';
import type { AirtrailService } from '../../../../src/nest/integrations/airtrail.service';
import type { RealtimeService } from '../../../../src/nest/realtime/realtime.service';
import type { ReservationsReadService } from '../../../../src/nest/reservations/reservations-read.service';
import type { ReservationsService } from '../../../../src/nest/reservations/reservations.service';
import { describe, it, expect, vi, beforeEach } from 'vitest';
/**
* The #1240 write gate: pushReservationToAirtrail must NOT write to AirTrail unless
* the flight's owner has opted in (airtrail_write_enabled). Collaborators are stubbed
* so the test exercises just the gate + payload wiring.
*
* They used to be module mocks over free functions. The fold made them injected
* services, so the stubs go through the constructor now — which is the point:
* the collaborators are visible in the signature instead of resolved by path.
* Only the mapper stays a module mock, because it stayed free functions.
*/
vi.mock('../../../../src/nest/integrations/airtrail.mapper', () => ({
canonicalHash: vi.fn(() => 'hash'),
mapFlightToReservation: vi.fn(() => ({})),
entityCode: (e: any) => e?.icao || e?.iata || null,
}));
const linkedRow = { id: 5, trip_id: 9, external_id: '42', external_owner_user_id: 7, sync_enabled: 1 };
const getFlight = vi.fn();
const listFlights = vi.fn();
const saveFlight = vi.fn();
const getReservation = vi.fn();
const getReservationWithJoins = vi.fn();
const updateReservation = vi.fn();
const isAirtrailWriteEnabled = vi.fn();
const getAirtrailCredentials = vi.fn();
// The Plan 3h Task 4 conversion's repository-shaped seams, replacing the old
// "route reads by SQL text" db.prepare stub: same fixtures, same test intent
// (the write gate + the #1535 multi-leg guard), asserted against the
// repository METHOD now instead of a literal SQL fragment.
const getValueMock = vi.fn<(key: string) => string | null>();
const countEndpointsMock = vi.fn<(filter: unknown) => number>();
const findAirtrailLinkedMock = vi.fn<(id: number) => typeof linkedRow | undefined>();
const detachSpy = vi.fn<(id: number) => void>();
const setAirtrailSyncStampSpy = vi.fn();
const listSyncCandidatesMock = vi.fn<(ownerId: number) => unknown[]>();
function makeServices(): { link: AirtrailLinkService; sync: AirtrailSyncService } {
const reservationsRepo = {
findAirtrailLinked: (id: number) => Promise.resolve(findAirtrailLinkedMock(id)),
setAirtrailSyncDisabled: (id: number) => {
detachSpy(id);
return Promise.resolve();
},
setAirtrailSyncStamp: (...args: unknown[]) => {
setAirtrailSyncStampSpy(...args);
return Promise.resolve();
},
listAirtrailSyncCandidatesForOwner: (ownerId: number) => Promise.resolve(listSyncCandidatesMock(ownerId)),
} as unknown as ReservationsRepository;
const endpointsRepo = {
count: (filter: unknown) => Promise.resolve(countEndpointsMock(filter)),
} as unknown as ReservationEndpointsRepository;
const appSettings = {
getValue: (key: string) => Promise.resolve(getValueMock(key)),
} as unknown as AppSettingsRepository;
const client = { getFlight, listFlights, saveFlight } as unknown as AirtrailClient;
const airtrail = { isAirtrailWriteEnabled, getAirtrailCredentials } as unknown as AirtrailService;
// The push and the shared link lifecycle live on AirtrailLinkService since the
// core/pull split (which retired airtrail.bridge); the pull delegates to it.
const link = new AirtrailLinkService(
reservationsRepo,
endpointsRepo,
appSettings,
{ broadcast: vi.fn() } as unknown as RealtimeService,
{ isAddonEnabled: vi.fn(() => true) } as unknown as AddonsService,
{ getReservationWithJoins } as unknown as ReservationsReadService,
client,
airtrail,
);
const sync = new AirtrailSyncService(
reservationsRepo,
link,
{ getReservation, update: updateReservation } as unknown as ReservationsService,
client,
airtrail,
);
return { link, sync };
}
let svc: { link: AirtrailLinkService; sync: AirtrailSyncService };
beforeEach(() => {
vi.clearAllMocks();
// Global sync setting, the linked reservation row and the endpoint count the
// multi-leg guard checks (#1535) — two = plain from/to.
getValueMock.mockReturnValue('true');
countEndpointsMock.mockReturnValue(2);
findAirtrailLinkedMock.mockReturnValue({ ...linkedRow });
listSyncCandidatesMock.mockReturnValue([]);
svc = makeServices();
getAirtrailCredentials.mockReturnValue({ baseUrl: 'https://at.example', apiKey: 'k', allowInsecureTls: false });
// GET returns AirTrail-owned detail TREK doesn't model — must survive the writeback.
getFlight.mockResolvedValue({
id: 42,
from: { iata: 'JFK' },
to: { iata: 'LHR' },
seats: [],
departureTerminal: '7',
});
saveFlight.mockResolvedValue({ id: 42 });
getReservationWithJoins.mockReturnValue({
external_id: '42',
reservation_time: '2021-09-01T19:00',
reservation_end_time: '2021-09-02T08:00',
notes: 'note',
metadata: JSON.stringify({}),
endpoints: [
{ role: 'from', code: 'JFK' },
{ role: 'to', code: 'LHR' },
],
});
});
describe('pushReservationToAirtrail write gate (#1240)', () => {
it('does nothing — and does not detach — when the owner has not opted in', async () => {
isAirtrailWriteEnabled.mockReturnValue(false);
await svc.link.pushReservationToAirtrail(5, 9);
expect(getFlight).not.toHaveBeenCalled();
expect(saveFlight).not.toHaveBeenCalled();
expect(detachSpy).not.toHaveBeenCalled(); // no detach, no hash write — pure no-op
expect(setAirtrailSyncStampSpy).not.toHaveBeenCalled();
});
it('writes back, preserving AirTrail-owned fields, when the owner has opted in', async () => {
isAirtrailWriteEnabled.mockReturnValue(true);
await svc.link.pushReservationToAirtrail(5, 9);
expect(saveFlight).toHaveBeenCalledTimes(1);
const payload = saveFlight.mock.calls[0][1] as Record<string, unknown>;
expect(payload.departureTerminal).toBe('7'); // spread preserved the unmanaged field
expect(payload.from).toBe('JFK'); // TREK-managed field still applied as a code
});
it('#1535 detaches instead of pushing when the reservation grew extra stops', async () => {
isAirtrailWriteEnabled.mockReturnValue(true);
countEndpointsMock.mockReturnValue(3); // from + stop + to
await svc.link.pushReservationToAirtrail(5, 9);
// Pushing would rewrite the single AirTrail flight to span the whole route.
expect(saveFlight).not.toHaveBeenCalled();
expect(detachSpy).toHaveBeenCalledWith(5);
});
it('#1535 detaches on metadata.legs even when the endpoint count is not available', async () => {
isAirtrailWriteEnabled.mockReturnValue(true);
getReservationWithJoins.mockReturnValue({
external_id: '42',
reservation_time: '2021-09-01T19:00',
metadata: JSON.stringify({
legs: [
{ from: 'BRU', to: 'HEL' },
{ from: 'HEL', to: 'JFK' },
],
}),
endpoints: [],
});
await svc.link.pushReservationToAirtrail(5, 9);
expect(saveFlight).not.toHaveBeenCalled();
expect(detachSpy).toHaveBeenCalledWith(5);
});
it('detaches when the owner key stopped working, rather than retrying forever', async () => {
isAirtrailWriteEnabled.mockReturnValue(true);
getFlight.mockRejectedValue(new AirtrailAuthError('invalid key'));
await svc.link.pushReservationToAirtrail(5, 9);
expect(saveFlight).not.toHaveBeenCalled();
expect(detachSpy).toHaveBeenCalledWith(5);
});
it('detaches when the flight is gone from AirTrail — the same as a remote delete', async () => {
isAirtrailWriteEnabled.mockReturnValue(true);
getFlight.mockResolvedValue(null);
await svc.link.pushReservationToAirtrail(5, 9);
expect(saveFlight).not.toHaveBeenCalled();
expect(detachSpy).toHaveBeenCalledWith(5);
});
});
describe('inbound sync multi-leg guard (#1535)', () => {
function withLinkedRow(endpointCount: number) {
getValueMock.mockReturnValue('true');
countEndpointsMock.mockReturnValue(endpointCount);
listSyncCandidatesMock.mockReturnValue([{ id: 5, trip_id: 9, external_id: '42', external_hash: 'stale' }]);
svc = makeServices();
}
it('detaches instead of flattening when a linked reservation grew extra stops locally', async () => {
// A remote change is pending (stored hash differs from canonicalHash's
// 'hash'), but the local reservation has become multi-leg — applying the
// single-flight shape would flatten the layover chain.
withLinkedRow(3);
listFlights.mockResolvedValue([{ id: 42 }]);
getReservation.mockReturnValue({ id: 5, metadata: JSON.stringify({}) });
const { changed } = await svc.sync.runAirtrailSyncForUser(7);
expect(updateReservation).not.toHaveBeenCalled();
expect(detachSpy).toHaveBeenCalledWith(5);
expect(changed).toBe(1);
});
it('still applies a remote change to a plain single-leg reservation', async () => {
withLinkedRow(2);
listFlights.mockResolvedValue([{ id: 42 }]);
getReservation.mockReturnValue({ id: 5, metadata: JSON.stringify({}) });
await svc.sync.runAirtrailSyncForUser(7);
expect(updateReservation).toHaveBeenCalledTimes(1);
expect(detachSpy).not.toHaveBeenCalled();
});
it('detaches a flight that vanished from AirTrail, keeping the TREK row', async () => {
withLinkedRow(2);
listFlights.mockResolvedValue([]); // the linked id is no longer there
const { changed } = await svc.sync.runAirtrailSyncForUser(7);
expect(updateReservation).not.toHaveBeenCalled();
expect(detachSpy).toHaveBeenCalledWith(5);
expect(changed).toBe(1);
});
it('leaves a disconnected owner alone rather than detaching their rows', async () => {
withLinkedRow(2);
getAirtrailCredentials.mockReturnValue(null);
const { changed } = await svc.sync.runAirtrailSyncForUser(7);
expect(listFlights).not.toHaveBeenCalled();
expect(detachSpy).not.toHaveBeenCalled();
expect(changed).toBe(0);
});
});