From e8d4278899598d0a81baf8dfa7451a4bf0000a9b Mon Sep 17 00:00:00 2001 From: Maurice Date: Sun, 9 Aug 2026 21:31:28 +0200 Subject: [PATCH] Give travel stats to the domain whose data it reads getTravelStats sat on AuthService for historical reasons only. It reads places, trips, visited_countries, place_regions and reservation_endpoints, and subtracts the countries hidden in Atlas, so every one of its sources is an Atlas concern. Keeping it there had a real cost: AuthModule had to import AtlasModule, and that edge is the reason atlas.mcp.ts reaches AuthService through auth.bridge instead of injecting it. The direction is now free to reverse. GET /api/auth/travel-stats keeps its path. The client calls it there, so the route moves to a small controller in atlas/ that holds the /api/auth prefix rather than changing the URL. PROFILE-014 runs against the full app and proves the endpoint is untouched. KNOWN_COUNTRIES moves along, since getTravelStats was its only reader. Both bridges construct AuthService by hand and needed the argument dropped. --- server/src/nest/admin/admin.bridge.ts | 3 +- server/src/nest/atlas/atlas.mcp.ts | 11 +- server/src/nest/atlas/atlas.module.ts | 7 +- server/src/nest/atlas/atlas.service.ts | 142 +++++++++++++++++ server/src/nest/atlas/known-countries.ts | 34 ++++ .../src/nest/atlas/travel-stats.controller.ts | 34 ++++ server/src/nest/auth/auth.bridge.ts | 12 +- server/src/nest/auth/auth.controller.ts | 7 +- server/src/nest/auth/auth.helpers.ts | 27 ---- server/src/nest/auth/auth.module.ts | 15 +- server/src/nest/auth/auth.service.ts | 150 ++---------------- .../tests/unit/nest/atlas.controller.test.ts | 51 +++++- server/tests/unit/nest/atlas.service.test.ts | 138 +++++++++++++++- .../tests/unit/nest/auth.controller.test.ts | 6 +- server/tests/unit/nest/auth.service.test.ts | 133 ---------------- 15 files changed, 442 insertions(+), 328 deletions(-) create mode 100644 server/src/nest/atlas/known-countries.ts create mode 100644 server/src/nest/atlas/travel-stats.controller.ts diff --git a/server/src/nest/admin/admin.bridge.ts b/server/src/nest/admin/admin.bridge.ts index 490a20d91..a1d3d81cf 100644 --- a/server/src/nest/admin/admin.bridge.ts +++ b/server/src/nest/admin/admin.bridge.ts @@ -5,7 +5,6 @@ import { AddonsService } from '../addons/addons.service'; import { SettingsService } from '../settings/settings.service'; import { AuthService } from '../auth/auth.service'; import { PasskeyService } from '../auth/passkey.service'; -import { AtlasService } from '../atlas/atlas.service'; import { UserCleanupService } from '../auth/user-cleanup.service'; import { WebauthnConfigService } from '../auth/webauthn-config.service'; import { TripMembershipService } from '../trip-membership/trip-membership.service'; @@ -41,7 +40,7 @@ const webauthn = new WebauthnConfigService(dbs); const userCleanup = new UserCleanupService(dbs); const mailer = new MailerService(dbs); const notifPrefs = new NotificationPreferencesService(dbs, mailer); -const auth = new AuthService(dbs, permissions, new AtlasService(dbs), new TripMembershipService(dbs), webauthn, userCleanup, mailer); +const auth = new AuthService(dbs, permissions, new TripMembershipService(dbs), webauthn, userCleanup, mailer); const admin = new AdminService( dbs, new AddonsService(dbs), diff --git a/server/src/nest/atlas/atlas.mcp.ts b/server/src/nest/atlas/atlas.mcp.ts index ad7134c17..b793b4bc6 100644 --- a/server/src/nest/atlas/atlas.mcp.ts +++ b/server/src/nest/atlas/atlas.mcp.ts @@ -5,9 +5,14 @@ import { demoDenied, ok, } from '@trek/nest-mcp'; import { z } from 'zod'; -// auth.bridge, not an injected AuthService: AuthService injects AtlasService -// (getTravelStats), so AtlasModule importing AuthModule would close a module -// cycle — same documented trade-off as places.mcp.ts keeping assignments.bridge. +// auth.bridge, not an injected AuthService. The module cycle that used to force +// this is gone (getTravelStats moved to AtlasService, so AuthModule no longer +// imports AtlasModule), but swapping one call site for a direct AuthModule +// import would pull the whole auth chain into every graph that touches Atlas, +// to reach a single isDemoUser. Eight other *.mcp.ts have the same shape with +// addons.bridge; they get resolved together once the nest-mcp registry can +// resolve a `when:` predicate lazily, at registry.attach instead of at module +// load. Same documented trade-off as places.mcp.ts keeping assignments.bridge. import { isDemoUser } from '../auth/auth.bridge'; import { isAddonEnabled } from '../addons/addons.bridge'; import { ADDON_IDS } from '../../addons'; diff --git a/server/src/nest/atlas/atlas.module.ts b/server/src/nest/atlas/atlas.module.ts index 9b13ade00..a44756d63 100644 --- a/server/src/nest/atlas/atlas.module.ts +++ b/server/src/nest/atlas/atlas.module.ts @@ -1,5 +1,6 @@ import { Module } from '@nestjs/common'; import { AtlasController } from './atlas.controller'; +import { TravelStatsController } from './travel-stats.controller'; import { AtlasService } from './atlas.service'; import { AtlasRpc } from './atlas.rpc'; import { PluginGuardsModule } from '../plugins/host/plugin-guards.module'; @@ -10,10 +11,14 @@ import { AtlasMcp } from './atlas.mcp'; * AtlasService for the plugin RPC surface (AtlasRpc injects it). * AtlasMcp is a provider (not a controller) — the nest-mcp registry discovers * it after app.init(). + * + * TravelStatsController serves GET /api/auth/travel-stats. The path stays where + * the client expects it; the code sits here because getTravelStats reads Atlas + * data. See the comment in that file. */ @Module({ imports: [PluginGuardsModule], - controllers: [AtlasController], + controllers: [AtlasController, TravelStatsController], providers: [AtlasService, AtlasMcp, AtlasRpc], exports: [AtlasService], }) diff --git a/server/src/nest/atlas/atlas.service.ts b/server/src/nest/atlas/atlas.service.ts index c0e900364..9d6178bef 100644 --- a/server/src/nest/atlas/atlas.service.ts +++ b/server/src/nest/atlas/atlas.service.ts @@ -10,6 +10,8 @@ import { resolveCountryCodeSync, reverseGeocodeRegion, } from './atlas-geo'; +import { KNOWN_COUNTRIES } from './known-countries'; +import { haversineKm } from '../common/geo'; export type CreateBucketData = { name: string; @@ -778,4 +780,144 @@ export class AtlasService { this.db.prepare('DELETE FROM bucket_list WHERE id = ? AND user_id = ?').run(itemId, userId); return true; } + + // --------------------------------------------------------------------------- + // Travel stats + // + // Moved here from AuthService, which owned it only for historical reasons. It + // reads places/trips/visited_countries/place_regions and subtracts the same + // hidden countries the Atlas map hides, so every one of its data sources is + // already an Atlas concern. The move is what lets AuthModule drop its + // AtlasModule import: that edge was the reason atlas.mcp.ts had to reach + // AuthService through auth.bridge instead of injecting it. + // + // The route keeps its path (GET /api/auth/travel-stats) and is served by + // travel-stats.controller.ts in this directory. + // --------------------------------------------------------------------------- + + getTravelStats(userId: number) { + const places = this.db.all<{ address: string | null; lat: number | null; lng: number | null }>(` + SELECT DISTINCT p.address, p.lat, p.lng + FROM places p + 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 = ? + `, userId, userId); + + // Archived trips still count here, matching the places, countries and flight + // distance widgets (which never filtered on is_archived) so the dashboard stats + // stay consistent — archiving a trip no longer zeroes out trips/days. + const tripStats = this.db.get<{ trips: number; days: number }>(` + SELECT COUNT(DISTINCT t.id) as trips, + COUNT(DISTINCT d.id) as days + FROM trips t + LEFT JOIN days d ON d.trip_id = t.id + LEFT JOIN trip_members tm ON t.id = tm.trip_id + WHERE (t.user_id = ? OR tm.user_id = ?) + `, userId, userId); + + const cities = new Set(); + const coords: { lat: number; lng: number }[] = []; + + places.forEach(p => { + // Explicit null checks: lat/lng of exactly 0 (equator / prime meridian) + // are valid coordinates the former falsy check silently dropped. + if (p.lat != null && p.lng != null) coords.push({ lat: p.lat, lng: p.lng }); + if (p.address) { + const parts = p.address.split(',').map(s => s.trim().replace(/\d{3,}/g, '').trim()); + const cityPart = parts.find(s => !KNOWN_COUNTRIES.has(s) && /^[A-Za-z\u00C0-\u00FF\s-]{2,}$/.test(s)); + if (cityPart) cities.add(cityPart); + } + }); + + // Visited countries \u2014 same source the Atlas page uses: ISO-2 codes from + // auto-resolved place regions plus countries the user marked manually. + const countryCodes = new Set(); + const manualCountries = this.db.all<{ country_code: string }>( + 'SELECT country_code FROM visited_countries WHERE user_id = ?', + userId + ); + 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 + JOIN places p ON p.id = pr.place_id + 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()); }); + + // Transport bookings don't create a place row, so their geocoded endpoints never + // reached place_regions — a country reached only by a flight/train (no lodging or + // 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 + FROM reservation_endpoints e + JOIN reservations r ON e.reservation_id = r.id + 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); + if (code) countryCodes.add(code.toUpperCase()); + } + + // Countries the user removed in Atlas stay removed on the dashboard too, so the + // passport card and the Atlas map agree (#1490). + for (const code of this.getHiddenCountries(userId)) countryCodes.delete(code.toUpperCase()); + + return { + countries: [...countryCodes], + cities: [...cities], + coords, + totalTrips: tripStats?.trips || 0, + totalDays: tripStats?.days || 0, + totalPlaces: places.length, + 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); + } } diff --git a/server/src/nest/atlas/known-countries.ts b/server/src/nest/atlas/known-countries.ts new file mode 100644 index 000000000..73c267079 --- /dev/null +++ b/server/src/nest/atlas/known-countries.ts @@ -0,0 +1,34 @@ +/** + * Country names, English and German, used to tell a city apart from a country + * when splitting a free-text place address. Moved here from auth.helpers.ts + * together with getTravelStats, its only reader. + * + * Deliberately not ISO codes: it matches against whatever the geocoder wrote + * into places.address, which is display text in the user's language. + */ +export const KNOWN_COUNTRIES = new Set([ + 'Japan', 'Germany', 'Deutschland', 'France', 'Frankreich', 'Italy', 'Italien', 'Spain', 'Spanien', + 'United States', 'USA', 'United Kingdom', 'UK', 'Thailand', 'Australia', 'Australien', + 'Canada', 'Kanada', 'Mexico', 'Mexiko', 'Brazil', 'Brasilien', 'China', 'India', 'Indien', + 'South Korea', 'Sudkorea', 'Indonesia', 'Indonesien', 'Turkey', 'Turkei', 'Turkiye', + 'Greece', 'Griechenland', 'Portugal', 'Netherlands', 'Niederlande', 'Belgium', 'Belgien', + 'Switzerland', 'Schweiz', 'Austria', 'Osterreich', 'Sweden', 'Schweden', 'Norway', 'Norwegen', + 'Denmark', 'Danemark', 'Finland', 'Finnland', 'Poland', 'Polen', 'Czech Republic', 'Tschechien', + 'Czechia', 'Hungary', 'Ungarn', 'Croatia', 'Kroatien', 'Romania', 'Rumanien', + 'Ireland', 'Irland', 'Iceland', 'Island', 'New Zealand', 'Neuseeland', + 'Singapore', 'Singapur', 'Malaysia', 'Vietnam', 'Philippines', 'Philippinen', + 'Egypt', 'Agypten', 'Morocco', 'Marokko', 'South Africa', 'Sudafrika', 'Kenya', 'Kenia', + 'Argentina', 'Argentinien', 'Chile', 'Colombia', 'Kolumbien', 'Peru', + 'Russia', 'Russland', 'United Arab Emirates', 'UAE', 'Vereinigte Arabische Emirate', + 'Israel', 'Jordan', 'Jordanien', 'Taiwan', 'Hong Kong', 'Hongkong', + 'Cuba', 'Kuba', 'Costa Rica', 'Panama', 'Ecuador', 'Bolivia', 'Bolivien', 'Uruguay', 'Paraguay', + 'Luxembourg', 'Luxemburg', 'Malta', 'Cyprus', 'Zypern', 'Estonia', 'Estland', + 'Latvia', 'Lettland', 'Lithuania', 'Litauen', 'Slovakia', 'Slowakei', 'Slovenia', 'Slowenien', + 'Bulgaria', 'Bulgarien', 'Serbia', 'Serbien', 'Montenegro', 'Albania', 'Albanien', + 'Sri Lanka', 'Nepal', 'Cambodia', 'Kambodscha', 'Laos', 'Myanmar', 'Mongolia', 'Mongolei', + 'Saudi Arabia', 'Saudi-Arabien', 'Qatar', 'Katar', 'Oman', 'Bahrain', 'Kuwait', + 'Tanzania', 'Tansania', 'Ethiopia', 'Athiopien', 'Nigeria', 'Ghana', 'Tunisia', 'Tunesien', + 'Dominican Republic', 'Dominikanische Republik', 'Jamaica', 'Jamaika', + 'Ukraine', 'Georgia', 'Georgien', 'Armenia', 'Armenien', 'Pakistan', 'Bangladesh', 'Bangladesch', + 'Senegal', 'Mozambique', 'Mosambik', 'Moldova', 'Moldawien', 'Belarus', 'Weissrussland', +]); diff --git a/server/src/nest/atlas/travel-stats.controller.ts b/server/src/nest/atlas/travel-stats.controller.ts new file mode 100644 index 000000000..17a23a36f --- /dev/null +++ b/server/src/nest/atlas/travel-stats.controller.ts @@ -0,0 +1,34 @@ +import { Controller, Get, UseGuards } from '@nestjs/common'; +import { AtlasService } from './atlas.service'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { CurrentUser } from '../auth/current-user.decorator'; +import type { User } from '../../types'; + +/** + * GET /api/auth/travel-stats — the dashboard's passport/stats card. + * + * The path is deliberately unchanged and deliberately not under /api/addons/atlas: + * the client calls it, so moving it would be a breaking change. What moved is the + * ownership. getTravelStats reads places, trips, visited_countries and + * place_regions and subtracts the countries hidden in Atlas, so it belongs to + * AtlasService; keeping it on AuthService forced AuthModule to import AtlasModule, + * and that edge is what made atlas.mcp.ts reach AuthService through auth.bridge. + * + * An /api/auth prefix on a controller outside auth/ is unusual enough to note: + * it is the price of holding the public path steady while the code finds its + * right home. Nest is fine with two controllers sharing a prefix as long as the + * routes below it are distinct, and travel-stats exists exactly once. + * + * JwtAuthGuard and CurrentUser are plain class imports here, the same way + * atlas.controller.ts already uses them. Neither pulls in a module edge. + */ +@Controller('api/auth') +@UseGuards(JwtAuthGuard) +export class TravelStatsController { + constructor(private readonly atlas: AtlasService) {} + + @Get('travel-stats') + travelStats(@CurrentUser() user: User) { + return this.atlas.getTravelStats(user.id); + } +} diff --git a/server/src/nest/auth/auth.bridge.ts b/server/src/nest/auth/auth.bridge.ts index f112f1fd4..eb0ee4b16 100644 --- a/server/src/nest/auth/auth.bridge.ts +++ b/server/src/nest/auth/auth.bridge.ts @@ -1,7 +1,6 @@ import { db } from '../../db/database'; import { DatabaseService } from '../database/database.service'; import { PermissionsService } from '../permissions/permissions.service'; -import { AtlasService } from '../atlas/atlas.service'; import { TripMembershipService } from '../trip-membership/trip-membership.service'; import { AuthService } from './auth.service'; import { MailerService } from '../notifications/mailer/mailer.service'; @@ -15,10 +14,12 @@ import { User } from '../../types'; * the legacy tool registrars (mcp/tools/journey|notifications|transports), the * not-yet-migrated services/adminService, and — as the one in-container * exception — - * atlas.mcp.ts, which cannot inject AuthService without closing an - * AuthModule↔AtlasModule cycle (AuthService injects AtlasService for - * getTravelStats; same documented trade-off as places.mcp.ts keeping its - * assignments.bridge imports). + * atlas.mcp.ts. The cycle that used to make that mandatory is gone + * (getTravelStats moved to AtlasService, so AuthModule no longer imports + * AtlasModule); what keeps it here is cost, not correctness: a direct import + * would drag the whole auth chain into every Atlas consumer for one + * isDemoUser call. Same open trade-off as places.mcp.ts keeping its + * assignments.bridge imports. * * Exports the legacy services/authService names 1:1 so repointing a consumer * is an import-path-only diff. Inside the container, inject AuthService @@ -39,7 +40,6 @@ const permissions = new PermissionsService(dbs); const auth = new AuthService( dbs, permissions, - new AtlasService(dbs), new TripMembershipService(dbs), new WebauthnConfigService(dbs), new UserCleanupService(dbs), diff --git a/server/src/nest/auth/auth.controller.ts b/server/src/nest/auth/auth.controller.ts index c1b11012c..553667318 100644 --- a/server/src/nest/auth/auth.controller.ts +++ b/server/src/nest/auth/auth.controller.ts @@ -192,10 +192,9 @@ export class AuthController { return { success: true }; } - @Get('travel-stats') - travelStats(@CurrentUser() user: User) { - return this.auth.getTravelStats(user.id); - } + // GET travel-stats moved to atlas/travel-stats.controller.ts. Same path, same + // guard, same response — only the owner changed, so AuthModule can drop its + // AtlasModule import. @Post('mfa/setup') @HttpCode(200) diff --git a/server/src/nest/auth/auth.helpers.ts b/server/src/nest/auth/auth.helpers.ts index fe646d357..5b34bb11b 100644 --- a/server/src/nest/auth/auth.helpers.ts +++ b/server/src/nest/auth/auth.helpers.ts @@ -45,33 +45,6 @@ export const ADMIN_SETTINGS_KEYS = [ export const avatarDir = path.join(__dirname, '../../../uploads/avatars'); if (!fs.existsSync(avatarDir)) fs.mkdirSync(avatarDir, { recursive: true }); -export const KNOWN_COUNTRIES = new Set([ - 'Japan', 'Germany', 'Deutschland', 'France', 'Frankreich', 'Italy', 'Italien', 'Spain', 'Spanien', - 'United States', 'USA', 'United Kingdom', 'UK', 'Thailand', 'Australia', 'Australien', - 'Canada', 'Kanada', 'Mexico', 'Mexiko', 'Brazil', 'Brasilien', 'China', 'India', 'Indien', - 'South Korea', 'Sudkorea', 'Indonesia', 'Indonesien', 'Turkey', 'Turkei', 'Turkiye', - 'Greece', 'Griechenland', 'Portugal', 'Netherlands', 'Niederlande', 'Belgium', 'Belgien', - 'Switzerland', 'Schweiz', 'Austria', 'Osterreich', 'Sweden', 'Schweden', 'Norway', 'Norwegen', - 'Denmark', 'Danemark', 'Finland', 'Finnland', 'Poland', 'Polen', 'Czech Republic', 'Tschechien', - 'Czechia', 'Hungary', 'Ungarn', 'Croatia', 'Kroatien', 'Romania', 'Rumanien', - 'Ireland', 'Irland', 'Iceland', 'Island', 'New Zealand', 'Neuseeland', - 'Singapore', 'Singapur', 'Malaysia', 'Vietnam', 'Philippines', 'Philippinen', - 'Egypt', 'Agypten', 'Morocco', 'Marokko', 'South Africa', 'Sudafrika', 'Kenya', 'Kenia', - 'Argentina', 'Argentinien', 'Chile', 'Colombia', 'Kolumbien', 'Peru', - 'Russia', 'Russland', 'United Arab Emirates', 'UAE', 'Vereinigte Arabische Emirate', - 'Israel', 'Jordan', 'Jordanien', 'Taiwan', 'Hong Kong', 'Hongkong', - 'Cuba', 'Kuba', 'Costa Rica', 'Panama', 'Ecuador', 'Bolivia', 'Bolivien', 'Uruguay', 'Paraguay', - 'Luxembourg', 'Luxemburg', 'Malta', 'Cyprus', 'Zypern', 'Estonia', 'Estland', - 'Latvia', 'Lettland', 'Lithuania', 'Litauen', 'Slovakia', 'Slowakei', 'Slovenia', 'Slowenien', - 'Bulgaria', 'Bulgarien', 'Serbia', 'Serbien', 'Montenegro', 'Albania', 'Albanien', - 'Sri Lanka', 'Nepal', 'Cambodia', 'Kambodscha', 'Laos', 'Myanmar', 'Mongolia', 'Mongolei', - 'Saudi Arabia', 'Saudi-Arabien', 'Qatar', 'Katar', 'Oman', 'Bahrain', 'Kuwait', - 'Tanzania', 'Tansania', 'Ethiopia', 'Athiopien', 'Nigeria', 'Ghana', 'Tunisia', 'Tunesien', - 'Dominican Republic', 'Dominikanische Republik', 'Jamaica', 'Jamaika', - 'Ukraine', 'Georgia', 'Georgien', 'Armenia', 'Armenien', 'Pakistan', 'Bangladesh', 'Bangladesch', - 'Senegal', 'Mozambique', 'Mosambik', 'Moldova', 'Moldawien', 'Belarus', 'Weissrussland', -]); - // --------------------------------------------------------------------------- // Helpers (exported for route-level use where needed) // --------------------------------------------------------------------------- diff --git a/server/src/nest/auth/auth.module.ts b/server/src/nest/auth/auth.module.ts index 18a8ebbc1..f611fd0c4 100644 --- a/server/src/nest/auth/auth.module.ts +++ b/server/src/nest/auth/auth.module.ts @@ -12,7 +12,6 @@ import { AppConfigModule } from '../app-config/app-config.module'; import { AuditModule } from '../audit/audit.module'; import { MailerModule } from '../notifications/mailer/mailer.module'; import { PermissionsModule } from '../permissions/permissions.module'; -import { AtlasModule } from '../atlas/atlas.module'; import { TripMembershipModule } from '../trip-membership/trip-membership.module'; /** @@ -21,10 +20,14 @@ import { TripMembershipModule } from '../trip-membership/trip-membership.module' * is a separate, not-yet-migrated route, so the strangler lists the auth * sub-paths explicitly rather than claiming all of /api/auth. * - * PermissionsModule feeds getAppConfig's permissions block; AtlasModule feeds - * getTravelStats' hidden-countries subtraction; MailerModule sends the - * password-reset mail. It is a leaf module of its own precisely so this import - * does not become AuthModule -> NotificationsModule -> AuthModule. AuthService is + * PermissionsModule feeds getAppConfig's permissions block; MailerModule sends + * the password-reset mail. It is a leaf module of its own precisely so this + * import does not become AuthModule -> NotificationsModule -> AuthModule. + * + * AtlasModule is deliberately absent. getTravelStats was the only reason for it + * and now belongs to AtlasService; the direction is reversed, so AtlasModule + * imports AuthModule and atlas.mcp.ts can inject AuthService instead of routing + * through auth.bridge. AuthService is * exported for the in-container consumers (the domain *.mcp.ts demo guards, * OidcService, PasskeyEnabledGuard); PasskeyService for AdminService's passkey * reset; UserCleanupService for the two account-deletion paths (AdminService) @@ -32,7 +35,7 @@ import { TripMembershipModule } from '../trip-membership/trip-membership.module' * through auth.bridge.ts. */ @Module({ - imports: [RateLimitModule, AuditModule, PermissionsModule, AtlasModule, TripMembershipModule, MailerModule, AppConfigModule], + imports: [RateLimitModule, AuditModule, PermissionsModule, TripMembershipModule, MailerModule, AppConfigModule], controllers: [AuthPublicController, AuthController, PasskeyController], providers: [AuthService, PasskeyService, UserCleanupService, WebauthnConfigService, AuthMcp], exports: [AuthService, PasskeyService, UserCleanupService], diff --git a/server/src/nest/auth/auth.service.ts b/server/src/nest/auth/auth.service.ts index 328bd6635..877332f5c 100644 --- a/server/src/nest/auth/auth.service.ts +++ b/server/src/nest/auth/auth.service.ts @@ -11,8 +11,6 @@ import { readEnv } from '../../app-config'; import { JWT_SECRET, SESSION_DURATION_SECONDS, SESSION_DURATION_REMEMBER_SECONDS } from '../../config'; import { DatabaseService } from '../database/database.service'; import { PermissionsService } from '../permissions/permissions.service'; -import { AtlasService } from '../atlas/atlas.service'; -import { getCountryFromCoords } from '../atlas/atlas-geo'; import { validatePassword } from '../common/passwordPolicy'; import { encryptMfaSecret, decryptMfaSecret } from '../common/crypto/mfaCrypto'; import { decrypt_api_key, maybe_encrypt_api_key, encrypt_api_key } from '../common/crypto/apiKeyCrypto'; @@ -24,7 +22,6 @@ import { revokeUserSessions } from '../../mcp/sessionManager'; import { startTripReminders } from '../../scheduler'; import { UserCleanupService } from './user-cleanup.service'; import { emitUserDeleted } from '../../plugin-user-lifecycle'; -import { haversineKm } from '../common/geo'; import { verifyJwtAndLoadUser } from './jwt-verify'; import { User } from '../../types'; import { DEMO_EMAIL_PRIMARY, isDemoEmail } from '../common/demo'; @@ -39,7 +36,6 @@ import { BCRYPT_COST, DUMMY_PASSWORD_HASH, EMAIL_REGEX, - KNOWN_COUNTRIES, avatarDir, generateBackupCodes, hashBackupCodeBcrypt, @@ -111,12 +107,15 @@ export interface ResetPasswordOutcome { * DI-native auth domain service. The SQL moved 1:1 from the legacy * src/services/authService.ts (same statements, same `||` falsy-coercion * defaults, same post-write re-selects, same error strings); the pure - * password/backup-code crypto lives in auth.helpers.ts. PermissionsService - * and AtlasService are injected (they replaced the permissions.bridge and - * atlas.bridge imports); the JWT cookie set/clear, the reset-email delivery - * and the remaining legacy helpers keep their plain imports. Non-Nest - * consumers (legacy MCP registrars, legacy adminService/oidcService/ - * passkeyService) go through auth.bridge.ts. + * password/backup-code crypto lives in auth.helpers.ts. PermissionsService is + * injected (it replaced the permissions.bridge import); the JWT cookie + * set/clear, the reset-email delivery and the remaining legacy helpers keep + * their plain imports. Non-Nest consumers (legacy MCP registrars, legacy + * adminService/oidcService/passkeyService) go through auth.bridge.ts. + * + * AtlasService is deliberately NOT injected any more: getTravelStats, its only + * reader, now lives on AtlasService itself. Dropping that edge is what lets + * AtlasModule import AuthModule instead of going the other way around. */ @Injectable() export class AuthService implements OnModuleInit, OnModuleDestroy { @@ -136,7 +135,6 @@ export class AuthService implements OnModuleInit, OnModuleDestroy { constructor( private readonly db: DatabaseService, private readonly permissions: PermissionsService, - private readonly atlas: AtlasService, private readonly membership: TripMembershipService, private readonly webauthn: WebauthnConfigService, private readonly userCleanup: UserCleanupService, @@ -912,136 +910,6 @@ export class AuthService implements OnModuleInit, OnModuleDestroy { return { success: true, auditSummary: summary, auditDebugDetails: debugDetails, shouldRestartScheduler }; } - // ------------------------------------------------------------------------- - // Travel stats - // ------------------------------------------------------------------------- - - getTravelStats(userId: number) { - const places = this.db.all<{ address: string | null; lat: number | null; lng: number | null }>(` - SELECT DISTINCT p.address, p.lat, p.lng - FROM places p - 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 = ? - `, userId, userId); - - // Archived trips still count here, matching the places, countries and flight - // distance widgets (which never filtered on is_archived) so the dashboard stats - // stay consistent — archiving a trip no longer zeroes out trips/days. - const tripStats = this.db.get<{ trips: number; days: number }>(` - SELECT COUNT(DISTINCT t.id) as trips, - COUNT(DISTINCT d.id) as days - FROM trips t - LEFT JOIN days d ON d.trip_id = t.id - LEFT JOIN trip_members tm ON t.id = tm.trip_id - WHERE (t.user_id = ? OR tm.user_id = ?) - `, userId, userId); - - const cities = new Set(); - const coords: { lat: number; lng: number }[] = []; - - places.forEach(p => { - // Explicit null checks: lat/lng of exactly 0 (equator / prime meridian) - // are valid coordinates the former falsy check silently dropped. - if (p.lat != null && p.lng != null) coords.push({ lat: p.lat, lng: p.lng }); - if (p.address) { - const parts = p.address.split(',').map(s => s.trim().replace(/\d{3,}/g, '').trim()); - const cityPart = parts.find(s => !KNOWN_COUNTRIES.has(s) && /^[A-Za-z\u00C0-\u00FF\s-]{2,}$/.test(s)); - if (cityPart) cities.add(cityPart); - } - }); - - // Visited countries \u2014 same source the Atlas page uses: ISO-2 codes from - // auto-resolved place regions plus countries the user marked manually. - const countryCodes = new Set(); - const manualCountries = this.db.all<{ country_code: string }>( - 'SELECT country_code FROM visited_countries WHERE user_id = ?', - userId - ); - 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 - JOIN places p ON p.id = pr.place_id - 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()); }); - - // Transport bookings don't create a place row, so their geocoded endpoints never - // reached place_regions — a country reached only by a flight/train (no lodging or - // 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 - FROM reservation_endpoints e - JOIN reservations r ON e.reservation_id = r.id - 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); - if (code) countryCodes.add(code.toUpperCase()); - } - - // Countries the user removed in Atlas stay removed on the dashboard too, so the - // passport card and the Atlas map agree (#1490). - for (const code of this.atlas.getHiddenCountries(userId)) countryCodes.delete(code.toUpperCase()); - - return { - countries: [...countryCodes], - cities: [...cities], - coords, - totalTrips: tripStats?.trips || 0, - totalDays: tripStats?.days || 0, - totalPlaces: places.length, - totalDistanceKm: this.flightDistanceKm(userId), - }; - } - - /** - * Total flight distance a user has covered, summed across every non-cancelled - * flight reservation in their trips. Each flight stores its waypoints in - * reservation_endpoints (from → stops → to, ordered by sequence); the legs - * between consecutive points are added up so multi-stop flights count - * correctly. - */ - private flightDistanceKm(userId: number): number { - const rows = this.db.all<{ reservation_id: number; lat: number; lng: number }>(` - SELECT re.reservation_id, re.lat, re.lng - FROM reservation_endpoints re - JOIN reservations r ON r.id = re.reservation_id - JOIN trips t ON t.id = r.trip_id - LEFT JOIN trip_members tm ON tm.trip_id = t.id AND tm.user_id = ? - WHERE (t.user_id = ? OR tm.user_id IS NOT NULL) - AND r.type = 'flight' - AND r.status != 'cancelled' - ORDER BY re.reservation_id, re.sequence - `, userId, userId); - - let total = 0; - let prev: { id: number; lat: number; lng: number } | null = null; - for (const point of rows) { - if (prev && prev.id === point.reservation_id) { - total += haversineKm(prev.lat, prev.lng, point.lat, point.lng); - } - prev = { id: point.reservation_id, lat: point.lat, lng: point.lng }; - } - return Math.round(total); - } - // ------------------------------------------------------------------------- // MFA // ------------------------------------------------------------------------- diff --git a/server/tests/unit/nest/atlas.controller.test.ts b/server/tests/unit/nest/atlas.controller.test.ts index 91450380f..20aa3652f 100644 --- a/server/tests/unit/nest/atlas.controller.test.ts +++ b/server/tests/unit/nest/atlas.controller.test.ts @@ -1,7 +1,11 @@ import { describe, it, expect, vi } from 'vitest'; -import { HttpException } from '@nestjs/common'; +import { HttpException, RequestMethod } from '@nestjs/common'; import type { Response } from 'express'; import { AtlasController } from '../../../src/nest/atlas/atlas.controller'; +import { TravelStatsController } from '../../../src/nest/atlas/travel-stats.controller'; +import { JwtAuthGuard } from '../../../src/nest/auth/jwt-auth.guard'; +import { AtlasModule } from '../../../src/nest/atlas/atlas.module'; +import { expectRegisteredController } from '../../helpers/module-providers'; import type { AtlasService } from '../../../src/nest/atlas/atlas.service'; import type { User } from '../../../src/types'; @@ -149,3 +153,48 @@ describe('AtlasController (parity with the legacy /api/addons/atlas route)', () }); }); }); + +// --------------------------------------------------------------------------- +// TravelStatsController — GET /api/auth/travel-stats. +// +// The path lives under /api/auth on purpose (the client calls it there and +// moving it would break); the code sits in atlas/ because getTravelStats reads +// Atlas data. These cases pin both halves: the delegation, and the fact that +// the controller keeps the auth prefix. +// --------------------------------------------------------------------------- + +describe('TravelStatsController', () => { + it('ATLAS-TRAVEL-001: delegates to AtlasService.getTravelStats with the caller id', () => { + const getTravelStats = vi.fn().mockReturnValue({ countries: ['JP'], totalTrips: 2 }); + const controller = new TravelStatsController({ getTravelStats } as unknown as AtlasService); + + expect(controller.travelStats(user)).toEqual({ countries: ['JP'], totalTrips: 2 }); + expect(getTravelStats).toHaveBeenCalledWith(8); + }); + + it('ATLAS-TRAVEL-002: passes the payload through untouched', () => { + const payload = { countries: [], cities: [], coords: [], totalTrips: 0, totalDays: 0, totalPlaces: 0, totalDistanceKm: 0 }; + const controller = new TravelStatsController({ getTravelStats: () => payload } as unknown as AtlasService); + + expect(controller.travelStats(user)).toBe(payload); + }); + + it('ATLAS-TRAVEL-003: still answers on /api/auth/travel-stats, so the move is not a breaking change', () => { + expect(Reflect.getMetadata('path', TravelStatsController)).toBe('api/auth'); + expect(Reflect.getMetadata('path', TravelStatsController.prototype.travelStats)).toBe('travel-stats'); + expect(Reflect.getMetadata('method', TravelStatsController.prototype.travelStats)).toBe(RequestMethod.GET); + }); + + it('ATLAS-TRAVEL-004: keeps the JwtAuthGuard the route had on AuthController', () => { + // Array shape asserted first on purpose: expect(undefined).toContain(SomeClass) + // passes in this vitest version, so the plain form would stop guarding the + // moment the decorator disappeared. Same reason module-providers.ts exists. + const guards = Reflect.getMetadata('__guards__', TravelStatsController) as unknown[] | undefined; + expect(Array.isArray(guards)).toBe(true); + expect(guards).toEqual(expect.arrayContaining([JwtAuthGuard])); + }); + + it('ATLAS-TRAVEL-005: AtlasModule registers the controller, so the route actually exists', () => { + expectRegisteredController(AtlasModule, TravelStatsController); + }); +}); diff --git a/server/tests/unit/nest/atlas.service.test.ts b/server/tests/unit/nest/atlas.service.test.ts index a54bbc857..19cbc3e1f 100644 --- a/server/tests/unit/nest/atlas.service.test.ts +++ b/server/tests/unit/nest/atlas.service.test.ts @@ -38,7 +38,7 @@ vi.mock('../../../src/db/database', () => dbMock); import { createTables } from '../../../src/db/schema'; import { runMigrations } from '../../../src/db/migrations'; import { resetTestDb } from '../../helpers/test-db'; -import { createUser, createTrip, createReservation } from '../../helpers/factories'; +import { createUser, createTrip, createReservation, createPlace } from '../../helpers/factories'; import { getCached, setCache, getCountryFromCoords, getCountryFromAddress, isPointInCountryBox, reverseGeocodeCountry, getRegionGeo, getCountryGeo } from '../../../src/nest/atlas/atlas-geo'; import { DatabaseService } from '../../../src/nest/database/database.service'; import { AtlasService } from '../../../src/nest/atlas/atlas.service'; @@ -1408,3 +1408,139 @@ describe('getVisitedRegions — status (#1048)', () => { expect(after.regions['FR'][0].status).toBe('visited'); }); }); + +// --------------------------------------------------------------------------- +// 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', () => { + function endpoint(reservationId: number, role: 'from' | 'to' | 'stop', sequence: number, lat: number, lng: number) { + testDb.prepare( + 'INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, lat, lng) VALUES (?, ?, ?, ?, ?, ?)' + ).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', 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 + + const stats = 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', () => { + // 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' }); + endpoint(res.id, 'from', 0, 50.9014, 4.4844); // Brussels + endpoint(res.id, 'stop', 1, 35.6762, 139.6503); // Tokyo — never leaves the airport + endpoint(res.id, 'to', 2, -33.8688, 151.2093); // Sydney + + const stats = 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', () => { + 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' }); + endpoint(res.id, 'from', 0, 50.9014, 4.4844); + endpoint(res.id, 'to', 1, 35.6762, 139.6503); + + expect(atlas.getTravelStats(user.id).countries).toContain('JP'); + + atlas.unmarkCountry(user.id, 'JP'); + + const after = 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); + + 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 = 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', () => { + 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 = 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', () => { + 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 = atlas.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 = 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)', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: 'Null Island' }); + testDb.prepare('INSERT INTO places (trip_id, name, lat, lng) VALUES (?, ?, ?, ?)').run(trip.id, 'Null Island', 0, 0); + const stats = atlas.getTravelStats(user.id); + expect(stats.coords).toContainEqual({ lat: 0, lng: 0 }); + }); +}); diff --git a/server/tests/unit/nest/auth.controller.test.ts b/server/tests/unit/nest/auth.controller.test.ts index 26bf82c72..99e84f2ed 100644 --- a/server/tests/unit/nest/auth.controller.test.ts +++ b/server/tests/unit/nest/auth.controller.test.ts @@ -282,14 +282,14 @@ describe('AuthController (authenticated)', () => { expect(ac(asvc({ getSettings: vi.fn().mockReturnValue({ settings: { theme: 'dark' } }) } as Partial), rl()).getSettings(user)).toEqual({ settings: { theme: 'dark' } }); }); - it('delete-avatar + users + travel-stats delegate to the service', async () => { + // travel-stats left with getTravelStats; it is covered by + // atlas.controller.test.ts (TravelStatsController). + it('delete-avatar + users delegate to the service', async () => { const deleteAvatar = vi.fn().mockResolvedValue({ removed: true }); expect(await ac(asvc({ deleteAvatar } as Partial), rl()).deleteAvatar(user)).toEqual({ removed: true }); const listUsers = vi.fn().mockReturnValue([{ id: 1 }]); expect(ac(asvc({ listUsers } as Partial), rl()).users(user)).toEqual({ users: [{ id: 1 }] }); expect(listUsers).toHaveBeenCalledWith(1); - const getTravelStats = vi.fn().mockReturnValue({ countries: 3 }); - expect(ac(asvc({ getTravelStats } as Partial), rl()).travelStats(user)).toEqual({ countries: 3 }); }); it('validate-keys maps error, else returns the maps/weather payload', async () => { diff --git a/server/tests/unit/nest/auth.service.test.ts b/server/tests/unit/nest/auth.service.test.ts index 47de3d991..2130db9da 100644 --- a/server/tests/unit/nest/auth.service.test.ts +++ b/server/tests/unit/nest/auth.service.test.ts @@ -73,7 +73,6 @@ import { resetTestDb } from '../../helpers/test-db'; 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'; import { PermissionsService } from '../../../src/nest/permissions/permissions.service'; import { DatabaseService } from '../../../src/nest/database/database.service'; import { verifyJwtAndLoadUser } from '../../../src/nest/auth/jwt-verify'; @@ -97,7 +96,6 @@ const membershipStub = { joinTripAsMember } as unknown as TripMembershipService; const svc = new AuthService( new DatabaseService(testDb), new PermissionsService(new DatabaseService(testDb)), - new AtlasService(new DatabaseService(testDb)), membershipStub, new WebauthnConfigService(new DatabaseService(testDb)), new UserCleanupService(new DatabaseService(testDb)), @@ -755,129 +753,6 @@ describe('MCP token service', () => { }); }); -// ── getTravelStats — dashboard passport card ──────────────────────────────── - -describe('getTravelStats', () => { - function endpoint(reservationId: number, role: 'from' | 'to' | 'stop', sequence: number, lat: number, lng: number) { - testDb.prepare( - 'INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, lat, lng) VALUES (?, ?, ?, ?, ?, ?)' - ).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', 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 - - const stats = svc.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', () => { - // 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' }); - endpoint(res.id, 'from', 0, 50.9014, 4.4844); // Brussels - endpoint(res.id, 'stop', 1, 35.6762, 139.6503); // Tokyo — never leaves the airport - endpoint(res.id, 'to', 2, -33.8688, 151.2093); // Sydney - - const stats = svc.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', () => { - 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' }); - endpoint(res.id, 'from', 0, 50.9014, 4.4844); - endpoint(res.id, 'to', 1, 35.6762, 139.6503); - - expect(svc.getTravelStats(user.id).countries).toContain('JP'); - - new AtlasService(new DatabaseService(testDb)).unmarkCountry(user.id, 'JP'); - - const after = svc.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); - - 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'); - }); -}); - - // --------------------------------------------------------------------------- // Coverage added with the DI fold (AUTH-DB-050+): the fold moved ~1400 lines // under the src/nest coverage gate, so the previously untested branches — @@ -1282,14 +1157,6 @@ describe('ephemeral + demo helpers', () => { // --------------------------------------------------------------------------- describe('auth quirk fixes', () => { - it('AUTH-DB-089: getTravelStats keeps a place at lat 0 / lng 0 (equator, prime meridian)', () => { - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id, { title: 'Null Island' }); - testDb.prepare('INSERT INTO places (trip_id, name, lat, lng) VALUES (?, ?, ?, ?)').run(trip.id, 'Null Island', 0, 0); - const stats = svc.getTravelStats(user.id); - expect(stats.coords).toContainEqual({ lat: 0, lng: 0 }); - }); - it('AUTH-DB-090: a throw mid-registration rolls the whole signup back (user + invite bookkeeping)', () => { const { user: owner } = createUser(testDb); const trip = createTrip(testDb, owner.id);