Files
TREK/server/tests/unit/services/airtrailWriteGate.test.ts
T
2026-10-06 21:50:59 +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);
});
});