diff --git a/server/src/db/database.ts b/server/src/db/database.ts index f8642446e..6270d5ad4 100644 --- a/server/src/db/database.ts +++ b/server/src/db/database.ts @@ -167,12 +167,6 @@ function isOwner(tripId: number | string, userId: number): boolean { return !!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId); } -try { - const { backfillFlightEndpoints } = require('../services/airportService'); - backfillFlightEndpoints(); -} catch (err) { - console.error('[DB] Flight endpoint backfill failed:', err); -} export { db, closeDb, reinitialize, getPlaceWithTags, canAccessTrip, isOwner }; export type { TripAccess, PlaceWithTags }; diff --git a/server/src/mcp/tools/mapsWeather.ts b/server/src/mcp/tools/mapsWeather.ts index 4da4e4a7c..34219b352 100644 --- a/server/src/mcp/tools/mapsWeather.ts +++ b/server/src/mcp/tools/mapsWeather.ts @@ -1,6 +1,6 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp'; import { z } from 'zod'; -import { findByIata, searchAirports } from '../../services/airportService'; +import { findByIata, searchAirports } from '../../nest/airports/airports.data'; import { getWeather, getDetailedWeather } from '../../services/weatherService'; import { TOOL_ANNOTATIONS_READONLY, diff --git a/server/src/mcp/tools/transports.ts b/server/src/mcp/tools/transports.ts index c94535490..b60b076e9 100644 --- a/server/src/mcp/tools/transports.ts +++ b/server/src/mcp/tools/transports.ts @@ -8,7 +8,7 @@ import { } from '../../nest/reservations/reservations.bridge'; import { linkBudgetItemToReservation } from '../../nest/budget/budget.bridge'; import { getDay } from '../../nest/days/days.bridge'; -import { findByIata } from '../../services/airportService'; +import { findByIata } from '../../nest/airports/airports.data'; import { safeBroadcast, TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT, TOOL_ANNOTATIONS_WRITE, demoDenied, noAccess, ok, hasTripPermission, permissionDenied, diff --git a/server/src/nest/airports/airports.data.ts b/server/src/nest/airports/airports.data.ts new file mode 100644 index 000000000..99390a2ec --- /dev/null +++ b/server/src/nest/airports/airports.data.ts @@ -0,0 +1,71 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +/** + * The bundled airport dataset — a JSON file read once and kept in memory. + * + * Free functions rather than a provider: nothing here touches the database, and + * the two MCP registrars (mcp/tools/mapsWeather.ts, mcp/tools/transports.ts) + * call them from outside the container. AirportsService is the in-container + * face; the DB-touching backfill lives there. + */ +export interface Airport { + iata: string; + icao: string | null; + name: string; + city: string; + country: string; + lat: number; + lng: number; + tz: string; +} + +let cache: Airport[] | null = null; +let byIata: Map | null = null; + +export function load(): Airport[] { + if (cache) return cache; + const file = path.join(__dirname, '..', '..', '..', 'assets', 'airports.json'); + if (!fs.existsSync(file)) { + console.warn('[airports] airports.json missing — run `node scripts/build-airports.mjs`'); + cache = []; + byIata = new Map(); + return cache; + } + const raw = fs.readFileSync(file, 'utf8'); + cache = JSON.parse(raw) as Airport[]; + byIata = new Map(cache.map(a => [a.iata, a])); + return cache; +} + +export function findByIata(code: string): Airport | null { + load(); + return byIata!.get(code.toUpperCase()) ?? null; +} + +export function searchAirports(query: string, limit = 12): Airport[] { + const all = load(); + const q = query.trim().toLowerCase(); + if (!q) return []; + + const upper = q.toUpperCase(); + if (q.length === 3) { + const exact = byIata!.get(upper); + if (exact) return [exact]; + } + + const matches: Array<{ a: Airport; score: number }> = []; + for (const a of all) { + let score = 0; + if (a.iata === upper) score = 100; + else if (a.icao === upper) score = 90; + else if (a.iata.startsWith(upper)) score = 70; + else if (a.city.toLowerCase().startsWith(q)) score = 60; + else if (a.name.toLowerCase().startsWith(q)) score = 50; + else if (a.city.toLowerCase().includes(q)) score = 30; + else if (a.name.toLowerCase().includes(q)) score = 20; + if (score > 0) matches.push({ a, score }); + } + matches.sort((x, y) => y.score - x.score || x.a.iata.localeCompare(y.a.iata)); + return matches.slice(0, limit).map(m => m.a); +} diff --git a/server/src/nest/airports/airports.module.ts b/server/src/nest/airports/airports.module.ts index ca7f72797..d95ed185b 100644 --- a/server/src/nest/airports/airports.module.ts +++ b/server/src/nest/airports/airports.module.ts @@ -1,9 +1,14 @@ import { Module } from '@nestjs/common'; import { AirportsController } from './airports.controller'; import { AirportsService } from './airports.service'; +import { DatabaseModule } from '../database/database.module'; -/** Airports domain (L2 leaf module). Registered in AppModule. */ +/** Airports domain (L2 leaf module). Registered in AppModule. + * DatabaseModule is imported explicitly: the flight-endpoint backfill needs the + * connection, and @Global only reaches modules that are in the graph — which + * the airports e2e TestingModule is not. */ @Module({ + imports: [DatabaseModule], controllers: [AirportsController], providers: [AirportsService], }) diff --git a/server/src/nest/airports/airports.service.ts b/server/src/nest/airports/airports.service.ts index 934e2e214..2d6a7d7b1 100644 --- a/server/src/nest/airports/airports.service.ts +++ b/server/src/nest/airports/airports.service.ts @@ -1,14 +1,29 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, OnApplicationBootstrap } from '@nestjs/common'; import type { Airport } from '@trek/shared'; -import { searchAirports, findByIata } from '../../services/airportService'; +import { searchAirports, findByIata, load } from './airports.data'; +import { DatabaseService } from '../database/database.service'; /** - * Thin Nest wrapper around the existing airport service. It delegates to the - * same `searchAirports` / `findByIata` functions the legacy route uses, so the - * in-memory dataset and lookup behaviour stay identical and unduplicated. + * The in-container face of the airport dataset, plus the flight-endpoint + * backfill that needs the database. + * + * The backfill used to run from db/database.ts at module load, through a + * require() back into services/ — a cycle that only held because `db` happened + * to be initialised by then. It runs on application bootstrap now: it repairs + * rows, nothing boots on it, and the container is fully built by that point. */ @Injectable() -export class AirportsService { +export class AirportsService implements OnApplicationBootstrap { + constructor(private readonly db: DatabaseService) {} + + onApplicationBootstrap(): void { + try { + this.backfillFlightEndpoints(); + } catch (err) { + console.error('[DB] Flight endpoint backfill failed:', err); + } + } + search(query: string): Airport[] { return searchAirports(query) as Airport[]; } @@ -16,4 +31,49 @@ export class AirportsService { findByIata(code: string): Airport | null { return findByIata(code) as Airport | null; } + + backfillFlightEndpoints(): void { + const pending = this.db.prepare(` + SELECT r.id, r.metadata, r.reservation_time, r.reservation_end_time + FROM reservations r + WHERE r.type = 'flight' + AND NOT EXISTS (SELECT 1 FROM reservation_endpoints e WHERE e.reservation_id = r.id) + `).all() as { id: number; metadata: string | null; reservation_time: string | null; reservation_end_time: string | null }[]; + + if (pending.length === 0) return; + + load(); + const insert = this.db.prepare(` + INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, code, lat, lng, timezone, local_time, local_date) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + const markReview = this.db.prepare('UPDATE reservations SET needs_review = 1 WHERE id = ?'); + + let filled = 0; + let flagged = 0; + for (const r of pending) { + if (!r.metadata) { markReview.run(r.id); flagged++; continue; } + let meta: any; + try { meta = JSON.parse(r.metadata); } catch { markReview.run(r.id); flagged++; continue; } + + const dep = meta.departure_airport ? findByIata(String(meta.departure_airport).slice(0, 3)) : null; + const arr = meta.arrival_airport ? findByIata(String(meta.arrival_airport).slice(0, 3)) : null; + + if (!dep || !arr) { markReview.run(r.id); flagged++; continue; } + + const split = (iso: string | null) => { + if (!iso) return { date: null as string | null, time: null as string | null }; + const [date, time] = iso.split('T'); + return { date: date || null, time: time ? time.slice(0, 5) : null }; + }; + const depParts = split(r.reservation_time); + const arrParts = split(r.reservation_end_time); + + insert.run(r.id, 'from', 0, dep.city ? `${dep.city} (${dep.iata})` : dep.name, dep.iata, dep.lat, dep.lng, dep.tz, depParts.time, depParts.date); + insert.run(r.id, 'to', 1, arr.city ? `${arr.city} (${arr.iata})` : arr.name, arr.iata, arr.lat, arr.lng, arr.tz, arrParts.time, arrParts.date); + filled++; + } + + console.log(`[airports] Backfill: ${filled} filled, ${flagged} flagged for review`); + } } diff --git a/server/src/nest/auth/auth.service.ts b/server/src/nest/auth/auth.service.ts index e60343206..d2e47795d 100644 --- a/server/src/nest/auth/auth.service.ts +++ b/server/src/nest/auth/auth.service.ts @@ -24,7 +24,7 @@ import { revokeUserSessions } from '../../mcp/sessionManager'; import { startTripReminders } from '../../scheduler'; import { deleteUserCompletely } from '../../services/userCleanupService'; import { emitUserDeleted } from '../../plugin-user-lifecycle'; -import { getFlightDistanceKm } from '../../services/distanceService'; +import { haversineKm } from '../common/geo'; import { verifyJwtAndLoadUser } from './jwt-verify'; import { User } from '../../types'; import { DEMO_EMAIL_PRIMARY, isDemoEmail } from '../common/demo'; @@ -991,10 +991,41 @@ export class AuthService { totalTrips: tripStats?.trips || 0, totalDays: tripStats?.days || 0, totalPlaces: places.length, - totalDistanceKm: getFlightDistanceKm(userId), + totalDistanceKm: this.flightDistanceKm(userId), }; } + /** + * Total flight distance a user has covered, summed across every non-cancelled + * flight reservation in their trips. Each flight stores its waypoints in + * reservation_endpoints (from → stops → to, ordered by sequence); the legs + * between consecutive points are added up so multi-stop flights count + * correctly. + */ + private flightDistanceKm(userId: number): number { + const rows = this.db.all<{ reservation_id: number; lat: number; lng: number }>(` + SELECT re.reservation_id, re.lat, re.lng + FROM reservation_endpoints re + JOIN reservations r ON r.id = re.reservation_id + JOIN trips t ON t.id = r.trip_id + LEFT JOIN trip_members tm ON tm.trip_id = t.id AND tm.user_id = ? + WHERE (t.user_id = ? OR tm.user_id IS NOT NULL) + AND r.type = 'flight' + AND r.status != 'cancelled' + ORDER BY re.reservation_id, re.sequence + `, userId, userId); + + let total = 0; + let prev: { id: number; lat: number; lng: number } | null = null; + for (const point of rows) { + if (prev && prev.id === point.reservation_id) { + total += haversineKm(prev.lat, prev.lng, point.lat, point.lng); + } + prev = { id: point.reservation_id, lat: point.lat, lng: point.lng }; + } + return Math.round(total); + } + // ------------------------------------------------------------------------- // MFA // ------------------------------------------------------------------------- diff --git a/server/src/nest/booking-import/kitinerary-mapper.ts b/server/src/nest/booking-import/kitinerary-mapper.ts index b6b524993..71e2a28a1 100644 --- a/server/src/nest/booking-import/kitinerary-mapper.ts +++ b/server/src/nest/booking-import/kitinerary-mapper.ts @@ -1,4 +1,4 @@ -import { findByIata } from '../../services/airportService'; +import { findByIata } from '../airports/airports.data'; import type { KiReservation, KiFlight, KiTrainTrip, KiBusTrip, KiBoatTrip, KiLodgingBusiness, KiFoodEstablishment, KiRentalCar, KiEvent, diff --git a/server/src/nest/common/geo.ts b/server/src/nest/common/geo.ts new file mode 100644 index 000000000..a6348d57f --- /dev/null +++ b/server/src/nest/common/geo.ts @@ -0,0 +1,11 @@ +/** Great-circle distance between two points in kilometres. */ +export function haversineKm(lat1: number, lng1: number, lat2: number, lng2: number): number { + const R = 6371; + const toRad = (deg: number) => (deg * Math.PI) / 180; + const dLat = toRad(lat2 - lat1); + const dLng = toRad(lng2 - lng1); + const a = + Math.sin(dLat / 2) ** 2 + + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2; + return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); +} diff --git a/server/src/nest/transit/transit-itinerary.helpers.ts b/server/src/nest/transit/transit-itinerary.helpers.ts index bb75fe3b2..edb34c8b5 100644 --- a/server/src/nest/transit/transit-itinerary.helpers.ts +++ b/server/src/nest/transit/transit-itinerary.helpers.ts @@ -7,7 +7,7 @@ * transit.mcp.ts's @Tool inputSchema decorators, which evaluate at module * load — before any DI container exists — so they must stay module-level. */ -import { haversineKm } from '../../services/distanceService'; +import { haversineKm } from '../common/geo'; import type { EndpointInput } from '../reservations/reservations.service'; import { localParts, resolveTimeZone } from '../common/timezoneService'; import { deriveTransitStats, type TransitItinerary, type TransitLeg } from './transit.helpers'; diff --git a/server/src/services/airportService.ts b/server/src/services/airportService.ts deleted file mode 100644 index a5745d31b..000000000 --- a/server/src/services/airportService.ts +++ /dev/null @@ -1,109 +0,0 @@ -import fs from 'node:fs'; -import path from 'node:path'; -import { db } from '../db/database'; - -export interface Airport { - iata: string; - icao: string | null; - name: string; - city: string; - country: string; - lat: number; - lng: number; - tz: string; -} - -let cache: Airport[] | null = null; -let byIata: Map | null = null; - -function load(): Airport[] { - if (cache) return cache; - const file = path.join(__dirname, '..', '..', 'assets', 'airports.json'); - if (!fs.existsSync(file)) { - console.warn('[airports] airports.json missing — run `node scripts/build-airports.mjs`'); - cache = []; - byIata = new Map(); - return cache; - } - const raw = fs.readFileSync(file, 'utf8'); - cache = JSON.parse(raw) as Airport[]; - byIata = new Map(cache.map(a => [a.iata, a])); - return cache; -} - -export function findByIata(code: string): Airport | null { - load(); - return byIata!.get(code.toUpperCase()) ?? null; -} - -export function searchAirports(query: string, limit = 12): Airport[] { - const all = load(); - const q = query.trim().toLowerCase(); - if (!q) return []; - - const upper = q.toUpperCase(); - if (q.length === 3) { - const exact = byIata!.get(upper); - if (exact) return [exact]; - } - - const matches: Array<{ a: Airport; score: number }> = []; - for (const a of all) { - let score = 0; - if (a.iata === upper) score = 100; - else if (a.icao === upper) score = 90; - else if (a.iata.startsWith(upper)) score = 70; - else if (a.city.toLowerCase().startsWith(q)) score = 60; - else if (a.name.toLowerCase().startsWith(q)) score = 50; - else if (a.city.toLowerCase().includes(q)) score = 30; - else if (a.name.toLowerCase().includes(q)) score = 20; - if (score > 0) matches.push({ a, score }); - } - matches.sort((x, y) => y.score - x.score || x.a.iata.localeCompare(y.a.iata)); - return matches.slice(0, limit).map(m => m.a); -} - -export function backfillFlightEndpoints(): void { - const pending = db.prepare(` - SELECT r.id, r.metadata, r.reservation_time, r.reservation_end_time - FROM reservations r - WHERE r.type = 'flight' - AND NOT EXISTS (SELECT 1 FROM reservation_endpoints e WHERE e.reservation_id = r.id) - `).all() as { id: number; metadata: string | null; reservation_time: string | null; reservation_end_time: string | null }[]; - - if (pending.length === 0) return; - - load(); - const insert = db.prepare(` - INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, code, lat, lng, timezone, local_time, local_date) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `); - const markReview = db.prepare('UPDATE reservations SET needs_review = 1 WHERE id = ?'); - - let filled = 0; - let flagged = 0; - for (const r of pending) { - if (!r.metadata) { markReview.run(r.id); flagged++; continue; } - let meta: any; - try { meta = JSON.parse(r.metadata); } catch { markReview.run(r.id); flagged++; continue; } - - const dep = meta.departure_airport ? findByIata(String(meta.departure_airport).slice(0, 3)) : null; - const arr = meta.arrival_airport ? findByIata(String(meta.arrival_airport).slice(0, 3)) : null; - - if (!dep || !arr) { markReview.run(r.id); flagged++; continue; } - - const split = (iso: string | null) => { - if (!iso) return { date: null as string | null, time: null as string | null }; - const [date, time] = iso.split('T'); - return { date: date || null, time: time ? time.slice(0, 5) : null }; - }; - const depParts = split(r.reservation_time); - const arrParts = split(r.reservation_end_time); - - insert.run(r.id, 'from', 0, dep.city ? `${dep.city} (${dep.iata})` : dep.name, dep.iata, dep.lat, dep.lng, dep.tz, depParts.time, depParts.date); - insert.run(r.id, 'to', 1, arr.city ? `${arr.city} (${arr.iata})` : arr.name, arr.iata, arr.lat, arr.lng, arr.tz, arrParts.time, arrParts.date); - filled++; - } - - console.log(`[airports] Backfill: ${filled} filled, ${flagged} flagged for review`); -} diff --git a/server/src/services/distanceService.ts b/server/src/services/distanceService.ts deleted file mode 100644 index 4b29dd885..000000000 --- a/server/src/services/distanceService.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { db } from '../db/database'; - -// Great-circle distance between two points in kilometres. -export function haversineKm(lat1: number, lng1: number, lat2: number, lng2: number): number { - const R = 6371; - const toRad = (deg: number) => (deg * Math.PI) / 180; - const dLat = toRad(lat2 - lat1); - const dLng = toRad(lng2 - lng1); - const a = - Math.sin(dLat / 2) ** 2 + - Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2; - return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); -} - -/** - * Total flight distance a user has covered, summed across every non-cancelled - * flight reservation in their trips. Each flight stores its waypoints in - * reservation_endpoints (from → stops → to, ordered by sequence); we add up the - * legs between consecutive points so multi-stop flights count correctly. - */ -export function getFlightDistanceKm(userId: number): number { - const rows = db.prepare(` - SELECT re.reservation_id, re.lat, re.lng - FROM reservation_endpoints re - JOIN reservations r ON r.id = re.reservation_id - JOIN trips t ON t.id = r.trip_id - LEFT JOIN trip_members tm ON tm.trip_id = t.id AND tm.user_id = ? - WHERE (t.user_id = ? OR tm.user_id IS NOT NULL) - AND r.type = 'flight' - AND r.status != 'cancelled' - ORDER BY re.reservation_id, re.sequence - `).all(userId, userId) as { reservation_id: number; lat: number; lng: number }[]; - - let total = 0; - let prev: { id: number; lat: number; lng: number } | null = null; - for (const point of rows) { - if (prev && prev.id === point.reservation_id) { - total += haversineKm(prev.lat, prev.lng, point.lat, point.lng); - } - prev = { id: point.reservation_id, lat: point.lat, lng: point.lng }; - } - return Math.round(total); -} diff --git a/server/tests/e2e/airports.e2e.test.ts b/server/tests/e2e/airports.e2e.test.ts index 83a1ad470..834223abe 100644 --- a/server/tests/e2e/airports.e2e.test.ts +++ b/server/tests/e2e/airports.e2e.test.ts @@ -23,8 +23,8 @@ const { db } = vi.hoisted(() => { vi.mock('../../src/db/database', () => ({ db, closeDb: () => {}, reinitialize: () => {} })); const { mockSearch, mockFindByIata } = vi.hoisted(() => ({ mockSearch: vi.fn(), mockFindByIata: vi.fn() })); -vi.mock('../../src/services/airportService', async (importActual) => { - const actual = await importActual(); +vi.mock('../../src/nest/airports/airports.data', async (importActual) => { + const actual = await importActual(); return { ...actual, searchAirports: mockSearch, findByIata: mockFindByIata }; }); diff --git a/server/tests/setup.console-noise.ts b/server/tests/setup.console-noise.ts index 6e34c34d5..ddaaf30fc 100644 --- a/server/tests/setup.console-noise.ts +++ b/server/tests/setup.console-noise.ts @@ -14,17 +14,17 @@ // incrementally and never replays steps against a current schema; guarding // the steps in src/ would change production output, so the replay artifact // is filtered here instead. Non-duplicate-column step failures still print. -// 2. `[DB] Flight endpoint backfill failed:` + `Cannot find module -// '../services/airportService'` — database.ts runs a boot-time backfill via -// a bare CJS require() at module eval, which the vitest transform pipeline -// cannot resolve (the compiled production dist can). Fires once in every -// test file that evaluates the real database.ts (~60 files). +// 2. `[DB] Flight endpoint backfill failed:` + `no such table` — the backfill +// runs on application bootstrap now, so it fires in e2e TestingModules whose +// fixture DB has no reservations table. It used to be a module-eval require() +// in database.ts that vitest could not resolve at all, in ~60 files; this is +// the same suppression, narrowed to what actually still happens. const MIGRATION_WARN_HEAD = '[migrations] Non-fatal migration step failed:'; const DUPLICATE_COLUMN = /duplicate column name: /; const BACKFILL_ERROR_HEAD = '[DB] Flight endpoint backfill failed:'; -const AIRPORT_MODULE_MISSING = /Cannot find module '\.\.\/services\/airportService'/; +const BACKFILL_TABLE_MISSING = /no such table/; const errorText = (value: unknown): string => value instanceof Error ? value.message : String(value); @@ -37,6 +37,6 @@ console.warn = (...args: unknown[]) => { const realError = console.error.bind(console); console.error = (...args: unknown[]) => { - if (args[0] === BACKFILL_ERROR_HEAD && AIRPORT_MODULE_MISSING.test(errorText(args[1]))) return; + if (args[0] === BACKFILL_ERROR_HEAD && BACKFILL_TABLE_MISSING.test(errorText(args[1]))) return; realError(...args); }; diff --git a/server/tests/unit/nest/airports/airports.data.test.ts b/server/tests/unit/nest/airports/airports.data.test.ts new file mode 100644 index 000000000..530e0c660 --- /dev/null +++ b/server/tests/unit/nest/airports/airports.data.test.ts @@ -0,0 +1,96 @@ +/** + * The bundled airport dataset — AIRPORT-DATA-001..010. + * + * These have no predecessor: the file lived in services/, outside the coverage + * gate, so its scoring ladder was only ever exercised incidentally through the + * e2e route. Moving it into src/nest made the gap visible. + * + * fs is stubbed with a four-airport fixture rather than reading the real + * assets/airports.json, so the ranking assertions stay deterministic and the + * missing-file branch is reachable. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const { readFileSync, existsSync } = vi.hoisted(() => ({ + readFileSync: vi.fn(), + existsSync: vi.fn(() => true), +})); +vi.mock('node:fs', () => ({ default: { readFileSync, existsSync }, readFileSync, existsSync })); + +const FIXTURE = [ + { iata: 'FRA', icao: 'EDDF', name: 'Frankfurt Airport', city: 'Frankfurt', country: 'DE', lat: 50, lng: 8.5, tz: 'Europe/Berlin' }, + { iata: 'FRO', icao: 'ENFL', name: 'Floro Airport', city: 'Floro', country: 'NO', lat: 61, lng: 5, tz: 'Europe/Oslo' }, + { iata: 'JFK', icao: 'KJFK', name: 'John F Kennedy International', city: 'New York', country: 'US', lat: 40, lng: -73, tz: 'America/New_York' }, + { iata: 'BER', icao: 'EDDB', name: 'Berlin Brandenburg', city: 'Berlin', country: 'DE', lat: 52, lng: 13, tz: 'Europe/Berlin' }, +]; + +/** A fresh module registry per case: the dataset is cached after the first load. */ +async function load(opts: { missing?: boolean; body?: string } = {}) { + vi.resetModules(); + existsSync.mockReturnValue(!opts.missing); + readFileSync.mockReturnValue(opts.body ?? JSON.stringify(FIXTURE)); + return import('../../../../src/nest/airports/airports.data'); +} + +beforeEach(() => vi.clearAllMocks()); + +describe('findByIata', () => { + it('AIRPORT-DATA-001: finds an airport by its IATA code, case-insensitively', async () => { + const { findByIata } = await load(); + expect(findByIata('fra')?.name).toBe('Frankfurt Airport'); + expect(findByIata('FRA')?.icao).toBe('EDDF'); + }); + + it('AIRPORT-DATA-002: returns null for an unknown code', async () => { + const { findByIata } = await load(); + expect(findByIata('ZZZ')).toBeNull(); + }); + + it('AIRPORT-DATA-003: returns null for every code when the dataset file is missing', async () => { + const { findByIata } = await load({ missing: true }); + expect(findByIata('FRA')).toBeNull(); + }); +}); + +describe('searchAirports ranking', () => { + it('AIRPORT-DATA-004: an exact three-letter IATA match short-circuits to that one airport', async () => { + const { searchAirports } = await load(); + expect(searchAirports('JFK').map(a => a.iata)).toEqual(['JFK']); + }); + + it('AIRPORT-DATA-005: an exact ICAO match outranks a prefix match', async () => { + const { searchAirports } = await load(); + // 'EDDF' is FRA's ICAO; nothing else starts with it. + expect(searchAirports('EDDF')[0].iata).toBe('FRA'); + }); + + it('AIRPORT-DATA-006: an IATA prefix outranks a city prefix', async () => { + const { searchAirports } = await load(); + // 'FR' prefixes the IATA of FRA and FRO, and the city of Frankfurt. + const iatas = searchAirports('FR').map(a => a.iata); + expect(iatas.slice(0, 2).sort()).toEqual(['FRA', 'FRO']); + }); + + it('AIRPORT-DATA-007: a city prefix outranks a substring match', async () => { + const { searchAirports } = await load(); + // 'Berlin' is BER's city (prefix) and appears inside its name too. + expect(searchAirports('Berlin')[0].iata).toBe('BER'); + }); + + it('AIRPORT-DATA-008: matches a name substring when nothing better applies', async () => { + const { searchAirports } = await load(); + expect(searchAirports('Kennedy').map(a => a.iata)).toEqual(['JFK']); + }); + + it('AIRPORT-DATA-009: an empty or whitespace query returns nothing', async () => { + const { searchAirports } = await load(); + expect(searchAirports('')).toEqual([]); + expect(searchAirports(' ')).toEqual([]); + }); + + it('AIRPORT-DATA-010: honours the result limit', async () => { + const { searchAirports } = await load(); + // Every fixture airport matches on country or name somewhere; cap it at one. + expect(searchAirports('a', 1)).toHaveLength(1); + }); +});