Files
TREK/server/tests/unit/services/airtrailWriteGate.test.ts
T
Maurice 19f999329e Fold the last legacy service into its domain
src/services/ held one thing: airtrail/, 1305 lines of plain functions over the
better-sqlite3 singleton. They are four providers and two pure helper modules
now, and the directory is empty.

Five hidden edges go with them. The db singleton in three files, the raw
broadcast from src/websocket in two, and the reservations, addons and audit
bridges - all module-level imports that the module graph could not see.

The scheduler stops reaching for it at call time. scheduler.ts ran
require('./services/airtrail/airtrailSync') inside the cron tick, so a wiring
mistake surfaced as "AirTrail sync tick failed" every five minutes rather than
at boot. It takes the dependency through SchedulerDeps now, the same structural
contract backups and placePhotos already used.

One edge stays a seam, deliberately. AirtrailSyncService needs
ReservationsService, because the inbound pull applies remote changes through
the real reservation update path - endpoint restamping and all - and
reimplementing that against raw SQL would be worse than the coupling. The
write-back trigger runs the other way, from reservations.controller, and
injecting there would close
ReservationsModule -> AirtrailModule -> ReservationsModule. airtrail.bridge.ts
holds that one function with the reason in its docblock, rather than a
forwardRef hiding it.

The write-gate suite was module mocks over free functions and is stubs through
a constructor now; it gained four cases on the way (auth failure, remote
delete, disconnected owner). The domain ratchet goes from 19/0/10/19 - set when
integrations was two thin controllers - to 69/52/72/71, which is what the
folded code measures. It is a 1:1 move, so it arrives with the coverage its
tests always gave it.
2026-08-10 17:08:54 +02:00

212 lines
9.2 KiB
TypeScript

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,
}));
import { AirtrailSyncService } from '../../../src/nest/integrations/airtrail-sync.service';
import { AirtrailAuthError } from '../../../src/nest/integrations/airtrail.client';
import type { DatabaseService } from '../../../src/nest/database/database.service';
import type { RealtimeService } from '../../../src/nest/realtime/realtime.service';
import type { AddonsService } from '../../../src/nest/addons/addons.service';
import type { ReservationsService } from '../../../src/nest/reservations/reservations.service';
import type { AirtrailClient } from '../../../src/nest/integrations/airtrail.client';
import type { AirtrailService } from '../../../src/nest/integrations/airtrail.service';
const linkedRow = { id: 5, trip_id: 9, external_id: '42', external_owner_user_id: 7, sync_enabled: 1 };
const runSpy = vi.fn();
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();
/** Routes reads by SQL, exactly as the old db.prepare stub did. */
let dbGet: (sql: string) => unknown;
let dbAll: (sql: string) => unknown[];
function makeService(): AirtrailSyncService {
const db = {
get: (sql: string) => dbGet(sql),
all: (sql: string) => dbAll(sql),
run: (sql: string, ...args: unknown[]) => {
runSpy(sql, args);
return {};
},
} as unknown as DatabaseService;
return new AirtrailSyncService(
db,
{ broadcast: vi.fn() } as unknown as RealtimeService,
{ isAddonEnabled: vi.fn(() => true) } as unknown as AddonsService,
{ getReservation, getReservationWithJoins, update: updateReservation } as unknown as ReservationsService,
{ getFlight, listFlights, saveFlight } as unknown as AirtrailClient,
{ isAirtrailWriteEnabled, getAirtrailCredentials } as unknown as AirtrailService,
);
}
let svc: 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.
dbGet = (sql: string) => {
if (sql.includes('app_settings')) return { value: 'true' };
if (sql.includes('FROM reservation_endpoints')) return { n: 2 };
if (sql.includes('FROM reservations')) return { ...linkedRow };
return undefined;
};
dbAll = () => [];
svc = makeService();
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.pushReservationToAirtrail(5, 9);
expect(getFlight).not.toHaveBeenCalled();
expect(saveFlight).not.toHaveBeenCalled();
expect(runSpy).not.toHaveBeenCalled(); // no detach, no hash write — pure no-op
});
it('writes back, preserving AirTrail-owned fields, when the owner has opted in', async () => {
isAirtrailWriteEnabled.mockReturnValue(true);
await svc.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);
dbGet = (sql: string) => {
if (sql.includes('app_settings')) return { value: 'true' };
if (sql.includes('FROM reservation_endpoints')) return { n: 3 }; // from + stop + to
if (sql.includes('FROM reservations')) return { ...linkedRow };
return undefined;
};
await svc.pushReservationToAirtrail(5, 9);
// Pushing would rewrite the single AirTrail flight to span the whole route.
expect(saveFlight).not.toHaveBeenCalled();
expect(runSpy).toHaveBeenCalledWith(expect.stringContaining('sync_enabled = 0'), [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.pushReservationToAirtrail(5, 9);
expect(saveFlight).not.toHaveBeenCalled();
expect(runSpy).toHaveBeenCalledWith(expect.stringContaining('sync_enabled = 0'), [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.pushReservationToAirtrail(5, 9);
expect(saveFlight).not.toHaveBeenCalled();
expect(runSpy).toHaveBeenCalledWith(expect.stringContaining('sync_enabled = 0'), [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.pushReservationToAirtrail(5, 9);
expect(saveFlight).not.toHaveBeenCalled();
expect(runSpy).toHaveBeenCalledWith(expect.stringContaining('sync_enabled = 0'), [5]);
});
});
describe('inbound sync multi-leg guard (#1535)', () => {
function withLinkedRow(endpointCount: number) {
dbGet = (sql: string) => {
if (sql.includes('app_settings')) return { value: 'true' };
if (sql.includes('FROM reservation_endpoints')) return { n: endpointCount };
return undefined;
};
dbAll = (sql: string) =>
sql.includes('sync_enabled = 1') ? [{ id: 5, trip_id: 9, external_id: '42', external_hash: 'stale' }] : [];
svc = makeService();
}
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.runAirtrailSyncForUser(7);
expect(updateReservation).not.toHaveBeenCalled();
expect(runSpy).toHaveBeenCalledWith(expect.stringContaining('sync_enabled = 0'), [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.runAirtrailSyncForUser(7);
expect(updateReservation).toHaveBeenCalledTimes(1);
expect(runSpy).not.toHaveBeenCalledWith(expect.stringContaining('sync_enabled = 0'), expect.anything());
});
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.runAirtrailSyncForUser(7);
expect(updateReservation).not.toHaveBeenCalled();
expect(runSpy).toHaveBeenCalledWith(expect.stringContaining('sync_enabled = 0'), [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.runAirtrailSyncForUser(7);
expect(listFlights).not.toHaveBeenCalled();
expect(runSpy).not.toHaveBeenCalled();
expect(changed).toBe(0);
});
});