mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 13:36:48 +00:00
19f999329e
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.
252 lines
11 KiB
TypeScript
252 lines
11 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
|
|
/**
|
|
* Import pipeline against the real test DB — only the AirTrail client and the
|
|
* per-user credentials are mocked. Covers the joined multi-leg import (#1535):
|
|
* one reservation per connection chain, detached from live sync, with every
|
|
* member id recorded for dedupe; plus the fallbacks when a requested join
|
|
* doesn't actually chain.
|
|
*/
|
|
|
|
import { db } from '../../../src/db/database';
|
|
import { createUser, createTrip } from '../../helpers/factories';
|
|
import type { AirtrailAirport, AirtrailFlightRaw } from '../../../src/nest/integrations/airtrail.client';
|
|
import { AirtrailImportService } from '../../../src/nest/integrations/airtrail-import.service';
|
|
import { DatabaseService } from '../../../src/nest/database/database.service';
|
|
import { PermissionsService } from '../../../src/nest/permissions/permissions.service';
|
|
import { RealtimeService } from '../../../src/nest/realtime/realtime.service';
|
|
import { BudgetService } from '../../../src/nest/budget/budget.service';
|
|
import { ExchangeRatesService } from '../../../src/nest/budget/exchange-rates.service';
|
|
import { 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';
|
|
|
|
// The client and the per-user credentials are the only stubs; the reservation
|
|
// writes go through the real service against the real test DB, as before. They
|
|
// were module mocks until the fold made them injected collaborators.
|
|
const listFlights = vi.fn();
|
|
const broadcast = vi.fn();
|
|
|
|
function makeImportService(): AirtrailImportService {
|
|
const dbs = () => new DatabaseService(db);
|
|
const permissions = new PermissionsService(dbs());
|
|
const realtime = { broadcast } as unknown as RealtimeService;
|
|
return new AirtrailImportService(
|
|
dbs(),
|
|
realtime,
|
|
new ReservationsService(
|
|
dbs(),
|
|
permissions,
|
|
new BudgetService(dbs(), permissions, new ExchangeRatesService(), realtime),
|
|
realtime,
|
|
),
|
|
{ listFlights } as unknown as AirtrailClient,
|
|
{
|
|
getAirtrailCredentials: () => ({ baseUrl: 'https://at.example', apiKey: 'k', allowInsecureTls: false }),
|
|
} as unknown as AirtrailService,
|
|
);
|
|
}
|
|
|
|
const importAirtrailFlights = (
|
|
...args: Parameters<AirtrailImportService['importAirtrailFlights']>
|
|
) => makeImportService().importAirtrailFlights(...args);
|
|
|
|
const BRU: AirtrailAirport = { id: 1, icao: 'EBBR', iata: 'BRU', name: 'Brussels', lat: 50.9014, lon: 4.4844, tz: 'Europe/Brussels', country: 'BE' };
|
|
const HEL: AirtrailAirport = { id: 2, icao: 'EFHK', iata: 'HEL', name: 'Helsinki-Vantaa', lat: 60.3172, lon: 24.9633, tz: 'Europe/Helsinki', country: 'FI' };
|
|
const JFK: AirtrailAirport = { id: 3, icao: 'KJFK', iata: 'JFK', name: 'John F. Kennedy Intl.', lat: 40.6413, lon: -73.7781, tz: 'America/New_York', country: 'US' };
|
|
const LHR: AirtrailAirport = { id: 4, icao: 'EGLL', iata: 'LHR', name: 'London Heathrow', lat: 51.4706, lon: -0.4619, tz: 'Europe/London', country: 'GB' };
|
|
|
|
function rawFlight(over: Partial<AirtrailFlightRaw> = {}): AirtrailFlightRaw {
|
|
return {
|
|
id: 101,
|
|
from: BRU,
|
|
to: HEL,
|
|
date: '2026-08-01',
|
|
datePrecision: 'day',
|
|
departure: '2026-08-01T06:00:00.000+00:00',
|
|
arrival: '2026-08-01T09:30:00.000+00:00',
|
|
departureScheduled: null,
|
|
arrivalScheduled: null,
|
|
airline: { id: 1, icao: 'FIN', iata: 'AY', name: 'Finnair' },
|
|
flightNumber: 'AY1502',
|
|
aircraft: null,
|
|
aircraftReg: null,
|
|
flightReason: 'leisure',
|
|
note: null,
|
|
seats: [{ userId: 'u1', guestName: null, seat: 'window', seatNumber: '12A', seatClass: 'economy' }],
|
|
...over,
|
|
};
|
|
}
|
|
|
|
const legBruHel = () => rawFlight();
|
|
const legHelJfk = () =>
|
|
rawFlight({
|
|
id: 102,
|
|
from: HEL,
|
|
to: JFK,
|
|
departure: '2026-08-01T11:00:00.000+00:00',
|
|
arrival: '2026-08-01T19:00:00.000+00:00',
|
|
flightNumber: 'AY15',
|
|
});
|
|
/** No connection to the BRU→HEL leg — departs LHR. */
|
|
const legLhrJfk = () =>
|
|
rawFlight({
|
|
id: 103,
|
|
from: LHR,
|
|
to: JFK,
|
|
departure: '2026-08-02T10:00:00.000+00:00',
|
|
arrival: '2026-08-02T18:00:00.000+00:00',
|
|
flightNumber: 'BA117',
|
|
});
|
|
|
|
function tripReservations(tripId: number) {
|
|
return db.prepare('SELECT * FROM reservations WHERE trip_id = ? ORDER BY id').all(tripId) as any[];
|
|
}
|
|
|
|
function endpointsOf(reservationId: number) {
|
|
return db
|
|
.prepare('SELECT role, code, sequence FROM reservation_endpoints WHERE reservation_id = ? ORDER BY sequence')
|
|
.all(reservationId) as { role: string; code: string | null; sequence: number }[];
|
|
}
|
|
|
|
let tripId: number;
|
|
let userId: number;
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
const { user } = createUser(db);
|
|
userId = user.id;
|
|
tripId = createTrip(db, userId, { start_date: '2026-08-01', end_date: '2026-08-05' }).id;
|
|
});
|
|
|
|
describe('importAirtrailFlights connection joining (#1535)', () => {
|
|
it('imports a connection chain as ONE multi-leg reservation, detached from live sync', async () => {
|
|
listFlights.mockResolvedValue([legBruHel(), legHelJfk()]);
|
|
|
|
// Deliberately unordered — the server orders the chain by departure itself.
|
|
const result = await importAirtrailFlights(tripId, userId, ['101', '102'], undefined, [['102', '101']]);
|
|
expect([...result.imported].sort()).toEqual(['101', '102']);
|
|
expect(result.skipped).toEqual([]);
|
|
|
|
const rows = tripReservations(tripId);
|
|
expect(rows).toHaveLength(1);
|
|
const r = rows[0];
|
|
expect(r.type).toBe('flight');
|
|
expect(r.external_source).toBe('airtrail');
|
|
expect(r.external_id).toBe('101');
|
|
expect(r.sync_enabled).toBe(0); // AirTrail has no multi-leg entity to round-trip to
|
|
|
|
const meta = JSON.parse(r.metadata);
|
|
expect(meta.airtrail_ids).toEqual(['101', '102']);
|
|
expect(meta.legs).toHaveLength(2);
|
|
expect(endpointsOf(r.id).map(e => [e.role, e.code])).toEqual([
|
|
['from', 'BRU'],
|
|
['stop', 'HEL'],
|
|
['to', 'JFK'],
|
|
]);
|
|
|
|
// Each leg is filed on its own trip day so the day planner renders the
|
|
// legs where they belong (both flights are on Aug 1 here).
|
|
const day1 = db.prepare("SELECT id FROM days WHERE trip_id = ? AND date = '2026-08-01'").get(tripId) as { id: number };
|
|
expect(meta.legs[0]).toMatchObject({ dep_day_id: day1.id, arr_day_id: day1.id });
|
|
expect(meta.legs[1]).toMatchObject({ dep_day_id: day1.id, arr_day_id: day1.id });
|
|
});
|
|
|
|
it('resolves overnight-connection legs to their own days', async () => {
|
|
const overnightLeg2 = {
|
|
...legHelJfk(),
|
|
date: '2026-08-02',
|
|
departure: '2026-08-02T07:00:00.000+00:00',
|
|
arrival: '2026-08-02T15:00:00.000+00:00',
|
|
};
|
|
listFlights.mockResolvedValue([legBruHel(), overnightLeg2]);
|
|
|
|
await importAirtrailFlights(tripId, userId, ['101', '102'], undefined, [['101', '102']]);
|
|
const [r] = tripReservations(tripId);
|
|
const dayId = (d: string) => (db.prepare('SELECT id FROM days WHERE trip_id = ? AND date = ?').get(tripId, d) as { id: number }).id;
|
|
const legs = JSON.parse(r.metadata).legs;
|
|
expect(legs[0].dep_day_id).toBe(dayId('2026-08-01'));
|
|
expect(legs[1].dep_day_id).toBe(dayId('2026-08-02'));
|
|
expect(legs[1].arr_day_id).toBe(dayId('2026-08-02'));
|
|
});
|
|
|
|
it('refuses to join an out-and-back return as a connection', async () => {
|
|
const returnFlight = {
|
|
...legBruHel(),
|
|
id: 104,
|
|
from: HEL,
|
|
to: BRU,
|
|
departure: '2026-08-01T18:00:00.000+00:00',
|
|
arrival: '2026-08-01T21:30:00.000+00:00',
|
|
flightNumber: 'AY1503',
|
|
};
|
|
listFlights.mockResolvedValue([legBruHel(), returnFlight]);
|
|
|
|
const result = await importAirtrailFlights(tripId, userId, ['101', '104'], undefined, [['101', '104']]);
|
|
expect([...result.imported].sort()).toEqual(['101', '104']);
|
|
expect(tripReservations(tripId)).toHaveLength(2); // two singles, no bogus BRU→HEL→BRU booking
|
|
});
|
|
|
|
it('skips every member of a joined booking on a later import attempt', async () => {
|
|
listFlights.mockResolvedValue([legBruHel(), legHelJfk()]);
|
|
await importAirtrailFlights(tripId, userId, ['101', '102'], undefined, [['101', '102']]);
|
|
|
|
// Only leg 2 carries no external_id of its own — it must still be recognized
|
|
// via metadata.airtrail_ids.
|
|
const again = await importAirtrailFlights(tripId, userId, ['102'], undefined);
|
|
expect(again.imported).toEqual([]);
|
|
expect(again.skipped).toEqual([{ flightId: '102', reason: 'already-imported' }]);
|
|
expect(tripReservations(tripId)).toHaveLength(1);
|
|
});
|
|
|
|
it('recognizes a joined leg imported by another member via its per-leg signature', async () => {
|
|
listFlights.mockResolvedValue([legBruHel(), legHelJfk()]);
|
|
await importAirtrailFlights(tripId, userId, ['101', '102'], undefined, [['101', '102']]);
|
|
|
|
// The same physical HEL→JFK flight from another member's AirTrail carries a
|
|
// different id there — the flight-number@date signature must catch it.
|
|
const { user: other } = createUser(db);
|
|
listFlights.mockResolvedValue([{ ...legHelJfk(), id: 999 }]);
|
|
const result = await importAirtrailFlights(tripId, other.id, ['999'], undefined);
|
|
expect(result.imported).toEqual([]);
|
|
expect(result.skipped).toEqual([{ flightId: '999', reason: 'already-in-trip', detail: expect.any(String) }]);
|
|
expect(tripReservations(tripId)).toHaveLength(1);
|
|
});
|
|
|
|
it('falls back to individual imports when the requested join does not chain', async () => {
|
|
listFlights.mockResolvedValue([legBruHel(), legLhrJfk()]);
|
|
|
|
const result = await importAirtrailFlights(tripId, userId, ['101', '103'], undefined, [['101', '103']]);
|
|
expect([...result.imported].sort()).toEqual(['101', '103']);
|
|
|
|
const rows = tripReservations(tripId);
|
|
expect(rows).toHaveLength(2);
|
|
for (const r of rows) {
|
|
expect(r.sync_enabled).toBe(1); // plain imports keep live sync
|
|
expect(endpointsOf(r.id)).toHaveLength(2);
|
|
}
|
|
});
|
|
|
|
it('falls back to individual imports when the layover exceeds 24 h', async () => {
|
|
const lateLeg2 = { ...legHelJfk(), departure: '2026-08-03T11:00:00.000+00:00', arrival: '2026-08-03T19:00:00.000+00:00', date: '2026-08-03' };
|
|
listFlights.mockResolvedValue([legBruHel(), lateLeg2]);
|
|
|
|
const result = await importAirtrailFlights(tripId, userId, ['101', '102'], undefined, [['101', '102']]);
|
|
expect([...result.imported].sort()).toEqual(['101', '102']);
|
|
expect(tripReservations(tripId)).toHaveLength(2);
|
|
});
|
|
|
|
it('imports singles exactly as before when no join is requested', async () => {
|
|
listFlights.mockResolvedValue([legBruHel()]);
|
|
|
|
const result = await importAirtrailFlights(tripId, userId, ['101'], undefined);
|
|
expect(result.imported).toEqual(['101']);
|
|
|
|
const [r] = tripReservations(tripId);
|
|
expect(r.external_id).toBe('101');
|
|
expect(r.sync_enabled).toBe(1);
|
|
expect(r.external_hash).toBeTruthy();
|
|
expect(JSON.parse(r.metadata).airtrail_ids).toBeUndefined();
|
|
});
|
|
});
|