diff --git a/server/src/nest/auth/auth.service.ts b/server/src/nest/auth/auth.service.ts index 83263d862..ef225c70f 100644 --- a/server/src/nest/auth/auth.service.ts +++ b/server/src/nest/auth/auth.service.ts @@ -943,6 +943,9 @@ export class AuthService { ); manualCountries.forEach(m => { if (m.country_code) countryCodes.add(m.country_code.toUpperCase()); }); + // Only trips that have already started count as visited — a country you have merely + // booked a trip to isn't stamped in the passport yet, and one you jotted down without + // any dates even less so (#1048). date('now') is UTC, matching tripVisitStatus. const placeRegionCodes = this.db.all<{ country_code: string }>(` SELECT DISTINCT pr.country_code FROM place_regions pr @@ -950,6 +953,8 @@ export class AuthService { JOIN trips t ON p.trip_id = t.id LEFT JOIN trip_members tm ON t.id = tm.trip_id WHERE (t.user_id = ? OR tm.user_id = ?) AND pr.country_code IS NOT NULL + AND COALESCE(t.start_date, t.end_date) IS NOT NULL + AND COALESCE(t.start_date, t.end_date) <= date('now') `, userId, userId); placeRegionCodes.forEach(r => { if (r.country_code) countryCodes.add(r.country_code.toUpperCase()); }); @@ -966,6 +971,8 @@ export class AuthService { JOIN trips t ON r.trip_id = t.id LEFT JOIN trip_members tm ON t.id = tm.trip_id WHERE (t.user_id = ? OR tm.user_id = ?) AND e.role IN ('from', 'to') + 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 code = getCountryFromCoords(e.lat, e.lng); diff --git a/server/tests/unit/nest/auth.service.test.ts b/server/tests/unit/nest/auth.service.test.ts index c3145af94..2ade03f20 100644 --- a/server/tests/unit/nest/auth.service.test.ts +++ b/server/tests/unit/nest/auth.service.test.ts @@ -72,7 +72,7 @@ import { describe, it, expect, beforeAll, beforeEach, afterAll, vi } from 'vites import { createTables } from '../../../src/db/schema'; import { runMigrations } from '../../../src/db/migrations'; import { resetTestDb } from '../../helpers/test-db'; -import { createUser, createAdmin, createInviteToken, createTrip, createReservation } from '../../helpers/factories'; +import { createUser, createAdmin, createInviteToken, createTrip, createPlace, createReservation } from '../../helpers/factories'; import { AuthService } from '../../../src/nest/auth/auth.service'; import * as authBridge from '../../../src/nest/auth/auth.bridge'; import { AtlasService } from '../../../src/nest/atlas/atlas.service'; @@ -751,9 +751,15 @@ describe('getTravelStats', () => { ).run(reservationId, role, sequence, `Endpoint ${sequence}`, lat, lng); } + // 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', () => { const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id, { title: 'Tokyo Trip' }); + 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' }); endpoint(res.id, 'from', 0, 50.9014, 4.4844); // Brussels endpoint(res.id, 'to', 1, 35.6762, 139.6503); // Tokyo @@ -767,7 +773,7 @@ describe('getTravelStats', () => { // 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' }); + 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' }); endpoint(res.id, 'from', 0, 50.9014, 4.4844); // Brussels endpoint(res.id, 'stop', 1, 35.6762, 139.6503); // Tokyo — never leaves the airport @@ -781,7 +787,7 @@ describe('getTravelStats', () => { it('AUTH-DB-049: #1490 a country removed in Atlas is not counted on the dashboard either', () => { const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id, { title: 'Tokyo Trip' }); + 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' }); endpoint(res.id, 'from', 0, 50.9014, 4.4844); endpoint(res.id, 'to', 1, 35.6762, 139.6503); @@ -794,6 +800,68 @@ describe('getTravelStats', () => { 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); + + function placeInRegion(tripId: number, countryCode: string, regionCode: string) { + const place = createPlace(testDb, tripId, { name: `Place in ${countryCode}` }); + testDb + .prepare('INSERT OR REPLACE INTO place_regions (place_id, country_code, region_code, region_name) VALUES (?, ?, ?, ?)') + .run(place.id, countryCode, regionCode, regionCode); + } + + it('AUTH-DB-094: #1048 a place in a future trip does not stamp its country; a past trip does', () => { + 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) }); + placeInRegion(past.id, 'FR', 'FR-75'); + placeInRegion(future.id, 'JP', 'JP-13'); + + const stats = svc.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', () => { + const { user } = createUser(testDb); + const dateless = createTrip(testDb, user.id, { title: 'Someday: Japan' }); + placeInRegion(dateless.id, 'JP', 'JP-13'); + const res = createReservation(testDb, dateless.id, { type: 'flight' }); + endpoint(res.id, 'from', 0, 50.9014, 4.4844); // Brussels + + const stats = svc.getTravelStats(user.id); + + expect(stats.countries).toEqual([]); + }); + + it('AUTH-DB-096: #1048 a flight booked for a future trip does not stamp its endpoints', () => { + 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' }); + endpoint(res.id, 'from', 0, 50.9014, 4.4844); // Brussels + endpoint(res.id, 'to', 1, 35.6762, 139.6503); // Tokyo + + const stats = svc.getTravelStats(user.id); + + expect(stats.countries).not.toContain('BE'); + expect(stats.countries).not.toContain('JP'); + }); + + 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. + const { user } = createUser(testDb); + const future = createTrip(testDb, user.id, { title: 'Tokyo, next month', start_date: iso(30), end_date: iso(40) }); + placeInRegion(future.id, 'JP', 'JP-13'); + testDb.prepare('INSERT INTO visited_countries (user_id, country_code) VALUES (?, ?)').run(user.id, 'JP'); + + const stats = svc.getTravelStats(user.id); + + expect(stats.countries).toContain('JP'); + }); });