Files
TREK/server/tests/unit/nest/travel-stats.service.test.ts
T
Maurice 9de9719a02 test(server): seed the share, quota, photo and stats specs through the factories
Converts the raw prepare() fixtures and reads in the share and place shadow
e2e suites, the google quota, travel stats and MCP guard services, the
memories MCP spec, the admin seeder spec and the assignment participants
repository spec to the ORM factories and readers.

The place shadow e2e moves from hand-rolled tables to the migrated snapshot
with its users pinned to the ids their session cookies are signed for. The
share e2e drops the raw canAccessTrip it still put on the database mock:
nothing has read it since trip access moved onto the repositories. The
quota spec computes yesterday and the retention window in UTC, the same
calendar day SQLite's date('now') gives. The participants oracle keeps its
raw statement behind an allow marker.
2026-10-08 18:46:47 +02:00

359 lines
17 KiB
TypeScript

/**
* travel-stats.service.test.ts
*
* getTravelStats lives on AtlasService, but its cases get their own file rather
* than joining atlas.service.test.ts. That file carries the admin1 GeoJSON
* bundle warm-up in beforeAll (issue #48) and its ATLAS-UNIT-021..024 cases sit
* close to the 15s limit under a fully parallel run; adding eighteen more cases
* to the same file pushed them over. Same DB-per-file pattern, no shared state.
*
* The cases moved from auth.service.test.ts with the method; AUTH-DB-* case IDs
* are preserved so the history stays greppable.
*/
vi.mock('../../../src/db/database', async () => {
const { createSnapshotTestDb } = await import('../../helpers/db-mock');
const db = createSnapshotTestDb();
const mock = { db, closeDb: () => {}, reinitialize: () => {}, canAccessTrip: () => undefined, isOwner: () => false };
return mock;
});
import { db as testDb } from '../../../src/db/database';
import { describe, it, expect, beforeAll, beforeEach, afterAll, vi } from 'vitest';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createTrip, createPlace, createReservation, addTripMember } from '../../helpers/factories';
import { AtlasService } from '../../../src/nest/atlas/atlas.service';
import { createTestUnitOfWork, createTestTripsRepo, createTestPlacesRepo, createTestReservationEndpointsRepo, sharedTestOrm } from '../../helpers/test-uow';
import { insertRow, upsertRow } from '../../helpers/factories/rows';
import { addReservationTraveler, makeReservationEndpoint } from '../../helpers/factories/reservations';
import { PlaceRegions } from '../../../src/db/entities/PlaceRegions.entity';
import { Places } from '../../../src/db/entities/Places.entity';
import { VisitedCountries } from '../../../src/db/entities/VisitedCountries.entity';
import {
createTestBucketListRepo,
createTestHiddenCountriesRepo,
createTestHiddenRegionsRepo,
createTestVisitedCountriesRepo,
createTestVisitedRegionsRepo,
createTestPlaceRegionsRepo,
} from '../../helpers/atlas-repos';
let atlas: AtlasService;
beforeAll(async () => {
atlas = new AtlasService(
await createTestBucketListRepo(testDb),
await createTestHiddenCountriesRepo(testDb),
await createTestHiddenRegionsRepo(testDb),
await createTestVisitedCountriesRepo(testDb),
await createTestVisitedRegionsRepo(testDb),
await createTestPlaceRegionsRepo(testDb),
await createTestTripsRepo(testDb),
await createTestPlacesRepo(testDb),
await createTestReservationEndpointsRepo(testDb),
await createTestUnitOfWork(testDb),
(await sharedTestOrm(testDb)).orm,
);
});
beforeEach(() => { resetTestDb(testDb); vi.clearAllMocks(); });
afterAll(() => { testDb.close(); });
// ---------------------------------------------------------------------------
// getTravelStats — moved here from auth.service.test.ts together with the
// method itself. Case IDs preserved so the history stays greppable.
// ---------------------------------------------------------------------------
// ── getTravelStats — dashboard passport card ────────────────────────────────
describe('getTravelStats', () => {
async 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
) {
await makeReservationEndpoint(await sharedTestOrm(testDb), reservationId, {
role, sequence, name: `Endpoint ${sequence}`, lat, lng, code, local_date: localDate, local_time: localTime,
});
}
// Every trip below is dated in the past: since #1048 the passport card only counts
// countries from trips that have already started, so a dateless fixture would make
// these role-filter/tombstone assertions vacuous.
const PAST_START = '2023-05-01';
const PAST_END = '2023-05-10';
it('AUTH-DB-047: #1486 counts the from/to countries of a flight', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Tokyo Trip', start_date: PAST_START, end_date: PAST_END });
const res = createReservation(testDb, trip.id, { type: 'flight' });
await endpoint(res.id, 'from', 0, 50.9014, 4.4844); // Brussels
await endpoint(res.id, 'to', 1, 35.6762, 139.6503); // Tokyo
const stats = await atlas.getTravelStats(user.id);
expect(stats.countries).toContain('BE');
expect(stats.countries).toContain('JP');
});
it('AUTH-DB-048: #1486 a connecting-flight layover does NOT count as visited', async () => {
// The Atlas query grew a role filter for #1486 but this copy of it did not, so the
// dashboard passport card still counted a plane change as a visited country.
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Connection Trip', start_date: PAST_START, end_date: PAST_END });
const res = createReservation(testDb, trip.id, { type: 'flight' });
await endpoint(res.id, 'from', 0, 50.9014, 4.4844); // Brussels
await endpoint(res.id, 'stop', 1, 35.6762, 139.6503); // Tokyo — never leaves the airport
await endpoint(res.id, 'to', 2, -33.8688, 151.2093); // Sydney
const stats = await atlas.getTravelStats(user.id);
expect(stats.countries).toContain('BE');
expect(stats.countries).toContain('AU');
expect(stats.countries).not.toContain('JP');
});
it('AUTH-DB-049: #1490 a country removed in Atlas is not counted on the dashboard either', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Tokyo Trip', start_date: PAST_START, end_date: PAST_END });
const res = createReservation(testDb, trip.id, { type: 'flight' });
await endpoint(res.id, 'from', 0, 50.9014, 4.4844);
await endpoint(res.id, 'to', 1, 35.6762, 139.6503);
expect((await atlas.getTravelStats(user.id)).countries).toContain('JP');
await atlas.unmarkCountry(user.id, 'JP');
const after = await atlas.getTravelStats(user.id);
expect(after.countries).not.toContain('JP');
expect(after.countries).toContain('BE');
});
// ── #1048: the passport card only stamps trips that have happened ──────────
// Relative offsets, not literal dates — a hardcoded "future" date expires.
const iso = (days: number) => new Date(Date.now() + days * 86_400_000).toISOString().slice(0, 10);
async function placeInRegion(tripId: number, countryCode: string, regionCode: string) {
const place = createPlace(testDb, tripId, { name: `Place in ${countryCode}` });
await upsertRow(await sharedTestOrm(testDb), PlaceRegions, {
place: place.id, country_code: countryCode, region_code: regionCode, region_name: regionCode,
});
}
it('AUTH-DB-094: #1048 a place in a future trip does not stamp its country; a past trip does', async () => {
const { user } = createUser(testDb);
const past = createTrip(testDb, user.id, { title: 'Paris, last month', start_date: iso(-40), end_date: iso(-30) });
const future = createTrip(testDb, user.id, { title: 'Tokyo, next month', start_date: iso(30), end_date: iso(40) });
await placeInRegion(past.id, 'FR', 'FR-75');
await placeInRegion(future.id, 'JP', 'JP-13');
const stats = await atlas.getTravelStats(user.id);
expect(stats.countries).toContain('FR');
expect(stats.countries).not.toContain('JP');
});
it('AUTH-DB-095: #1048 a trip with no dates at all stamps nothing', async () => {
const { user } = createUser(testDb);
const dateless = createTrip(testDb, user.id, { title: 'Someday: Japan' });
await placeInRegion(dateless.id, 'JP', 'JP-13');
const res = createReservation(testDb, dateless.id, { type: 'flight' });
await endpoint(res.id, 'from', 0, 50.9014, 4.4844); // Brussels
const stats = await atlas.getTravelStats(user.id);
expect(stats.countries).toEqual([]);
});
it('AUTH-DB-096: #1048 a flight booked for a future trip does not stamp its endpoints', async () => {
const { user } = createUser(testDb);
const future = createTrip(testDb, user.id, { title: 'Tokyo, next month', start_date: iso(30), end_date: iso(40) });
const res = createReservation(testDb, future.id, { type: 'flight' });
await endpoint(res.id, 'from', 0, 50.9014, 4.4844); // Brussels
await endpoint(res.id, 'to', 1, 35.6762, 139.6503); // Tokyo
const stats = await atlas.getTravelStats(user.id);
expect(stats.countries).not.toContain('BE');
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.
async function splitChainThroughHelsinki(tripId: number, onwardDate: string, onwardTime: string) {
const inbound = createReservation(testDb, tripId, { type: 'flight', title: 'BRU-HEL' });
await endpoint(inbound.id, 'from', 0, 50.9014, 4.4844, 'BRU', '2023-05-01', '07:00');
await 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' });
await endpoint(onward.id, 'from', 0, 60.3172, 24.9633, 'HEL', onwardDate, onwardTime);
await 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', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'New York via Helsinki', start_date: PAST_START, end_date: PAST_END });
await splitChainThroughHelsinki(trip.id, '2023-05-01', '11:00');
const stats = await 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', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Helsinki stopover', start_date: PAST_START, end_date: PAST_END });
await splitChainThroughHelsinki(trip.id, '2023-05-03', '11:00');
expect((await atlas.getTravelStats(user.id)).countries).toContain('FI');
});
it('AUTH-DB-101: #1490 removing a country still subtracts it around the layover pairing', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'New York via Helsinki', start_date: PAST_START, end_date: PAST_END });
await splitChainThroughHelsinki(trip.id, '2023-05-01', '11:00');
await atlas.unmarkCountry(user.id, 'BE');
const stats = await 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', async () => {
// The manual list is the user's own word, not derived from a trip — the date
// filter must not reach it.
const { user } = createUser(testDb);
const future = createTrip(testDb, user.id, { title: 'Tokyo, next month', start_date: iso(30), end_date: iso(40) });
await placeInRegion(future.id, 'JP', 'JP-13');
await insertRow(await sharedTestOrm(testDb), VisitedCountries, { user: user.id, country_code: 'JP' });
const stats = await atlas.getTravelStats(user.id);
expect(stats.countries).toContain('JP');
});
});
describe('travel-stats quirk fixes', () => {
it('AUTH-DB-089: getTravelStats keeps a place at lat 0 / lng 0 (equator, prime meridian)', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Null Island' });
await insertRow(await sharedTestOrm(testDb), Places, { trip: trip.id, name: 'Null Island', lat: 0, lng: 0 });
const stats = await atlas.getTravelStats(user.id);
expect(stats.coords).toContainEqual({ lat: 0, lng: 0 });
});
});
/**
* Whose trip it is, and whose flight (#1966).
*
* 4.0 lets a booking name the people it is for (#1517), and the figures never
* read that: they scoped by trip membership, so on a shared trip everyone was
* credited with everyone's flights. Two people flying to the same place from
* different cities each got both departures, and the countries each of them had
* "visited" included the other's.
*
* The rule has a second half that matters more than the first: a booking with
* nobody named on it still counts for everyone. Every reservation made before
* 4.0 looks exactly like that, so without it this would have taken the flown
* distance of every existing install to zero.
*/
describe('personal figures on a shared trip (#1966)', () => {
const PAST_START = '2023-05-01';
const PAST_END = '2023-05-10';
const endpoint = async (reservationId: number, role: 'from' | 'to', sequence: number, lat: number, lng: number, code: string) =>
makeReservationEndpoint(await sharedTestOrm(testDb), reservationId, {
role, sequence, name: `Endpoint ${sequence}`, lat, lng, code,
});
const assignTo = async (reservationId: number, userId: number) =>
addReservationTraveler(await sharedTestOrm(testDb), reservationId, userId);
/** A shared trip: A flies Rome→New York, B flies Mexico City→New York. */
async function sharedTrip() {
const { user: alice } = createUser(testDb);
const { user: bob } = createUser(testDb);
const trip = createTrip(testDb, alice.id, { title: 'Shared', start_date: PAST_START, end_date: PAST_END });
addTripMember(testDb, trip.id, bob.id);
const fromRome = createReservation(testDb, trip.id, { type: 'flight', title: 'FCO-JFK' });
await endpoint(fromRome.id, 'from', 0, 41.8003, 12.2389, 'FCO');
await endpoint(fromRome.id, 'to', 1, 40.6413, -73.7781, 'JFK');
const fromMexico = createReservation(testDb, trip.id, { type: 'flight', title: 'MEX-JFK' });
await endpoint(fromMexico.id, 'from', 0, 19.4363, -99.0721, 'MEX');
await endpoint(fromMexico.id, 'to', 1, 40.6413, -73.7781, 'JFK');
return { alice, bob, trip, fromRome, fromMexico };
}
it('credits each traveler with their own flight only', async () => {
const { alice, bob, fromRome, fromMexico } = await sharedTrip();
await assignTo(fromRome.id, alice.id);
await assignTo(fromMexico.id, bob.id);
const a = await atlas.getTravelStats(alice.id);
const b = await atlas.getTravelStats(bob.id);
// Rome to New York is a good deal further than Mexico City to New York, so
// equal distances would mean both are still counting both flights.
expect(a.totalDistanceKm).toBeGreaterThan(0);
expect(b.totalDistanceKm).toBeGreaterThan(0);
expect(a.totalDistanceKm).not.toBe(b.totalDistanceKm);
});
it('does not stamp another traveler s departure country on your passport', async () => {
const { alice, bob, fromRome, fromMexico } = await sharedTrip();
await assignTo(fromRome.id, alice.id);
await assignTo(fromMexico.id, bob.id);
// Alice never went through Mexico.
expect((await atlas.getTravelStats(alice.id)).countries).not.toContain('MX');
expect((await atlas.getTravelStats(bob.id)).countries).not.toContain('IT');
});
/*
* The case that keeps this from being a breaking change. The assignment table
* is empty on every install upgrading from 3.4.1.
*/
it('still counts a booking nobody is named on, for everyone on the trip', async () => {
const { alice, bob } = await sharedTrip();
const a = await atlas.getTravelStats(alice.id);
const b = await atlas.getTravelStats(bob.id);
expect(a.totalDistanceKm).toBeGreaterThan(0);
expect(a.totalDistanceKm).toBe(b.totalDistanceKm);
});
it('counts a booking they are both named on, once for each of them', async () => {
const { alice, bob, fromRome, fromMexico } = await sharedTrip();
await assignTo(fromRome.id, alice.id);
await assignTo(fromRome.id, bob.id);
await assignTo(fromMexico.id, alice.id);
await assignTo(fromMexico.id, bob.id);
const a = await atlas.getTravelStats(alice.id);
const b = await atlas.getTravelStats(bob.id);
expect(a.totalDistanceKm).toBe(b.totalDistanceKm);
expect(a.countries).toEqual(b.countries);
});
it('leaves a traveler assigned nothing with the unassigned bookings only', async () => {
const { alice, bob, fromRome } = await sharedTrip();
await assignTo(fromRome.id, bob.id);
// Alice keeps the Mexico flight (nobody named) but loses the Rome one.
const a = await atlas.getTravelStats(alice.id);
expect(a.countries).not.toContain('IT');
expect(a.totalDistanceKm).toBeGreaterThan(0);
});
});