/** * Unit tests for the AirTrail import pair: AirtrailMcp.list_airtrail_flights * (mirrors GET /api/integrations/airtrail/flights) and * ReservationImportMcp.import_airtrail_flights (mirrors * POST /api/trips/:tripId/reservations/import/airtrail). * * The AirTrail instance is the only thing stubbed: the credentials are read out * of the real users row, the mapper and the dedupe run for real, and the * reservations land in the test DB. */ import { ADDON_IDS } from '../../../src/addons'; import { db as testDb } from '../../../src/db/database'; import { Reservations } from '../../../src/db/entities/Reservations.entity'; import { Users } from '../../../src/db/entities/Users.entity'; import { AirtrailImportService } from '../../../src/nest/integrations/airtrail-import.service'; import { AirtrailClient, AirtrailRequestError, type AirtrailFlightRaw, } from '../../../src/nest/integrations/airtrail.client'; import { PermissionsService } from '../../../src/nest/permissions/permissions.service'; import { createUser, createTrip, addTripMember } from '../../helpers/factories'; import { findRows, updateRows } from '../../helpers/factories/rows'; import { FakeRealtimeService } from '../../helpers/fake-realtime'; import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness'; import { resetTestDb, setAddonEnabled } from '../../helpers/test-db'; import { createTestOrm, type TestOrm } from '../../helpers/test-orm'; import { createTestUnitOfWork, createTestAppSettingsRepo } from '../../helpers/test-uow'; import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; vi.mock('../../../src/db/database', async () => { const { createSnapshotTestDb, buildDbMock } = await import('../../helpers/db-mock'); return buildDbMock(createSnapshotTestDb()); }); const realtime = new FakeRealtimeService(); const broadcastMock = realtime.broadcastMock; // The permissions cache is module-scoped, so a write through any instance is // what the tool's own checkPermission call reads back. let permissionsService: PermissionsService; let savePermissions: typeof permissionsService.savePermissions; beforeAll(async () => { permissionsService = new PermissionsService( await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb), ); savePermissions = permissionsService.savePermissions.bind(permissionsService); }); // The registry builds its own AirtrailClient per harness, so the stub goes on // the prototype. Everything below listFlights (creds, mapper, dedupe) is real. const listFlightsMock = vi.spyOn(AirtrailClient.prototype, 'listFlights'); beforeEach(async () => { resetTestDb(testDb); broadcastMock.mockClear(); listFlightsMock.mockReset(); listFlightsMock.mockResolvedValue([]); delete process.env.DEMO_MODE; // resetTestDb leaves the addons table alone, and both tools are gated on the // airtrail addon, so every case restates the toggle it needs. setAddonEnabled(testDb, ADDON_IDS.AIRTRAIL, true); await savePermissions({ reservation_edit: 'trip_member' }); }); let orm: TestOrm; beforeAll(async () => { orm = await createTestOrm(testDb); }); afterAll(async () => { await orm.close(); testDb.close(); }); async function withHarness(userId: number, fn: (h: McpHarness) => Promise, scopes: string[] | null = null) { const h = await createMcpHarness({ realtime, userId, withResources: false, scopes }); try { await fn(h); } finally { await h.cleanup(); } } /** The stored connection, plaintext key: decrypt_api_key passes legacy plaintext straight through. */ async function connectAirtrail(userId: number, url = 'https://airtrail.example.com'): Promise { await updateRows(orm, Users, { id: userId }, { airtrail_url: url, airtrail_api_key: 'plain-test-key' }); } const ZRH = { id: 1, icao: 'LSZH', iata: 'ZRH', name: 'Zurich', lat: 47.458, lon: 8.548, tz: 'Europe/Zurich', country: 'CH', }; const FRA = { id: 2, icao: 'EDDF', iata: 'FRA', name: 'Frankfurt', lat: 50.033, lon: 8.571, tz: 'Europe/Berlin', country: 'DE', }; const JFK = { id: 3, icao: 'KJFK', iata: 'JFK', name: 'New York JFK', lat: 40.641, lon: -73.778, tz: 'America/New_York', country: 'US', }; function flight(overrides: Partial & { id: number }): AirtrailFlightRaw { return { from: ZRH, to: FRA, date: '2026-09-10', datePrecision: 'day', departure: '2026-09-10T06:00:00Z', arrival: '2026-09-10T07:10:00Z', departureScheduled: null, arrivalScheduled: null, airline: { icao: 'DLH', iata: 'LH', name: 'Lufthansa' }, flightNumber: 'LH1201', aircraft: { icao: 'A320' }, aircraftReg: 'D-AIZA', note: null, seats: [{ userId: 'u1', guestName: null, seat: null, seatNumber: '14A', seatClass: 'economy' }], ...overrides, }; } function reservationRows(tripId: number) { return findRows(orm, Reservations, { trip: tripId }, { id: 'asc' }); } // --------------------------------------------------------------------------- // list_airtrail_flights // --------------------------------------------------------------------------- describe('Tool: list_airtrail_flights', () => { it('returns the caller flights, normalized and oldest departure first', async () => { const { user } = createUser(testDb); await connectAirtrail(user.id); listFlightsMock.mockResolvedValue([ flight({ id: 22, date: '2026-09-20', departure: '2026-09-20T06:00:00Z', flightNumber: 'LH400', from: FRA, to: JFK, }), flight({ id: 11 }), ]); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'list_airtrail_flights', arguments: {} }); expect(result.isError).toBeFalsy(); const data = parseToolResult(result) as any; expect(data.flights.map((f: any) => f.id)).toEqual(['11', '22']); expect(data.total).toBe(2); expect(data.truncated).toBe(false); expect(data.flights[0]).toMatchObject({ id: '11', fromCode: 'ZRH', toCode: 'FRA', airline: 'Lufthansa', flightNumber: 'LH1201', seatClass: 'economy', }); }); // The stored connection is what reaches the client, so a tool can only ever // read the caller's own AirTrail account. expect(listFlightsMock).toHaveBeenCalledWith({ baseUrl: 'https://airtrail.example.com', apiKey: 'plain-test-key', allowInsecureTls: false, }); }); it('narrows to a date window and keeps undated flights', async () => { const { user } = createUser(testDb); await connectAirtrail(user.id); listFlightsMock.mockResolvedValue([ flight({ id: 1, date: '2026-08-01', departure: '2026-08-01T06:00:00Z' }), flight({ id: 2, date: '2026-09-10', departure: null }), flight({ id: 3, date: '2026-10-01', departure: '2026-10-01T06:00:00Z' }), flight({ id: 4, date: null, departure: null, arrival: null }), flight({ id: 5, date: null, departure: null, arrival: null }), flight({ id: 6 }), flight({ id: 7 }), ]); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'list_airtrail_flights', arguments: { from: '2026-09-01', to: '2026-09-30' }, }); const data = parseToolResult(result) as any; // Dated first in departure order (6 and 7 share an instant and keep their // input order), then the two that carry no date at all. expect(data.flights.map((f: any) => f.id)).toEqual(['2', '6', '7', '4', '5']); expect(data.total).toBe(5); }); }); it('falls back to its own wording when AirTrail fails without a message', async () => { const { user } = createUser(testDb); await connectAirtrail(user.id); listFlightsMock.mockRejectedValue(new Error('')); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'list_airtrail_flights', arguments: {} }); expect(result.isError).toBe(true); expect((result.content as any)[0].text).toBe('Could not load AirTrail flights'); }); }); it('caps the result and says it was truncated', async () => { const { user } = createUser(testDb); await connectAirtrail(user.id); listFlightsMock.mockResolvedValue( Array.from({ length: 5 }, (_, i) => flight({ id: i + 1, departure: `2026-09-1${i}T06:00:00Z` })), ); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'list_airtrail_flights', arguments: { limit: 2 } }); const data = parseToolResult(result) as any; expect(data.flights.map((f: any) => f.id)).toEqual(['1', '2']); expect(data.total).toBe(5); expect(data.truncated).toBe(true); }); }); it('reports an unconnected account instead of an empty list', async () => { const { user } = createUser(testDb); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'list_airtrail_flights', arguments: {} }); expect(result.isError).toBe(true); expect((result.content as any)[0].text).toContain('AirTrail is not connected'); }); expect(listFlightsMock).not.toHaveBeenCalled(); }); it('passes an upstream failure through as the tool error', async () => { const { user } = createUser(testDb); await connectAirtrail(user.id); listFlightsMock.mockRejectedValue(new AirtrailRequestError('AirTrail list failed (HTTP 500)', 500)); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'list_airtrail_flights', arguments: {} }); expect(result.isError).toBe(true); expect((result.content as any)[0].text).toContain('AirTrail list failed (HTTP 500)'); }); }); it('is hidden while the airtrail addon is off', async () => { const { user } = createUser(testDb); await connectAirtrail(user.id); setAddonEnabled(testDb, ADDON_IDS.AIRTRAIL, false); await withHarness(user.id, async (h) => { const names = (await h.client.listTools()).tools.map((t) => t.name); expect(names).not.toContain('list_airtrail_flights'); const result = await h.client.callTool({ name: 'list_airtrail_flights', arguments: {} }); expect(result.isError).toBe(true); expect((result.content as any)[0].text).toContain('not found'); }); }); it('rides reservations:read and is hidden from a token without it', async () => { const { user } = createUser(testDb); await connectAirtrail(user.id); await withHarness( user.id, async (h) => { expect((await h.client.listTools()).tools.map((t) => t.name)).toContain('list_airtrail_flights'); }, ['reservations:read'], ); await withHarness( user.id, async (h) => { expect((await h.client.listTools()).tools.map((t) => t.name)).not.toContain('list_airtrail_flights'); }, ['places:read'], ); }); }); // --------------------------------------------------------------------------- // import_airtrail_flights // --------------------------------------------------------------------------- describe('Tool: import_airtrail_flights', () => { it('imports the listed flights as linked flight bookings', async () => { const { user } = createUser(testDb); await connectAirtrail(user.id); const trip = createTrip(testDb, user.id, { start_date: '2026-09-10', end_date: '2026-09-12' }); listFlightsMock.mockResolvedValue([flight({ id: 11 }), flight({ id: 12, flightNumber: 'LH1202' })]); await withHarness(user.id, async (h) => { // The ids the list tool hands back are exactly what the import tool takes. const listed = parseToolResult(await h.client.callTool({ name: 'list_airtrail_flights', arguments: {} })) as any; const result = await h.client.callTool({ name: 'import_airtrail_flights', arguments: { tripId: trip.id, flightIds: listed.flights.map((f: any) => f.id) }, }); expect(result.isError).toBeFalsy(); const data = parseToolResult(result) as any; expect([...data.imported].sort()).toEqual(['11', '12']); expect(data.skipped).toEqual([]); }); const rows = await reservationRows(trip.id); expect(rows).toHaveLength(2); expect(rows[0]).toMatchObject({ title: 'LH1201', type: 'flight', external_source: 'airtrail', external_id: '11', external_owner_user_id: user.id, sync_enabled: 1, }); expect(broadcastMock).toHaveBeenCalledTimes(2); expect(broadcastMock.mock.calls[0][1]).toBe('reservation:created'); }); it('skips a flight that is already linked to the trip', async () => { const { user } = createUser(testDb); await connectAirtrail(user.id); const trip = createTrip(testDb, user.id, { start_date: '2026-09-10', end_date: '2026-09-12' }); listFlightsMock.mockResolvedValue([flight({ id: 11 })]); await withHarness(user.id, async (h) => { await h.client.callTool({ name: 'import_airtrail_flights', arguments: { tripId: trip.id, flightIds: ['11'] } }); const again = await h.client.callTool({ name: 'import_airtrail_flights', arguments: { tripId: trip.id, flightIds: ['11'] }, }); const data = parseToolResult(again) as any; expect(data.imported).toEqual([]); expect(data.skipped).toEqual([{ flightId: '11', reason: 'already-imported' }]); }); expect(await reservationRows(trip.id)).toHaveLength(1); }); it('joins a connection into one multi-leg booking detached from sync', async () => { const { user } = createUser(testDb); await connectAirtrail(user.id); const trip = createTrip(testDb, user.id, { start_date: '2026-09-10', end_date: '2026-09-12' }); listFlightsMock.mockResolvedValue([ flight({ id: 11 }), flight({ id: 12, from: FRA, to: JFK, flightNumber: 'LH400', departure: '2026-09-10T09:00:00Z', arrival: '2026-09-10T18:00:00Z', }), ]); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'import_airtrail_flights', arguments: { tripId: trip.id, flightIds: ['11', '12'], connections: [['11', '12']] }, }); const data = parseToolResult(result) as any; expect(data.imported).toEqual(['11', '12']); }); const rows = await reservationRows(trip.id); expect(rows).toHaveLength(1); expect(rows[0].sync_enabled).toBe(0); expect(JSON.parse(rows[0].metadata ?? '{}').airtrail_ids).toEqual(['11', '12']); }); it('refuses a connection naming a flight that is not being imported', async () => { const { user } = createUser(testDb); await connectAirtrail(user.id); const trip = createTrip(testDb, user.id, { start_date: '2026-09-10', end_date: '2026-09-12' }); listFlightsMock.mockResolvedValue([flight({ id: 11 })]); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'import_airtrail_flights', arguments: { tripId: trip.id, flightIds: ['11'], connections: [['11', '99']] }, }); expect(result.isError).toBe(true); expect((result.content as any)[0].text).toContain('flight 99'); }); expect(await reservationRows(trip.id)).toHaveLength(0); expect(listFlightsMock).not.toHaveBeenCalled(); }); it('refuses more than fifty flights in one call', async () => { const { user } = createUser(testDb); await connectAirtrail(user.id); const trip = createTrip(testDb, user.id, { start_date: '2026-09-10', end_date: '2026-09-12' }); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'import_airtrail_flights', arguments: { tripId: trip.id, flightIds: Array.from({ length: 51 }, (_, i) => String(i + 1)) }, }); expect(result.isError).toBe(true); expect((result.content as any)[0].text).toContain('at most 50'); }); expect(await reservationRows(trip.id)).toHaveLength(0); expect(listFlightsMock).not.toHaveBeenCalled(); }); it('reports an unconnected account', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { start_date: '2026-09-10', end_date: '2026-09-12' }); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'import_airtrail_flights', arguments: { tripId: trip.id, flightIds: ['11'] }, }); expect(result.isError).toBe(true); expect((result.content as any)[0].text).toContain('AirTrail is not connected'); }); expect(await reservationRows(trip.id)).toHaveLength(0); }); it('falls back to its own wording when the import fails without a message', async () => { const { user } = createUser(testDb); await connectAirtrail(user.id); const trip = createTrip(testDb, user.id, { start_date: '2026-09-10', end_date: '2026-09-12' }); const importMock = vi .spyOn(AirtrailImportService.prototype, 'importAirtrailFlights') .mockRejectedValue(new Error('')); try { await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'import_airtrail_flights', arguments: { tripId: trip.id, flightIds: ['11'] }, }); expect(result.isError).toBe(true); expect((result.content as any)[0].text).toBe('AirTrail import failed'); }); } finally { // Nothing auto-restores spies in this config, so an escaped stub would // silently answer for every later case in the file. importMock.mockRestore(); } }); it('enforces demo, trip access and the reservation permission', async () => { const { user: owner } = createUser(testDb); const { user: demo } = createUser(testDb, { email: 'demo@trek.app' }); const { user: stranger } = createUser(testDb); const { user: member } = createUser(testDb); const trip = createTrip(testDb, owner.id, { start_date: '2026-09-10', end_date: '2026-09-12' }); addTripMember(testDb, trip.id, demo.id); addTripMember(testDb, trip.id, member.id); for (const u of [owner, demo, stranger, member]) await connectAirtrail(u.id); listFlightsMock.mockResolvedValue([flight({ id: 11 })]); process.env.DEMO_MODE = 'true'; await withHarness(demo.id, async (h) => { const result = await h.client.callTool({ name: 'import_airtrail_flights', arguments: { tripId: trip.id, flightIds: ['11'] }, }); expect(result.isError).toBe(true); expect((result.content as any)[0].text).toContain('demo mode'); }); delete process.env.DEMO_MODE; await withHarness(stranger.id, async (h) => { const result = await h.client.callTool({ name: 'import_airtrail_flights', arguments: { tripId: trip.id, flightIds: ['11'] }, }); expect(result.isError).toBe(true); expect((result.content as any)[0].text).toContain('access denied'); }); await savePermissions({ reservation_edit: 'trip_owner' }); await withHarness(member.id, async (h) => { const result = await h.client.callTool({ name: 'import_airtrail_flights', arguments: { tripId: trip.id, flightIds: ['11'] }, }); expect(result.isError).toBe(true); expect((result.content as any)[0].text).toContain('permission'); }); expect(await reservationRows(trip.id)).toHaveLength(0); expect(broadcastMock).not.toHaveBeenCalled(); expect(listFlightsMock).not.toHaveBeenCalled(); }); it('is hidden while the airtrail addon is off', async () => { const { user } = createUser(testDb); await connectAirtrail(user.id); const trip = createTrip(testDb, user.id, { start_date: '2026-09-10', end_date: '2026-09-12' }); setAddonEnabled(testDb, ADDON_IDS.AIRTRAIL, false); await withHarness(user.id, async (h) => { const names = (await h.client.listTools()).tools.map((t) => t.name); expect(names).not.toContain('import_airtrail_flights'); const result = await h.client.callTool({ name: 'import_airtrail_flights', arguments: { tripId: trip.id, flightIds: ['11'] }, }); expect(result.isError).toBe(true); expect((result.content as any)[0].text).toContain('not found'); }); expect(await reservationRows(trip.id)).toHaveLength(0); }); it('rides reservations:write and is hidden from a read-only token', async () => { const { user } = createUser(testDb); await connectAirtrail(user.id); await withHarness( user.id, async (h) => { expect((await h.client.listTools()).tools.map((t) => t.name)).toContain('import_airtrail_flights'); }, ['reservations:write'], ); await withHarness( user.id, async (h) => { expect((await h.client.listTools()).tools.map((t) => t.name)).not.toContain('import_airtrail_flights'); }, ['reservations:read'], ); }); });