Files
TREK/server/tests/unit/services/airtrailImport.test.ts
T
jubnl 3e37474a2c test(server): plan 4 — constructor ripple for the rosterUserIds/getPlaceWithTags inline
Hand-built BudgetService/BudgetMcp/TodoService call sites drop the removed
DatabaseService constructor arg (budgetRepoArgs() now appends a
TripMembersRepository so most spread call sites need no other change);
budget.service.test.ts's rosterHas() helper and svc() factory move from a
spied DatabaseService to a stubbed TripMembersRepository. The
AccommodationsService helper (tests/helpers/accommodations-service.ts) and
its two direct callers drop the same arg. PackingService call sites are
untouched (its new param is optional, so every existing hand-built instance
still resolves rosterUserIds through the DatabaseService fallback).
2026-09-24 06:18:32 +02:00

267 lines
13 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 { ReservationsReadService } from '../../../src/nest/reservations/reservations-read.service';
import type { AirtrailClient } from '../../../src/nest/integrations/airtrail.client';
import type { AirtrailService } from '../../../src/nest/integrations/airtrail.service';
import { notificationsStub } from '../../helpers/notifications';
import { accommodationsOver } from '../../helpers/accommodations-service';
import { createTestUnitOfWork, createTestAppSettingsRepo, createTestReservationsRepo, createTestReservationEndpointsRepo, createTestReservationTravelersRepo, createTestReservationDayPositionsRepo, createTestDayAccommodationsRepo, createTestDaysRepo, createTestPlacesRepo, createTestDayAssignmentsRepo, createTestTripMembersRepo, createTestUsersRepo, createTestTripsRepo } from '../../helpers/test-uow';
import { createTestBudgetItemsRepo } from '../../helpers/files-repos';
import { budgetRepoArgs } from '../../helpers/budget-repos';
// 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();
async function makeImportService(): Promise<AirtrailImportService> {
const dbs = () => new DatabaseService(db);
const permissions = new PermissionsService(await createTestAppSettingsRepo(dbs().connection), await createTestUnitOfWork(dbs().connection));
const realtime = { broadcast } as unknown as RealtimeService;
return new AirtrailImportService(
await createTestReservationsRepo(dbs().connection),
await createTestReservationEndpointsRepo(dbs().connection),
await createTestDaysRepo(dbs().connection),
realtime,
new ReservationsService(
permissions,
new BudgetService(permissions, new ExchangeRatesService(), realtime, await createTestUnitOfWork(dbs().connection), ...(await budgetRepoArgs(dbs().connection))),
realtime,
notificationsStub(),
new ReservationsReadService(await createTestReservationsRepo(dbs().connection), await createTestReservationEndpointsRepo(dbs().connection), await createTestReservationTravelersRepo(dbs().connection)),
await accommodationsOver(dbs()), await createTestUnitOfWork(dbs().connection),
await createTestReservationsRepo(dbs().connection), await createTestReservationEndpointsRepo(dbs().connection), await createTestReservationTravelersRepo(dbs().connection),
await createTestReservationDayPositionsRepo(dbs().connection), await createTestDayAccommodationsRepo(dbs().connection),
await createTestDaysRepo(dbs().connection), await createTestPlacesRepo(dbs().connection), await createTestDayAssignmentsRepo(dbs().connection),
await createTestTripMembersRepo(dbs().connection), await createTestUsersRepo(dbs().connection), await createTestTripsRepo(dbs().connection),
await createTestBudgetItemsRepo(dbs().connection),
),
{ listFlights } as unknown as AirtrailClient,
{
getAirtrailCredentials: () => ({ baseUrl: 'https://at.example', apiKey: 'k', allowInsecureTls: false }),
} as unknown as AirtrailService,
);
}
const importAirtrailFlights = async (
...args: Parameters<AirtrailImportService['importAirtrailFlights']>
) => (await 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();
});
});