mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 13:36:48 +00:00
724c88c201
A mechanical prettier --write over server/src, server/tests, server/scripts and shared/src with the workspaces' .prettierrc; nothing but layout and import order changes, and every file keeps the same set of imports. shared is formatted throughout and its format baseline is empty. The server baseline keeps the 35 files that Prettier would grow past their lint:size limit (they need a split first, which is not a formatting change), and the generated entity files stay as their generator writes them.
555 lines
21 KiB
TypeScript
555 lines
21 KiB
TypeScript
/**
|
|
* 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<void>, 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<void> {
|
|
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<AirtrailFlightRaw> & { 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'],
|
|
);
|
|
});
|
|
});
|