mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 05:56:53 +00:00
refactor(server): fold airports and distance, drop the boot-time backfill cycle (#1834)
Two thirds of be-weather-airports-fold. weatherService (513 LOC, a process cache and a setInterval at import) is left for its own PR. airportService split along the line that was already there. The dataset lookup touches no database — it reads one JSON file and keeps it in memory — and both MCP registrars call it from outside the container, so findByIata/searchAirports are free functions in nest/airports/airports.data.ts. backfillFlightEndpoints needs the connection and became a method on AirportsService. That backfill was the interesting part. db/database.ts ran it at module eval through a bare require() back into services/, so the module graph closed a cycle that only held because `db` happened to be initialised by the time the require resolved. It runs in onApplicationBootstrap now: it repairs rows, nothing boots on it, and the container is fully built by then. The cycle had a visible cost. tests/setup.console-noise.ts carried a suppression for `Cannot find module '../services/airportService'`, because vitest's transform pipeline could not resolve that require at all — it fired in roughly sixty test files. That suppression is now narrowed to the only case left: an e2e TestingModule whose fixture DB has no reservations table. distanceService split the same way: haversineKm is arithmetic and moved to common/geo.ts; getFlightDistanceKm reads the database and became a private method on AuthService, its only caller. AirportsModule imports DatabaseModule explicitly. @Global only reaches modules that are in the graph, and the airports e2e builds its container from AirportsModule alone — exactly the trap the nest README now documents. The search ranking gets its first direct tests. It sat in services/, outside the coverage gate, and only ever ran incidentally through the route; moving it into src/nest made the gap measurable (16% branches). The ten cases pin the scoring ladder — exact IATA short-circuit, ICAO over prefix, IATA prefix over city prefix, city prefix over substring — and the missing-dataset fallback, which no test had reached. 90% branches now.
This commit is contained in:
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, Airport> | 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);
|
||||
}
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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<string, Airport> | 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`);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<typeof import('../../src/services/airportService')>();
|
||||
vi.mock('../../src/nest/airports/airports.data', async (importActual) => {
|
||||
const actual = await importActual<typeof import('../../src/nest/airports/airports.data')>();
|
||||
return { ...actual, searchAirports: mockSearch, findByIata: mockFindByIata };
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user