mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
* feat(aviation): add SearchGoogleFlights and SearchGoogleDates RPCs
Port Google Flights internal API from fli Python library as two new
aviation service RPCs routed through the Railway relay.
- Proto: SearchGoogleFlights and SearchGoogleDates messages and RPCs
- Relay: handleGoogleFlightsSearch and handleGoogleFlightsDates handlers
with JSONP parsing, 61-day chunking for date ranges, cabin/stops/sort mappers
- Server handlers: forward params to relay google-flights endpoints
- gateway.ts: no-store for flights, medium cache for dates
* feat(mcp): expose search_flights and search_flight_prices_by_date tools
* test(mcp): update tool count to 24 after adding search_flights and search_flight_prices_by_date
* fix(aviation): address PR review issues in Google Flights RPCs
P1: airline filtering — use gfParseAirlines() in relay (handles comma-joined
string from codegen) and parseStringArray() in server handlers
P1: partial chunk failure now sets degraded: true instead of silently
returning incomplete data as success; relay includes partial: true flag
P2: round-trip date search validates trip_duration > 0 before proceeding;
returns 400 when is_round_trip=true and duration is absent/zero
P2: relay mappers accept user-friendly aliases ('0'/'1' for max_stops,
'price'/'departure' for sort_by) alongside symbolic enum values;
MCP tool docs updated to match
* fix(aviation): use cachedFetchJson in searchGoogleDates for stampede protection
Medium cache tier (10 min) requires Redis-level coalescing to prevent
concurrent requests from all hitting the relay before cache warms.
Cache key includes all request params (sorted airlines for stable keys).
* fix(aviation): always use getAll() for airlines in relay; add multi-airline tests
The OR short-circuit (get() || getAll()) meant get() returned the first
airline value (truthy), so getAll() never ran and only one airline was
forwarded to Google. Fix: unconditionally use getAll().
Tests cover: multi-airline repeated params, single airline, empty array,
comma-joined string from codegen, partial degraded flag propagation.
212 lines
6.4 KiB
TypeScript
212 lines
6.4 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import { describe, it, beforeEach, afterEach } from 'node:test';
|
|
|
|
const originalFetch = globalThis.fetch;
|
|
|
|
process.env.WS_RELAY_URL = 'http://relay.test';
|
|
process.env.RELAY_SHARED_SECRET = 'test-secret';
|
|
|
|
const { searchGoogleFlights } = await import('../server/worldmonitor/aviation/v1/search-google-flights.ts');
|
|
const { searchGoogleDates } = await import('../server/worldmonitor/aviation/v1/search-google-dates.ts');
|
|
|
|
type MockFn = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
|
|
|
function mockFetch(fn: MockFn) {
|
|
globalThis.fetch = fn as typeof globalThis.fetch;
|
|
}
|
|
|
|
function urlOf(input: RequestInfo | URL): string {
|
|
if (typeof input === 'string') return input;
|
|
if (input instanceof URL) return input.href;
|
|
return (input as Request).url;
|
|
}
|
|
|
|
const mockCtx = { request: new Request('http://localhost'), pathParams: {}, headers: {} } as never;
|
|
|
|
describe('searchGoogleFlights — multi-airline filtering', () => {
|
|
afterEach(() => {
|
|
globalThis.fetch = originalFetch;
|
|
});
|
|
|
|
it('forwards multiple airlines as repeated query params to the relay', async () => {
|
|
let capturedUrl = '';
|
|
mockFetch(async (input) => {
|
|
capturedUrl = urlOf(input);
|
|
return new Response(JSON.stringify({ flights: [] }), { status: 200 });
|
|
});
|
|
|
|
await searchGoogleFlights(mockCtx, {
|
|
origin: 'JFK',
|
|
destination: 'LHR',
|
|
departureDate: '2026-05-01',
|
|
airlines: ['BA', 'AA'],
|
|
returnDate: '',
|
|
cabinClass: '',
|
|
maxStops: '',
|
|
departureWindow: '',
|
|
sortBy: '',
|
|
passengers: 1,
|
|
});
|
|
|
|
const url = new URL(capturedUrl);
|
|
const airlines = url.searchParams.getAll('airlines');
|
|
assert.deepEqual(airlines.sort(), ['AA', 'BA'], 'each airline should be a separate airlines= param');
|
|
assert.equal(url.searchParams.get('airlines'), 'BA', 'first value sanity check');
|
|
});
|
|
|
|
it('forwards a single airline correctly', async () => {
|
|
let capturedUrl = '';
|
|
mockFetch(async (input) => {
|
|
capturedUrl = urlOf(input);
|
|
return new Response(JSON.stringify({ flights: [] }), { status: 200 });
|
|
});
|
|
|
|
await searchGoogleFlights(mockCtx, {
|
|
origin: 'DXB',
|
|
destination: 'CDG',
|
|
departureDate: '2026-06-01',
|
|
airlines: ['EK'],
|
|
returnDate: '',
|
|
cabinClass: '',
|
|
maxStops: '',
|
|
departureWindow: '',
|
|
sortBy: '',
|
|
passengers: 1,
|
|
});
|
|
|
|
const url = new URL(capturedUrl);
|
|
assert.deepEqual(url.searchParams.getAll('airlines'), ['EK']);
|
|
});
|
|
|
|
it('sends no airlines param when array is empty', async () => {
|
|
let capturedUrl = '';
|
|
mockFetch(async (input) => {
|
|
capturedUrl = urlOf(input);
|
|
return new Response(JSON.stringify({ flights: [] }), { status: 200 });
|
|
});
|
|
|
|
await searchGoogleFlights(mockCtx, {
|
|
origin: 'ORD',
|
|
destination: 'NRT',
|
|
departureDate: '2026-07-01',
|
|
airlines: [],
|
|
returnDate: '',
|
|
cabinClass: '',
|
|
maxStops: '',
|
|
departureWindow: '',
|
|
sortBy: '',
|
|
passengers: 1,
|
|
});
|
|
|
|
const url = new URL(capturedUrl);
|
|
assert.equal(url.searchParams.has('airlines'), false, 'no airlines param when array is empty');
|
|
});
|
|
|
|
it('handles comma-joined string from codegen (parseStringArray path)', async () => {
|
|
let capturedUrl = '';
|
|
mockFetch(async (input) => {
|
|
capturedUrl = urlOf(input);
|
|
return new Response(JSON.stringify({ flights: [] }), { status: 200 });
|
|
});
|
|
|
|
// Simulate what the generated server stub produces: a comma-joined string assigned to string[]
|
|
await searchGoogleFlights(mockCtx, {
|
|
origin: 'SFO',
|
|
destination: 'HKG',
|
|
departureDate: '2026-08-01',
|
|
airlines: 'UA,CX' as unknown as string[],
|
|
returnDate: '',
|
|
cabinClass: '',
|
|
maxStops: '',
|
|
departureWindow: '',
|
|
sortBy: '',
|
|
passengers: 1,
|
|
});
|
|
|
|
const url = new URL(capturedUrl);
|
|
const airlines = url.searchParams.getAll('airlines');
|
|
assert.deepEqual(airlines.sort(), ['CX', 'UA'], 'comma-joined string should be split into separate params');
|
|
});
|
|
});
|
|
|
|
describe('searchGoogleDates — multi-airline filtering', () => {
|
|
afterEach(() => {
|
|
globalThis.fetch = originalFetch;
|
|
});
|
|
|
|
it('forwards multiple airlines as repeated query params to the relay', async () => {
|
|
let capturedUrl = '';
|
|
mockFetch(async (input) => {
|
|
capturedUrl = urlOf(input);
|
|
return new Response(JSON.stringify({ dates: [], partial: false }), { status: 200 });
|
|
});
|
|
|
|
await searchGoogleDates(mockCtx, {
|
|
origin: 'LAX',
|
|
destination: 'SYD',
|
|
startDate: '2026-05-01',
|
|
endDate: '2026-05-30',
|
|
airlines: ['QF', 'UA'],
|
|
isRoundTrip: false,
|
|
tripDuration: 0,
|
|
cabinClass: '',
|
|
maxStops: '',
|
|
departureWindow: '',
|
|
sortByPrice: false,
|
|
passengers: 1,
|
|
});
|
|
|
|
const url = new URL(capturedUrl);
|
|
const airlines = url.searchParams.getAll('airlines');
|
|
assert.deepEqual(airlines.sort(), ['QF', 'UA'], 'each airline should be a separate airlines= param');
|
|
});
|
|
|
|
it('sets degraded: true when relay returns partial: true', async () => {
|
|
mockFetch(async () =>
|
|
new Response(JSON.stringify({ dates: [{ date: '2026-05-01', return_date: '', price: 450 }], partial: true }), { status: 200 }),
|
|
);
|
|
|
|
const result = await searchGoogleDates(mockCtx, {
|
|
origin: 'MIA',
|
|
destination: 'MAD',
|
|
startDate: '2026-05-01',
|
|
endDate: '2026-05-30',
|
|
airlines: [],
|
|
isRoundTrip: false,
|
|
tripDuration: 0,
|
|
cabinClass: '',
|
|
maxStops: '',
|
|
departureWindow: '',
|
|
sortByPrice: false,
|
|
passengers: 1,
|
|
});
|
|
|
|
assert.equal(result.degraded, true, 'partial chunk failure should set degraded: true');
|
|
assert.equal(result.dates.length, 1, 'partial results still returned');
|
|
});
|
|
|
|
it('sets degraded: false when relay returns complete results', async () => {
|
|
mockFetch(async () =>
|
|
new Response(JSON.stringify({ dates: [{ date: '2026-06-01', return_date: '', price: 380 }], partial: false }), { status: 200 }),
|
|
);
|
|
|
|
const result = await searchGoogleDates(mockCtx, {
|
|
origin: 'BOS',
|
|
destination: 'LIS',
|
|
startDate: '2026-06-01',
|
|
endDate: '2026-06-30',
|
|
airlines: [],
|
|
isRoundTrip: false,
|
|
tripDuration: 0,
|
|
cabinClass: '',
|
|
maxStops: '',
|
|
departureWindow: '',
|
|
sortByPrice: false,
|
|
passengers: 1,
|
|
});
|
|
|
|
assert.equal(result.degraded, false);
|
|
assert.equal(result.dates.length, 1);
|
|
});
|
|
});
|