From 54693474f140ceaaea14a5b30d3024cf7a89bc79 Mon Sep 17 00:00:00 2001 From: Maurice Date: Mon, 17 Aug 2026 14:20:58 +0200 Subject: [PATCH] fix(atlas): stop counting a connection airport as a country visited The role IN ('from','to') filter from #1486 is correct and still in place, but it cannot see the case this issue actually reports. Joining AirTrail legs into one booking with a layover stop is opt-in, so legs imported separately store the hub as the legitimate `to` of one booking and the `from` of the next, which no role filter can tell apart from two real arrivals. Pair them where the countries are counted instead: an arrival and a departure at the same airport, in different bookings of the same trip, within 24 h of each other are a connection, and neither endpoint counts as reached. 24 h is the same window the importer already uses to chain same-PNR legs. Doing it on the read side means the databases that already hold these flights heal on the next request. Only flights pair, and never cancelled ones, so a rental car picked up at the airport still counts its country (#1366). A booking that returns to where the chain started is a return flight, and that check compares distance rather than airport identity, so flying home into a different airport of the same metro area does not erase the country you spent the day in. A country with a place logged in it, or marked by hand, is untouched. Closes #1535 --- server/src/nest/atlas/atlas.service.ts | 61 +++- server/src/nest/atlas/transfer-endpoints.ts | 305 ++++++++++++++++ server/tests/e2e/atlas.e2e.test.ts | 33 ++ .../nest/atlas-transfer-endpoints.test.ts | 337 ++++++++++++++++++ server/tests/unit/nest/atlas.service.test.ts | 161 ++++++++- .../unit/nest/travel-stats.service.test.ts | 60 +++- 6 files changed, 942 insertions(+), 15 deletions(-) create mode 100644 server/src/nest/atlas/transfer-endpoints.ts create mode 100644 server/tests/unit/nest/atlas-transfer-endpoints.test.ts diff --git a/server/src/nest/atlas/atlas.service.ts b/server/src/nest/atlas/atlas.service.ts index dd66a8d6e..0bbfd2acf 100644 --- a/server/src/nest/atlas/atlas.service.ts +++ b/server/src/nest/atlas/atlas.service.ts @@ -14,8 +14,19 @@ import { } from './atlas-geo'; import { KNOWN_COUNTRIES } from './known-countries'; import { cityFromAddress } from './city-from-address'; +import { transferEndpointIds } from './transfer-endpoints'; +import type { FlightEndpointRow } from './transfer-endpoints'; import { haversineKm } from '../common/geo'; +/** + * A reservation endpoint plus the two booking columns that decide whether it may + * take part in layover pairing at all (see transfer-endpoints.ts). + */ +type EndpointRow = FlightEndpointRow & { + reservation_type: string | null; + reservation_status: string | null; +}; + export type CreateBucketData = { name: string; lat?: number | null; @@ -314,19 +325,33 @@ export class AtlasService { // via resolvePlaceCountries above and would otherwise be missed (#1366). // Only 'from'/'to' legs count as actually reached — a 'stop' is an intermediate // connection/layover (e.g. a plane change) the traveler never really visited. - const endpoints = this.db + const endpointRows = this.db .prepare( ` - SELECT DISTINCT r.trip_id, e.lat, e.lng + SELECT DISTINCT e.id, e.reservation_id, r.trip_id, e.role, e.code, e.lat, e.lng, e.local_date, e.local_time, + r.type AS reservation_type, r.status AS reservation_status, + CASE e.role + WHEN 'to' THEN COALESCE(r.reservation_end_time, r.reservation_time) + ELSE COALESCE(r.reservation_time, r.reservation_end_time) + END AS fallback_time FROM reservation_endpoints e JOIN reservations r ON e.reservation_id = r.id WHERE r.trip_id IN (${tripIds.map(() => '?').join(',')}) AND e.role IN ('from', 'to') `, ) - .all(...tripIds) as { trip_id: number; lat: number; lng: number }[]; + .all(...tripIds) as EndpointRow[]; - // Selecting trip_id makes the DISTINCT per trip, so the same airport comes back once - // per booking. Collapse to one entry per coordinate before resolving countries — + // A layover the traveler never left is only marked 'stop' while both its legs sit in + // one booking. Split over two bookings it is a plain 'to' plus 'from' at the same + // airport, so it has to be recognised from the times instead (#1535). Only flights + // pair: a rental car picked up where a flight landed has its own from/to there and + // must keep contributing its country (#1366). + const transfers = transferEndpointIds( + endpointRows.filter((e) => e.reservation_type === 'flight' && e.reservation_status !== 'cancelled'), + ); + const endpoints = endpointRows.filter((e) => !transfers.has(e.id)); + + // Collapse to one entry per coordinate before resolving countries — // getCountryFromCoords is a point-in-polygon scan and used to run once per point. const endpointStatus = new Map(); for (const e of endpoints) { @@ -962,9 +987,16 @@ export class AtlasService { // planned place there) was never counted as visited (#1366). Resolve each endpoint // coordinate to a country and fold it in too. // Only 'from'/'to' legs count as actually reached — a 'stop' is an intermediate - // connection/layover (e.g. a plane change) the traveler never really visited (#1486). - const endpoints = this.db.all<{ lat: number; lng: number }>(` - SELECT DISTINCT e.lat, e.lng + // connection/layover (e.g. a plane change) the traveler never really visited (#1486), + // and the same layover split over two bookings is recognised from its ground time + // instead, because there it is stored as a legitimate 'to'/'from' pair (#1535). + const endpointRows = this.db.all(` + SELECT DISTINCT e.id, e.reservation_id, r.trip_id, e.role, e.code, e.lat, e.lng, e.local_date, e.local_time, + r.type AS reservation_type, r.status AS reservation_status, + CASE e.role + WHEN 'to' THEN COALESCE(r.reservation_end_time, r.reservation_time) + ELSE COALESCE(r.reservation_time, r.reservation_end_time) + END AS fallback_time FROM reservation_endpoints e JOIN reservations r ON e.reservation_id = r.id JOIN trips t ON r.trip_id = t.id @@ -973,7 +1005,18 @@ export class AtlasService { AND COALESCE(t.start_date, t.end_date) IS NOT NULL AND COALESCE(t.start_date, t.end_date) <= date('now') `, userId, userId); - for (const e of endpoints) { + const transfers = transferEndpointIds( + endpointRows.filter(e => e.reservation_type === 'flight' && e.reservation_status !== 'cancelled') + ); + // The DISTINCT no longer collapses per coordinate now that the endpoint id has to be + // in the projection, so collapse here instead: getCountryFromCoords is a + // point-in-polygon scan and must stay at one run per coordinate. + const endpointCoords = new Map(); + for (const e of endpointRows) { + if (transfers.has(e.id)) continue; + endpointCoords.set(`${e.lat},${e.lng}`, { lat: e.lat, lng: e.lng }); + } + for (const e of endpointCoords.values()) { const code = getCountryFromCoords(e.lat, e.lng); if (code) countryCodes.add(code.toUpperCase()); } diff --git a/server/src/nest/atlas/transfer-endpoints.ts b/server/src/nest/atlas/transfer-endpoints.ts new file mode 100644 index 000000000..43ea304eb --- /dev/null +++ b/server/src/nest/atlas/transfer-endpoints.ts @@ -0,0 +1,305 @@ +/** + * Layover detection for the two country-deriving Atlas queries. + * + * A plane change is not a visit, so #1486 taught both queries to skip + * reservation_endpoints rows with role='stop'. That only reaches a layover + * stored INSIDE one booking. When the legs of one journey land in the database as + * two separate reservations (an AirTrail import whose flights never chained, or + * legs imported one by one before the join feature existed), the hub is the + * legitimate 'to' of the first booking and the legitimate 'from' of the second, + * and no role filter can tell that apart from a real destination (#1535). + * + * What does tell it apart is the time on the ground: leaving the same airport + * again within 24 h is a connection, not a stay. The window is deliberately the + * one both importers already apply when they merge the legs of one journey + * (orderConnectionChain in airtrail-import.service.ts, kitinerary-mapper.ts), so + * Atlas and the importers agree on what counts as one connection. + * + * Read-side only, hence no migration: both queries derive their countries live on + * every request, so a database that already holds the split bookings heals as + * soon as this ships. + */ +import { haversineKm } from '../common/geo'; + +/** Ground time up to which an onward departure is a connection rather than a stay. */ +export const MAX_LAYOVER_MS = 24 * 3600 * 1000; + +/** + * Without clocks only calendar dates are left. Leaving the day after arriving is + * still an overnight connection; a wider gap can't be told apart from a genuine + * stopover in the stored data, so it keeps counting as visited. + */ +export const MAX_CLOCKLESS_DAY_GAP = 1; + +/** + * How far from where the arriving leg started the departing leg may land and + * still count as the flight home rather than the next hop. A return is regularly + * booked into the sibling airport of the same city (Charleroi instead of + * Zaventem, Gatwick instead of Heathrow), and that day was spent at the + * destination, not in transit. + * + * A radius is the right shape for that question: the country of a coordinate + * would be both too coarse (Belfast to Dublin to London would read as a return + * and start counting Ireland as visited) and too expensive, since + * getCountryFromCoords scans admin polygons and this runs inside the pairing + * loop. 100 km covers every sibling pair worth naming (CRL/BRU 45 km, LGW/LHR + * 40 km, EWR/JFK 34 km, HND/NRT 60 km) and stays below the distance between two + * cities that each own their traffic, so Amsterdam to Frankfurt to Brussels + * (170 km) keeps pairing as the connection it is. + */ +const RETURN_RADIUS_KM = 100; + +/** One reservation_endpoints row, joined to the columns its booking contributes. */ +export interface FlightEndpointRow { + id: number; + reservation_id: number; + trip_id: number; + role: string; + code: string | null; + lat: number; + lng: number; + local_date: string | null; + local_time: string | null; + /** The booking's own time column for this side of the leg, used for the date only. */ + fallback_time: string | null; +} + +/** Local calendar date plus, when the leg carried an instant, the local clock. */ +interface Stamp { + date: string | null; + time: string | null; +} + +/** Where one end of a booking actually is, for the airports the importer geocoded. */ +interface Coords { + lat: number; + lng: number; +} + +/** The match keys and positions of one booking's own two ends, shared by all of its endpoints. */ +interface BookingKeys { + from: Set; + to: Set; + fromCoords: Coords[]; + toCoords: Coords[]; +} + +interface Endpoint { + row: FlightEndpointRow; + booking: BookingKeys; + /** The buckets this endpoint belongs to, already scoped to its trip. */ + keys: string[]; + /** Midnight of the leg's local date. */ + dayMs: number; + /** The leg's exact instant, null when it carried no clock. */ + exactMs: number | null; + /** What a bucket of departures is ordered by: the instant if there is one, else the day. */ + sortMs: number; +} + +const DATE_ONLY = /^\d{4}-\d{2}-\d{2}$/; +const DATE_PREFIX = /^(\d{4}-\d{2}-\d{2})/; +const CLOCK = /^\d{2}:\d{2}/; +const DAY_MS = 24 * 3600 * 1000; + +/** + * How far past the arrival's midnight the sorted departures still have to be + * walked. Two days, because each half of a pair is anchored on its own date: a + * clockless arrival still pairs with a departure at 23:45 the next day, and an + * arrival at 23:50 still pairs with a date-only departure the day after. This + * bounds the scan only, isConnection stays the one place that decides. + */ +const SCAN_WINDOW_MS = 2 * DAY_MS; + +/** + * Every key an endpoint can be matched on. Two rows for one airport need not + * agree on their shape: an imported leg carries the IATA/ICAO code, a booking + * typed in by hand may only have been geocoded. Indexing both and pairing on + * either match covers the mixed case, and the coordinate key is rounded because + * two geocodes of the same airport differ in the last decimals. + */ +function endpointKeys(e: FlightEndpointRow): string[] { + const keys: string[] = []; + const code = e.code?.trim(); + if (code) keys.push(`c:${code.toUpperCase()}`); + if (Number.isFinite(e.lat) && Number.isFinite(e.lng)) keys.push(`p:${e.lat.toFixed(2)},${e.lng.toFixed(2)}`); + return keys; +} + +/** + * An endpoint only has local_date/local_time when its leg carried a usable + * instant; localParts() returns nulls otherwise, which is exactly the date-only + * AirTrail flight that fails to chain in the first place. So fall back to the + * booking's own time column, but take its DATE half only: for an arrival that + * column can hold the departure airport's local clock (airtrail.mapper.ts), which + * says nothing about the time on the ground here. + */ +function stampOf(e: FlightEndpointRow): Stamp { + if (e.local_date && DATE_ONLY.test(e.local_date)) { + const time = e.local_time && CLOCK.test(e.local_time) ? e.local_time.slice(0, 5) : null; + return { date: e.local_date, time }; + } + const fallback = e.fallback_time?.match(DATE_PREFIX); + return { date: fallback ? fallback[1] : null, time: null }; +} + +function utcMs(date: string, time: string | null): number { + return Date.parse(`${date}T${time ?? '00:00'}:00Z`); +} + +/** + * The instants the pairing compares, read once per endpoint instead of once per + * pair. null means the leg can never be either half of a pair: without a date + * that parses, neither branch below has anything to measure. + * + * A clock that got past the shape check can still be nonsense, and the resulting + * NaN is kept rather than downgraded to "no clock": a pair where both sides claim + * an instant has always been judged on the instant alone. + */ +function instantsOf(stamp: Stamp): { dayMs: number; exactMs: number | null; sortMs: number } | null { + if (!stamp.date) return null; + const dayMs = utcMs(stamp.date, null); + if (!Number.isFinite(dayMs)) return null; + const exactMs = stamp.time === null ? null : utcMs(stamp.date, stamp.time); + return { dayMs, exactMs, sortMs: exactMs !== null && Number.isFinite(exactMs) ? exactMs : dayMs }; +} + +/** + * Is the departure the onward leg of the arrival? Both endpoints sit at the same + * airport and therefore in the same timezone, so the naive local stamps compare + * as real instants and their difference is the true time on the ground. That is + * the only reason this is sound, so never reuse it across two airports. + */ +function isConnection(arrival: Endpoint, departure: Endpoint): boolean { + if (arrival.exactMs !== null && departure.exactMs !== null) { + const gap = departure.exactMs - arrival.exactMs; + return Number.isFinite(gap) && gap >= 0 && gap <= MAX_LAYOVER_MS; + } + const days = (departure.dayMs - arrival.dayMs) / DAY_MS; + return days >= 0 && days <= MAX_CLOCKLESS_DAY_GAP; +} + +function intersects(a: Set, b: Set): boolean { + for (const key of a) { + if (b.has(key)) return true; + } + return false; +} + +/** + * Is the departing booking heading back to where the arriving one started? Then + * the traveler spent the time in between at the destination, and the same call is + * the one orderConnectionChain makes before it joins two legs. + * + * The airport keys answer that for a return into the exact same airport; the + * distance covers the sibling airport of the same city, which the keys can never + * match. Legs the importer had no coordinates for fall back to the key match. + */ +function returnsToOrigin(departure: Endpoint, arrival: Endpoint): boolean { + if (intersects(departure.booking.to, arrival.booking.from)) return true; + for (const landed of departure.booking.toCoords) { + for (const started of arrival.booking.fromCoords) { + if (haversineKm(landed.lat, landed.lng, started.lat, started.lng) <= RETURN_RADIUS_KM) return true; + } + } + return false; +} + +/** First entry of a bucket sorted by sortMs that is not earlier than `from`. */ +function firstFrom(bucket: Endpoint[], from: number): number { + let low = 0; + let high = bucket.length; + while (low < high) { + const mid = (low + high) >>> 1; + if (bucket[mid].sortMs < from) low = mid + 1; + else high = mid; + } + return low; +} + +/** + * The endpoint ids that are only a connection airport, spread over two bookings. + * + * Callers pass flight endpoints of non-cancelled bookings only: a car rental + * picked up right where a flight landed has its own from/to at that airport and + * would otherwise pair with the arrival and swallow the destination country + * (#1366). + */ +export function transferEndpointIds(rows: FlightEndpointRow[]): Set { + const transfers = new Set(); + if (rows.length < 2) return transfers; + + const landings: Endpoint[] = []; + const departures = new Map(); + const bookings = new Map(); + + for (const row of rows) { + if (row.role !== 'from' && row.role !== 'to') continue; + const keys = endpointKeys(row); + if (keys.length === 0) continue; + + let booking = bookings.get(row.reservation_id); + if (!booking) { + booking = { from: new Set(), to: new Set(), fromCoords: [], toCoords: [] }; + bookings.set(row.reservation_id, booking); + } + const arriving = row.role === 'to'; + const ownKeys = arriving ? booking.to : booking.from; + for (const key of keys) ownKeys.add(key); + if (Number.isFinite(row.lat) && Number.isFinite(row.lng)) { + (arriving ? booking.toCoords : booking.fromCoords).push({ lat: row.lat, lng: row.lng }); + } + + // A leg without a readable date pairs with nothing, but its keys and its + // position had to be recorded above: they describe the booking either way. + const instants = instantsOf(stampOf(row)); + if (!instants) continue; + + // Two trips that happen to touch the same airport are two journeys: an + // arrival home from one and a departure on the next is not a layover. The + // trip belongs in the bucket key rather than in a per-pair check, so the + // journeys of an imported flight log are never compared against each other. + const endpoint: Endpoint = { row, booking, keys: keys.map((key) => `${row.trip_id}:${key}`), ...instants }; + if (arriving) { + landings.push(endpoint); + continue; + } + for (const key of endpoint.keys) { + const bucket = departures.get(key); + if (bucket) bucket.push(endpoint); + else departures.set(key, [endpoint]); + } + } + + // In time order, so an arrival can stop looking once the ground time is out of + // reach. Ten years of flight log imported into one trip put hundreds of + // departures on a home airport, and comparing every arrival against all of them + // cost more than a second on both request paths that call this. + for (const bucket of departures.values()) bucket.sort((a, b) => a.sortMs - b.sortMs); + + for (const arrival of landings) { + const until = arrival.dayMs + SCAN_WINDOW_MS; + // An endpoint carrying both a code and a coordinate sits in two buckets, so + // without this the same pair would be judged twice. + const seen = arrival.keys.length > 1 ? new Set() : null; + for (const key of arrival.keys) { + const left = departures.get(key); + if (!left) continue; + for (let i = firstFrom(left, arrival.dayMs); i < left.length; i++) { + const departure = left[i]; + if (departure.sortMs > until) break; + if (seen) { + if (seen.has(departure)) continue; + seen.add(departure); + } + if (departure.row.reservation_id === arrival.row.reservation_id) continue; + if (returnsToOrigin(departure, arrival)) continue; + if (!isConnection(arrival, departure)) continue; + transfers.add(arrival.row.id); + transfers.add(departure.row.id); + } + } + } + + return transfers; +} diff --git a/server/tests/e2e/atlas.e2e.test.ts b/server/tests/e2e/atlas.e2e.test.ts index baa089a4d..bf702a102 100644 --- a/server/tests/e2e/atlas.e2e.test.ts +++ b/server/tests/e2e/atlas.e2e.test.ts @@ -184,6 +184,39 @@ describe('Atlas e2e (real auth guard + real service + temp SQLite)', () => { expect(jp.status).toBe(200); expect(jp.body.status).toBe('planned'); }); + // #1535: the same journey booked as two flights, which is what an AirTrail import + // whose legs never chained leaves behind. The hub is a legitimate 'to'/'from' pair, so + // only the ground time between them says it was a plane change. Own user again. + it('200 stats leaves out a layover country whose two legs are separate bookings', async () => { + const flyerId = createUser(db as never, { username: 'atlas-flyer', email: 'atlas-flyer@test.example' }).user.id; + const iso = (days: number) => new Date(Date.now() + days * 86_400_000).toISOString().slice(0, 10); + const trip = createTrip(db as never, flyerId, { + title: 'New York via Helsinki', + start_date: iso(-20), + end_date: iso(-10), + }); + const day = iso(-15); + const insertFlight = db.prepare("INSERT INTO reservations (trip_id, title, type) VALUES (?, ?, 'flight')"); + const insertEndpoint = db.prepare( + 'INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, lat, lng, code, local_date, local_time) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)', + ); + const inbound = insertFlight.run(trip.id, 'BRU-HEL').lastInsertRowid; + insertEndpoint.run(inbound, 'from', 0, 'Brussels', 50.9014, 4.4844, 'BRU', day, '07:00'); + insertEndpoint.run(inbound, 'to', 1, 'Helsinki', 60.3172, 24.9633, 'HEL', day, '09:30'); + const onward = insertFlight.run(trip.id, 'HEL-JFK').lastInsertRowid; + insertEndpoint.run(onward, 'from', 0, 'Helsinki', 60.3172, 24.9633, 'HEL', day, '11:00'); + insertEndpoint.run(onward, 'to', 1, 'New York', 40.6413, -73.7781, 'JFK', day, '15:00'); + + const res = await request(server).get('/api/addons/atlas/stats').set('Cookie', sessionCookie(flyerId)); + expect(res.status).toBe(200); + + const codes = (res.body.countries as { code: string }[]).map((c) => c.code); + expect(codes).toContain('BE'); + expect(codes).toContain('US'); + expect(codes).not.toContain('FI'); + expect(res.body.stats.totalCountries).toBe(2); + }); + // ── /locate (#1115) ──────────────────────────────────────────────────────── describe('GET locate', () => { it('resolves a coordinate to the country and the region the map can highlight', async () => { diff --git a/server/tests/unit/nest/atlas-transfer-endpoints.test.ts b/server/tests/unit/nest/atlas-transfer-endpoints.test.ts new file mode 100644 index 000000000..033f29f2a --- /dev/null +++ b/server/tests/unit/nest/atlas-transfer-endpoints.test.ts @@ -0,0 +1,337 @@ +/** + * The layover pairing behind #1535. Pure, so it gets its own file and no DB: the + * whole question is which two reservation_endpoints rows describe one plane change + * spread over two bookings, and every case below is a shape the AirTrail import + * really produces (timed legs, date-only legs, a leg whose booking clock belongs + * to the other airport). + */ +import { describe, it, expect } from 'vitest'; + +import { + MAX_CLOCKLESS_DAY_GAP, + MAX_LAYOVER_MS, + transferEndpointIds, + type FlightEndpointRow, +} from '../../../src/nest/atlas/transfer-endpoints'; + +interface Airport { + code: string | null; + lat: number; + lng: number; +} + +const BRU: Airport = { code: 'BRU', lat: 50.9014, lng: 4.4844 }; +const HEL: Airport = { code: 'HEL', lat: 60.3172, lng: 24.9633 }; +const JFK: Airport = { code: 'JFK', lat: 40.6413, lng: -73.7781 }; + +/** Brussels' other airport, 45 km from BRU: the one a cheap return lands at. */ +const CRL: Airport = { code: 'CRL', lat: 50.4592, lng: 4.4538 }; +/** Amsterdam, 170 km from Brussels, which is two cities and not one. */ +const AMS: Airport = { code: 'AMS', lat: 52.3105, lng: 4.7683 }; +const FRA: Airport = { code: 'FRA', lat: 50.0379, lng: 8.5622 }; + +/** Same airport as HEL, but geocoded instead of imported, so no IATA code. */ +const HEL_NO_CODE: Airport = { code: null, lat: 60.3174, lng: 24.9629 }; + +interface Clock { + date?: string | null; + time?: string | null; + /** The booking's own reservation_time/_end_time, which is all a date-only leg has. */ + fallback?: string | null; +} + +let nextId = 0; + +function endpoint( + reservationId: number, + role: 'from' | 'to' | 'stop', + at: Airport, + clock: Clock = {}, + tripId = 1, +): FlightEndpointRow { + return { + id: ++nextId, + reservation_id: reservationId, + trip_id: tripId, + role, + code: at.code, + lat: at.lat, + lng: at.lng, + local_date: clock.date ?? null, + local_time: clock.time ?? null, + fallback_time: clock.fallback ?? null, + }; +} + +/** + * BRU → HEL as one booking and HEL → JFK as the next, which is what the importer + * writes when the two legs never chained. `arrival`/`departure` are the clocks at + * the hub, the only ones the pairing looks at. + */ +function splitChain(arrival: Clock, departure: Clock, hub: Airport = HEL, hubAgain: Airport = hub) { + const rows = [ + endpoint(1, 'from', BRU, { date: '2026-08-01', time: '07:00' }), + endpoint(1, 'to', hub, arrival), + endpoint(2, 'from', hubAgain, departure), + endpoint(2, 'to', JFK, { date: departure.date ?? '2026-08-01', time: '15:00' }), + ]; + return { rows, hubIds: [rows[1].id, rows[2].id].sort((a, b) => a - b) }; +} + +const ids = (found: Set) => [...found].sort((a, b) => a - b); + +describe('transferEndpointIds', () => { + it('keeps the 24 h window the importers use to merge the legs of one journey', () => { + expect(MAX_LAYOVER_MS).toBe(24 * 3600 * 1000); + expect(MAX_CLOCKLESS_DAY_GAP).toBe(1); + }); + + it('pairs an arrival and an onward departure at the same airport code', () => { + const { rows, hubIds } = splitChain({ date: '2026-08-01', time: '09:30' }, { date: '2026-08-01', time: '11:00' }); + expect(ids(transferEndpointIds(rows))).toEqual(hubIds); + }); + + it('pairs on the coordinate when neither row carries a code', () => { + const { rows, hubIds } = splitChain( + { date: '2026-08-01', time: '09:30' }, + { date: '2026-08-01', time: '11:00' }, + HEL_NO_CODE, + ); + expect(ids(transferEndpointIds(rows))).toEqual(hubIds); + }); + + it('pairs a coded row with an uncoded one at the same coordinate', () => { + const { rows, hubIds } = splitChain( + { date: '2026-08-01', time: '09:30' }, + { date: '2026-08-01', time: '11:00' }, + HEL, + HEL_NO_CODE, + ); + expect(ids(transferEndpointIds(rows))).toEqual(hubIds); + }); + + it('matches codes case-insensitively', () => { + const { rows, hubIds } = splitChain( + { date: '2026-08-01', time: '09:30' }, + { date: '2026-08-01', time: '11:00' }, + { code: 'hel', lat: 1, lng: 1 }, + { code: 'HEL', lat: 2, lng: 2 }, + ); + expect(ids(transferEndpointIds(rows))).toEqual(hubIds); + }); + + it('pairs a turnaround with no ground time at all and one of exactly 24 h', () => { + for (const departure of [ + { date: '2026-08-01', time: '09:30' }, + { date: '2026-08-02', time: '09:30' }, + ]) { + const { rows, hubIds } = splitChain({ date: '2026-08-01', time: '09:30' }, departure); + expect(ids(transferEndpointIds(rows))).toEqual(hubIds); + } + }); + + it('leaves a stay of 24 h and a minute, or a departure before the arrival, counted', () => { + for (const departure of [ + { date: '2026-08-02', time: '09:31' }, + { date: '2026-08-01', time: '09:29' }, + ]) { + const { rows } = splitChain({ date: '2026-08-01', time: '09:30' }, departure); + expect(transferEndpointIds(rows).size).toBe(0); + } + }); + + it('falls back to the calendar date when a leg has no clock, same day or the next', () => { + for (const date of ['2026-08-01', '2026-08-02']) { + const { rows, hubIds } = splitChain({ date: '2026-08-01' }, { date }); + expect(ids(transferEndpointIds(rows))).toEqual(hubIds); + } + }); + + it('treats an unreadable clock as no clock and pairs on the date', () => { + const { rows, hubIds } = splitChain({ date: '2026-08-01', time: 'midday' }, { date: '2026-08-01', time: '11:00' }); + expect(ids(transferEndpointIds(rows))).toEqual(hubIds); + }); + + it('keeps a hub the traveler left two days later', () => { + const { rows } = splitChain({ date: '2026-08-01' }, { date: '2026-08-03' }); + expect(transferEndpointIds(rows).size).toBe(0); + }); + + it('keeps a hub whose onward departure has no date at all', () => { + const { rows } = splitChain({ date: '2026-08-01', time: '09:30' }, {}); + expect(transferEndpointIds(rows).size).toBe(0); + }); + + it('reads the date out of the booking column but never its clock', () => { + // A date-only AirTrail flight leaves the arrival endpoint without local parts, and + // reservation_time holds the DEPARTURE airport's local clock, 22:00 in Brussels for + // a flight that landed in Helsinki that morning. Using it as an arrival time would + // make the onward 11:00 departure look like it left before the plane landed. + const { rows, hubIds } = splitChain( + { fallback: '2026-08-01T22:00' }, + { date: '2026-08-01', time: '11:00' }, + ); + expect(ids(transferEndpointIds(rows))).toEqual(hubIds); + }); + + it('never pairs the two ends of one booking', () => { + const rows = [ + endpoint(1, 'from', BRU, { date: '2026-08-01', time: '07:00' }), + endpoint(1, 'to', HEL, { date: '2026-08-01', time: '09:30' }), + ]; + expect(transferEndpointIds(rows).size).toBe(0); + }); + + it('never pairs across two trips', () => { + const rows = [ + endpoint(1, 'from', BRU, { date: '2026-08-01', time: '07:00' }), + endpoint(1, 'to', HEL, { date: '2026-08-01', time: '09:30' }), + endpoint(2, 'from', HEL, { date: '2026-08-01', time: '11:00' }, 2), + endpoint(2, 'to', JFK, { date: '2026-08-01', time: '15:00' }, 2), + ]; + expect(transferEndpointIds(rows).size).toBe(0); + }); + + it('never treats a same-day out-and-back as a connection', () => { + // Flown out in the morning, back in the evening: the day was spent in Finland. + const rows = [ + endpoint(1, 'from', BRU, { date: '2026-08-01', time: '07:00' }), + endpoint(1, 'to', HEL, { date: '2026-08-01', time: '09:30' }), + endpoint(2, 'from', HEL, { date: '2026-08-01', time: '18:00' }), + endpoint(2, 'to', BRU, { date: '2026-08-01', time: '20:30' }), + ]; + expect(transferEndpointIds(rows).size).toBe(0); + }); + + it("never treats an out-and-back into the city's other airport as a connection", () => { + // Flown to Helsinki in the morning, back to Brussels in the evening, but the return + // lands at Charleroi instead of Zaventem. The day was still spent in Finland, and + // the airport keys alone can never see that the two ends are one city. + const rows = [ + endpoint(1, 'from', BRU, { date: '2026-08-01', time: '07:00' }), + endpoint(1, 'to', HEL, { date: '2026-08-01', time: '09:30' }), + endpoint(2, 'from', HEL, { date: '2026-08-01', time: '18:00' }), + endpoint(2, 'to', CRL, { date: '2026-08-01', time: '20:30' }), + ]; + expect(transferEndpointIds(rows).size).toBe(0); + }); + + it('still pairs a hub between two airports that are their own cities', () => { + // Amsterdam to Frankfurt to Brussels is a real connection, and AMS and BRU are + // 170 km apart, so the radius that catches a sibling airport must not reach here. + const rows = [ + endpoint(1, 'from', AMS, { date: '2026-08-01', time: '07:00' }), + endpoint(1, 'to', FRA, { date: '2026-08-01', time: '08:15' }), + endpoint(2, 'from', FRA, { date: '2026-08-01', time: '10:00' }), + endpoint(2, 'to', BRU, { date: '2026-08-01', time: '11:05' }), + ]; + expect(ids(transferEndpointIds(rows))).toEqual([rows[1].id, rows[2].id]); + }); + + it('keeps an overnight return through the sibling airport out of the pairing', () => { + // Same shape a night away on business has: 22.5 h on the ground, home into Gatwick + // instead of Heathrow. Still inside the 24 h window, so only the direction says no. + const LHR: Airport = { code: 'LHR', lat: 51.47, lng: -0.4543 }; + const LGW: Airport = { code: 'LGW', lat: 51.1537, lng: -0.1821 }; + const rows = [ + endpoint(1, 'from', LHR, { date: '2026-08-01', time: '06:30' }), + endpoint(1, 'to', HEL, { date: '2026-08-01', time: '09:30' }), + endpoint(2, 'from', HEL, { date: '2026-08-02', time: '08:00' }), + endpoint(2, 'to', LGW, { date: '2026-08-02', time: '09:45' }), + ]; + expect(transferEndpointIds(rows).size).toBe(0); + }); + + it('pairs only inside one trip, even where the hub and the clocks would fit', () => { + // Two journeys through Helsinki on one day. The second trip's arrival has no onward + // leg of its own, and the first trip's departure two hours later must not become one. + const rows = [ + endpoint(1, 'from', BRU, { date: '2026-08-01', time: '07:00' }), + endpoint(1, 'to', HEL, { date: '2026-08-01', time: '09:30' }), + endpoint(2, 'from', HEL, { date: '2026-08-01', time: '11:00' }), + endpoint(2, 'to', JFK, { date: '2026-08-01', time: '15:00' }), + endpoint(3, 'from', BRU, { date: '2026-08-01', time: '06:00' }, 2), + endpoint(3, 'to', HEL, { date: '2026-08-01', time: '09:00' }, 2), + ]; + + expect(ids(transferEndpointIds(rows))).toEqual([rows[1].id, rows[2].id]); + }); + + it('pairs across midnight when only one of the two legs carries a clock', () => { + // The scan looks ahead from the arrival's date, not from its instant, because a + // date-only leg is anchored on midnight: landing at 23:50 and leaving on a flight + // the next day is one connection, and so is a dateless landing left at 23:45. + const lateArrival = splitChain({ date: '2026-08-01', time: '23:50' }, { date: '2026-08-02' }); + expect(ids(transferEndpointIds(lateArrival.rows))).toEqual(lateArrival.hubIds); + + const lateDeparture = splitChain({ date: '2026-08-01' }, { date: '2026-08-02', time: '23:45' }); + expect(ids(transferEndpointIds(lateDeparture.rows))).toEqual(lateDeparture.hubIds); + }); + + it('pairs nothing when neither side has a readable date', () => { + for (const clock of [{}, { fallback: 'sometime in August' }, { date: '01/08/2026' }]) { + const { rows } = splitChain(clock, clock); + expect(transferEndpointIds(rows).size).toBe(0); + } + }); + + it('pairs nothing when a date passes the shape check but is not a real date', () => { + for (const time of [null, '09:30']) { + const { rows } = splitChain({ date: '2026-13-45', time }, { date: '2026-13-45', time }); + expect(transferEndpointIds(rows).size).toBe(0); + } + }); + + it('pairs an onward leg whose booking never got a second endpoint', () => { + // A leg AirTrail has no coordinates for loses that endpoint, so the onward booking + // has a 'from' and nothing else, which must not read as flying back to the origin. + const rows = [ + endpoint(1, 'from', BRU, { date: '2026-08-01', time: '07:00' }), + endpoint(1, 'to', HEL, { date: '2026-08-01', time: '09:30' }), + endpoint(2, 'from', HEL, { date: '2026-08-01', time: '11:00' }), + ]; + expect(ids(transferEndpointIds(rows))).toEqual([rows[1].id, rows[2].id]); + }); + + it('ignores a stop row rather than reading it as an arrival', () => { + // The callers filter roles in SQL, but a 'stop' is already handled by #1486 and must + // never become one half of a pair here. + const rows = [ + endpoint(1, 'from', BRU, { date: '2026-08-01', time: '07:00' }), + endpoint(1, 'stop', HEL, { date: '2026-08-01', time: '09:30' }), + endpoint(2, 'from', HEL, { date: '2026-08-01', time: '11:00' }), + endpoint(2, 'to', JFK, { date: '2026-08-01', time: '15:00' }), + ]; + expect(transferEndpointIds(rows).size).toBe(0); + }); + + it('ignores a row that can be keyed neither by code nor by coordinate', () => { + const { rows } = splitChain( + { date: '2026-08-01', time: '09:30' }, + { date: '2026-08-01', time: '11:00' }, + { code: null, lat: NaN, lng: NaN }, + ); + expect(transferEndpointIds(rows).size).toBe(0); + }); + + it('has nothing to pair with a single endpoint', () => { + expect(transferEndpointIds([endpoint(1, 'to', HEL, { date: '2026-08-01', time: '09:30' })]).size).toBe(0); + }); + + it('marks only the pair that matches when the same hub is used again later', () => { + // Out via Helsinki on the 1st, home via Helsinki on the 8th, but that time with + // three days in the city, so only the first pass is a plane change. + const rows = [ + endpoint(1, 'from', BRU, { date: '2026-08-01', time: '07:00' }), + endpoint(1, 'to', HEL, { date: '2026-08-01', time: '09:30' }), + endpoint(2, 'from', HEL, { date: '2026-08-01', time: '11:00' }), + endpoint(2, 'to', JFK, { date: '2026-08-01', time: '15:00' }), + endpoint(3, 'from', JFK, { date: '2026-08-08', time: '01:00' }), + endpoint(3, 'to', HEL, { date: '2026-08-08', time: '10:00' }), + endpoint(4, 'from', HEL, { date: '2026-08-11', time: '12:00' }), + endpoint(4, 'to', BRU, { date: '2026-08-11', time: '14:30' }), + ]; + + expect(ids(transferEndpointIds(rows))).toEqual([rows[1].id, rows[2].id]); + }); +}); diff --git a/server/tests/unit/nest/atlas.service.test.ts b/server/tests/unit/nest/atlas.service.test.ts index f29f8d49d..e35c1c1be 100644 --- a/server/tests/unit/nest/atlas.service.test.ts +++ b/server/tests/unit/nest/atlas.service.test.ts @@ -54,11 +54,14 @@ function insertReservationEndpoint( role: 'from' | 'to' | 'stop', sequence: number, lat: number, - lng: number + lng: number, + code: string | null = null, + localDate: string | null = null, + localTime: string | null = null ) { db.prepare( - 'INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, lat, lng) VALUES (?, ?, ?, ?, ?, ?)' - ).run(reservationId, role, sequence, `Endpoint ${sequence}`, lat, lng); + 'INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, lat, lng, code, local_date, local_time) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)' + ).run(reservationId, role, sequence, `Endpoint ${sequence}`, lat, lng, code, localDate, localTime); } function insertPlace(db: any, tripId: number, name: string, address: string | null = null) { @@ -202,6 +205,158 @@ describe('getStats', () => { }); }); +// ── #1535: the layover the role filter can't see ───────────────────────────── + +/** + * When the legs of one journey arrive as two separate bookings (an AirTrail import + * whose flights never chained), the connection airport is the legitimate 'to' of the + * first and the legitimate 'from' of the second, so the role filter from #1486 lets + * it through. Brussels to Helsinki to New York throughout: BE and US were reached, + * Finland was a plane change. + */ +describe('getStats layover pairs across two bookings', () => { + const BRU = { lat: 50.9014, lng: 4.4844, code: 'BRU' }; + const HEL = { lat: 60.3172, lng: 24.9633, code: 'HEL' }; + const JFK = { lat: 40.6413, lng: -73.7781, code: 'JFK' }; + + type Clock = { date: string | null; time: string | null }; + + function inboundToHelsinki(tripId: number, arrival: Clock) { + const res = createReservation(testDb, tripId, { type: 'flight', title: 'BRU-HEL' }); + insertReservationEndpoint(testDb, res.id, 'from', 0, BRU.lat, BRU.lng, BRU.code, arrival.date, '07:00'); + insertReservationEndpoint(testDb, res.id, 'to', 1, HEL.lat, HEL.lng, HEL.code, arrival.date, arrival.time); + return res; + } + + function onwardFromHelsinki(tripId: number, departure: Clock, type = 'flight') { + const res = createReservation(testDb, tripId, { type, title: 'HEL-JFK' }); + insertReservationEndpoint(testDb, res.id, 'from', 0, HEL.lat, HEL.lng, HEL.code, departure.date, departure.time); + insertReservationEndpoint(testDb, res.id, 'to', 1, JFK.lat, JFK.lng, JFK.code, departure.date, '15:00'); + return res; + } + + const codesFor = async (userId: number) => + (await atlas.stats(userId)).countries.map((c: { code: string }) => c.code); + + it('ATLAS-UNIT-044 (#1535): a hub arrived at and left again the same day is not visited', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: 'New York via Helsinki' }); + inboundToHelsinki(trip.id, { date: '2026-08-01', time: '09:30' }); + onwardFromHelsinki(trip.id, { date: '2026-08-01', time: '11:00' }); + + const codes = await codesFor(user.id); + expect(codes).toContain('BE'); + expect(codes).toContain('US'); + expect(codes).not.toContain('FI'); + }); + + it('ATLAS-UNIT-045 (#1535): a hub the traveler left two days later stays visited', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: 'Helsinki stopover' }); + inboundToHelsinki(trip.id, { date: '2026-08-01', time: '09:30' }); + onwardFromHelsinki(trip.id, { date: '2026-08-03', time: '11:00' }); + + expect(await codesFor(user.id)).toContain('FI'); + }); + + it('ATLAS-UNIT-046 (#1535): a pair with no clocks pairs on the booking dates', async () => { + // The case the reporter actually has: a date-only AirTrail flight leaves the + // endpoints without local parts, so only reservation_time carries the day. + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: 'New York via Helsinki' }); + const inbound = inboundToHelsinki(trip.id, { date: null, time: null }); + const onward = onwardFromHelsinki(trip.id, { date: null, time: null }); + const setTime = testDb.prepare('UPDATE reservations SET reservation_time = ? WHERE id = ?'); + setTime.run('2026-08-01', inbound.id); + setTime.run('2026-08-01', onward.id); + + const codes = await codesFor(user.id); + expect(codes).toContain('BE'); + expect(codes).toContain('US'); + expect(codes).not.toContain('FI'); + }); + + it('ATLAS-UNIT-047 (#1535): clockless bookings two days apart keep the hub visited', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: 'Helsinki stopover' }); + const inbound = inboundToHelsinki(trip.id, { date: null, time: null }); + const onward = onwardFromHelsinki(trip.id, { date: null, time: null }); + const setTime = testDb.prepare('UPDATE reservations SET reservation_time = ? WHERE id = ?'); + setTime.run('2026-08-01', inbound.id); + setTime.run('2026-08-03', onward.id); + + expect(await codesFor(user.id)).toContain('FI'); + }); + + it('ATLAS-UNIT-048 (#1535): a place in the layover country keeps it visited', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: 'New York via Helsinki' }); + inboundToHelsinki(trip.id, { date: '2026-08-01', time: '09:30' }); + onwardFromHelsinki(trip.id, { date: '2026-08-01', time: '11:00' }); + insertPlace(testDb, trip.id, 'Kamppi Chapel', 'Simonkatu 7, Helsinki, Finland'); + + expect(await codesFor(user.id)).toContain('FI'); + }); + + it('ATLAS-UNIT-049 (#1366 guard): a car rental picked up where the flight landed keeps the country', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: 'Driving Finland' }); + inboundToHelsinki(trip.id, { date: '2026-08-01', time: '09:30' }); + onwardFromHelsinki(trip.id, { date: '2026-08-01', time: '11:00' }, 'car_rental'); + + expect(await codesFor(user.id)).toContain('FI'); + }); + + it('ATLAS-UNIT-050 (#1535): a cancelled onward flight is no connection', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: 'Helsinki, stuck' }); + inboundToHelsinki(trip.id, { date: '2026-08-01', time: '09:30' }); + const onward = onwardFromHelsinki(trip.id, { date: '2026-08-01', time: '11:00' }); + testDb.prepare('UPDATE reservations SET status = ? WHERE id = ?').run('cancelled', onward.id); + + expect(await codesFor(user.id)).toContain('FI'); + }); + + it('ATLAS-UNIT-051 (#1535): an onward flight on another trip is another journey', async () => { + // Landing home from one trip and leaving on the next within a day is not a plane + // change, and the pairing spans every trip the user can see. + const { user } = createUser(testDb); + const arriving = createTrip(testDb, user.id, { title: 'Back from Brussels' }); + const leaving = createTrip(testDb, user.id, { title: 'Off to New York' }); + inboundToHelsinki(arriving.id, { date: '2026-08-01', time: '09:30' }); + onwardFromHelsinki(leaving.id, { date: '2026-08-01', time: '11:00' }); + + expect(await codesFor(user.id)).toContain('FI'); + }); + + it('ATLAS-UNIT-052 (#1535): a same-day out-and-back is a day in the country, not a transfer', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: 'A day in Helsinki' }); + inboundToHelsinki(trip.id, { date: '2026-08-01', time: '09:30' }); + const back = createReservation(testDb, trip.id, { type: 'flight', title: 'HEL-BRU' }); + insertReservationEndpoint(testDb, back.id, 'from', 0, HEL.lat, HEL.lng, HEL.code, '2026-08-01', '18:00'); + insertReservationEndpoint(testDb, back.id, 'to', 1, BRU.lat, BRU.lng, BRU.code, '2026-08-01', '20:30'); + + expect(await codesFor(user.id)).toContain('FI'); + }); + + it('ATLAS-UNIT-053 (#1535): a return into the other airport of the same city is still a day away', async () => { + // Same day trip as ATLAS-UNIT-052, but home into Charleroi instead of Zaventem, the + // way a cheap return is booked. Comparing the airports alone reads that as flying + // onwards and drops Finland, though the traveler spent the day in Helsinki. + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: 'A day in Helsinki' }); + inboundToHelsinki(trip.id, { date: '2026-08-01', time: '09:30' }); + const back = createReservation(testDb, trip.id, { type: 'flight', title: 'HEL-CRL' }); + insertReservationEndpoint(testDb, back.id, 'from', 0, HEL.lat, HEL.lng, HEL.code, '2026-08-01', '18:00'); + insertReservationEndpoint(testDb, back.id, 'to', 1, 50.4592, 4.4538, 'CRL', '2026-08-01', '20:30'); + + const codes = await codesFor(user.id); + expect(codes).toContain('FI'); + expect(codes).toContain('BE'); + }); +}); + // ── the shared geocode cache ────────────────────────────────────── /** diff --git a/server/tests/unit/nest/travel-stats.service.test.ts b/server/tests/unit/nest/travel-stats.service.test.ts index d34b43623..d24d9289d 100644 --- a/server/tests/unit/nest/travel-stats.service.test.ts +++ b/server/tests/unit/nest/travel-stats.service.test.ts @@ -44,10 +44,19 @@ afterAll(() => { testDb.close(); }); // ── getTravelStats — dashboard passport card ──────────────────────────────── describe('getTravelStats', () => { - function endpoint(reservationId: number, role: 'from' | 'to' | 'stop', sequence: number, lat: number, lng: number) { + function endpoint( + reservationId: number, + role: 'from' | 'to' | 'stop', + sequence: number, + lat: number, + lng: number, + code: string | null = null, + localDate: string | null = null, + localTime: string | null = null + ) { testDb.prepare( - 'INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, lat, lng) VALUES (?, ?, ?, ?, ?, ?)' - ).run(reservationId, role, sequence, `Endpoint ${sequence}`, lat, lng); + 'INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, lat, lng, code, local_date, local_time) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)' + ).run(reservationId, role, sequence, `Endpoint ${sequence}`, lat, lng, code, localDate, localTime); } // Every trip below is dated in the past: since #1048 the passport card only counts @@ -149,6 +158,51 @@ describe('getTravelStats', () => { expect(stats.countries).not.toContain('JP'); }); + // ── #1535: the layover split over two bookings ───────────────────────────── + // The passport card derives its countries from the same endpoints, so it needs the + // same pairing: a hub stored as the 'to' of one booking and the 'from' of the next + // slips past the #1486 role filter. Brussels → Helsinki → New York. + + function splitChainThroughHelsinki(tripId: number, onwardDate: string, onwardTime: string) { + const inbound = createReservation(testDb, tripId, { type: 'flight', title: 'BRU-HEL' }); + endpoint(inbound.id, 'from', 0, 50.9014, 4.4844, 'BRU', '2023-05-01', '07:00'); + endpoint(inbound.id, 'to', 1, 60.3172, 24.9633, 'HEL', '2023-05-01', '09:30'); + const onward = createReservation(testDb, tripId, { type: 'flight', title: 'HEL-JFK' }); + endpoint(onward.id, 'from', 0, 60.3172, 24.9633, 'HEL', onwardDate, onwardTime); + endpoint(onward.id, 'to', 1, 40.6413, -73.7781, 'JFK', onwardDate, '15:00'); + } + + it('AUTH-DB-099: #1535 a plane change booked as two flights does not stamp the hub country', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: 'New York via Helsinki', start_date: PAST_START, end_date: PAST_END }); + splitChainThroughHelsinki(trip.id, '2023-05-01', '11:00'); + + const stats = atlas.getTravelStats(user.id); + expect(stats.countries).toContain('BE'); + expect(stats.countries).toContain('US'); + expect(stats.countries).not.toContain('FI'); + }); + + it('AUTH-DB-100: #1535 a stopover of two days still stamps the hub country', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: 'Helsinki stopover', start_date: PAST_START, end_date: PAST_END }); + splitChainThroughHelsinki(trip.id, '2023-05-03', '11:00'); + + expect(atlas.getTravelStats(user.id).countries).toContain('FI'); + }); + + it('AUTH-DB-101: #1490 removing a country still subtracts it around the layover pairing', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: 'New York via Helsinki', start_date: PAST_START, end_date: PAST_END }); + splitChainThroughHelsinki(trip.id, '2023-05-01', '11:00'); + + atlas.unmarkCountry(user.id, 'BE'); + + const stats = atlas.getTravelStats(user.id); + expect(stats.countries).not.toContain('BE'); + expect(stats.countries).toContain('US'); + }); + it('AUTH-DB-097: #1048 a manually marked country stays stamped regardless of trip dates', () => { // The manual list is the user's own word, not derived from a trip — the date // filter must not reach it.