/** * Atlas e2e — drives /api/addons/atlas through the REAL JwtAuthGuard AND the * real DI-native AtlasService (DatabaseModule + AtlasModule) against a temp * SQLite db (full schema). No service mock (the legacy path-mock died with the * fold): auth, status codes (mark POSTs stay 200, bucket create stays 201), * cache headers, the bespoke 400/404 bodies, and the SQL effects are all real. * countries/geo serves the real bundled admin-0 gz. Seeded data stays * coordinate-free so the background place_regions geocode never fires. */ import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; import request from 'supertest'; import cookieParser from 'cookie-parser'; import type { Server } from 'http'; import { Test } from '@nestjs/testing'; import { sessionCookie } from './harness'; vi.mock('../../src/db/database', async () => { const { createSnapshotTestDb } = await import('../helpers/db-mock'); const db = createSnapshotTestDb(); return { db, closeDb: () => {}, reinitialize: () => {}, getPlaceWithTags: () => null, canAccessTrip: () => undefined, isOwner: () => false, }; }); vi.mock('../../src/websocket', () => ({ broadcastToUser: vi.fn(), broadcast: vi.fn() })); import { db } from '../../src/db/database'; import { createUser, createTrip } from '../helpers/factories'; import { AtlasModule } from '../../src/nest/atlas/atlas.module'; import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe'; import { TestUnitOfWorkModule } from '../helpers/test-uow'; import { createTestMikroOrmModule, createTestOrm, type TestOrm } from '../helpers/test-orm'; import { countRows, deleteRows, findRow, upsertRow } from '../helpers/factories/rows'; import { markCountryVisited } from '../helpers/factories/atlas'; import { makePlace } from '../helpers/factories/places'; import { makeReservation, makeReservationEndpoint } from '../helpers/factories/reservations'; import { makeMcpToken } from '../helpers/factories/tokens'; import { BucketList } from '../../src/db/entities/BucketList.entity'; import { HiddenCountries } from '../../src/db/entities/HiddenCountries.entity'; import { PlaceRegions } from '../../src/db/entities/PlaceRegions.entity'; import { VisitedCountries } from '../../src/db/entities/VisitedCountries.entity'; import { VisitedRegions } from '../../src/db/entities/VisitedRegions.entity'; describe('Atlas e2e (real auth guard + real service + temp SQLite)', () => { let server: Server; let app: Awaited>; let userId: number; let orm: TestOrm; async function build() { const moduleRef = await Test.createTestingModule({ imports: [await TestUnitOfWorkModule.forRoot(db), await createTestMikroOrmModule(db), AtlasModule] }).compile(); const nest = moduleRef.createNestApplication(); nest.use(cookieParser()); nest.useGlobalFilters(new TrekExceptionFilter()); // Mirror the production APP_PIPE (app.module.ts): DTO-typed bodies validate // by metatype, exactly as they do under buildApp(). nest.useGlobalPipes(new ZodValidationPipe()); await nest.init(); return nest; } beforeAll(async () => { userId = createUser(db as never, { username: 'atlas-e2e', email: 'atlas-e2e@test.example' }).user.id; orm = await createTestOrm(db); app = await build(); server = app.getHttpServer(); }); afterAll(async () => { await app.close(); await orm.close(); }); it('401 without a session cookie', async () => { const res = await request(server).get('/api/addons/atlas/stats'); expect(res.status).toBe(401); }); it('200 countries/geo serves the bundled gzipped admin-0 that the client decompresses to a FeatureCollection', async () => { const res = await request(server).get('/api/addons/atlas/countries/geo').set('Cookie', sessionCookie(userId)); expect(res.status).toBe(200); expect(res.headers['content-encoding']).toBe('gzip'); // superagent transparently decompresses, mirroring the browser. expect(res.body.type).toBe('FeatureCollection'); expect(res.body.features.length).toBeGreaterThan(0); expect(res.headers['cache-control']).toContain('max-age=86400'); }); it('200 stats for an authenticated user — the trip-less branch keeps its bespoke shape', async () => { await markCountryVisited(orm, userId, 'JP'); const res = await request(server).get('/api/addons/atlas/stats').set('Cookie', sessionCookie(userId)); expect(res.status).toBe(200); // Zero-trip early return: has a `trips` key and only the four base stats — // no totalCities/mostVisited/continents/… (preserved quirk). expect(res.body).toEqual({ countries: [{ code: 'JP', placeCount: 0, tripCount: 0, firstVisit: null, lastVisit: null }], trips: [], stats: { totalTrips: 0, totalPlaces: 0, totalCountries: 1, totalDays: 0 }, }); await deleteRows(orm, VisitedCountries, { user: userId }); }); it('200 (not 201) on POST country mark, with upper-cased code written to the db', async () => { const res = await request(server).post('/api/addons/atlas/country/de/mark').set('Cookie', sessionCookie(userId)); expect(res.status).toBe(200); expect(res.body).toEqual({ success: true }); const row = await findRow(orm, VisitedCountries, { user: userId }); expect(row?.country_code).toBe('DE'); await deleteRows(orm, VisitedCountries, { user: userId }); await deleteRows(orm, HiddenCountries, { user: userId }); }); it('400 on region mark without country_code (ZodValidationPipe envelope)', async () => { const res = await request(server).post('/api/addons/atlas/region/by/mark').set('Cookie', sessionCookie(userId)).send({ name: 'Bavaria' }); expect(res.status).toBe(400); // The legacy hand-rolled 'name and country_code are required' body became // the pipe's `field: message` envelope with the atlas DTO ratchet. expect(res.body.error).toMatch(/^country_code: /); expect(await countRows(orm, VisitedRegions, { user: userId })).toBe(0); }); it("400 'Name is required' on whitespace-only bucket name (legacy trim guard survives the DTO)", async () => { const res = await request(server).post('/api/addons/atlas/bucket-list').set('Cookie', sessionCookie(userId)).send({ name: ' ' }); expect(res.status).toBe(400); expect(res.body).toEqual({ error: 'Name is required' }); }); it('no-store cache header on /regions', async () => { const res = await request(server).get('/api/addons/atlas/regions').set('Cookie', sessionCookie(userId)); expect(res.status).toBe(200); expect(res.body).toEqual({ regions: {} }); expect(res.headers['cache-control']).toBe('no-cache, no-store'); }); it('empty FeatureCollection (no cache header) when /regions/geo has no countries', async () => { const res = await request(server).get('/api/addons/atlas/regions/geo').set('Cookie', sessionCookie(userId)); expect(res.status).toBe(200); expect(res.body).toEqual({ type: 'FeatureCollection', features: [] }); expect(res.headers['cache-control']).toBeUndefined(); }); it('201 on bucket-list create, row persisted', async () => { const res = await request(server).post('/api/addons/atlas/bucket-list').set('Cookie', sessionCookie(userId)).send({ name: 'Kyoto' }); expect(res.status).toBe(201); expect(res.body.item.name).toBe('Kyoto'); const row = await findRow(orm, BucketList, { id: res.body.item.id }); expect({ name: row?.name, user_id: row?.user_id }).toEqual({ name: 'Kyoto', user_id: userId }); }); it('keeps a wished-for region, and refuses one outside the item country (#1901)', async () => { const ok = await request(server).post('/api/addons/atlas/bucket-list').set('Cookie', sessionCookie(userId)) .send({ name: 'Bayern', country_code: 'DE', region_code: 'DE-BY' }); expect(ok.status).toBe(201); expect(ok.body.item.region_code).toBe('DE-BY'); const bad = await request(server).post('/api/addons/atlas/bucket-list').set('Cookie', sessionCookie(userId)) .send({ name: 'Berlin', country_code: 'FR', region_code: 'DE-BE' }); expect(bad.status).toBe(400); }); it('404 on delete of a missing bucket item', async () => { const res = await request(server).delete('/api/addons/atlas/bucket-list/999').set('Cookie', sessionCookie(userId)); expect(res.status).toBe(404); expect(res.body).toEqual({ error: 'Item not found' }); }); // #1048 — the whole point of the feature over the real wire: a booked-but-not-taken // trip must still reach the client (so the map can draw it) without counting as a // visit. Runs on its own user so the trip-less pin above stays trip-less. it('200 stats keeps a future trip out of the visited count but still ships it as planned', async () => { const plannerId = createUser(db as never, { username: 'atlas-planner', email: 'atlas-planner@test.example' }).user.id; // Offsets from today, not literal dates — a hardcoded future date expires. const iso = (days: number) => new Date(Date.now() + days * 86_400_000).toISOString().slice(0, 10); const past = createTrip(db as never, plannerId, { title: 'Rome, last month', start_date: iso(-40), end_date: iso(-30) }); const future = createTrip(db as never, plannerId, { title: 'Tokyo, next month', start_date: iso(30), end_date: iso(40) }); // Address-only (no lat/lng), keeping the file's no-background-geocode property. const unplaced = { lat: null, lng: null, category: null }; await makePlace(orm, past.id, { ...unplaced, name: 'Colosseum', address: 'Piazza del Colosseo, Rome, Italy' }); await makePlace(orm, future.id, { ...unplaced, name: 'Senso-ji', address: 'Asakusa, Tokyo, Japan' }); const res = await request(server).get('/api/addons/atlas/stats').set('Cookie', sessionCookie(plannerId)); expect(res.status).toBe(200); const byCode = Object.fromEntries((res.body.countries as { code: string }[]).map((c) => [c.code, c])); // #1535: the dates run from the start to the end of the trip, not start to start. expect(byCode['IT']).toMatchObject({ status: 'visited', firstVisit: iso(-40), lastVisit: iso(-30) }); expect(byCode['JP']).toMatchObject({ status: 'planned', firstVisit: iso(30), lastVisit: iso(40) }); expect(res.body.stats.totalCountries).toBe(1); expect(res.body.stats.totalCountriesPlanned).toBe(1); expect(res.body.countries.length).toBeGreaterThan(res.body.stats.totalCountries); expect(res.body.continents).toEqual({ Europe: 1 }); expect(res.body.continentsPlanned).toEqual({ Asia: 1 }); // The country sheet agrees with the map. const jp = await request(server).get('/api/addons/atlas/country/jp').set('Cookie', sessionCookie(plannerId)); 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 = async (title: string) => (await makeReservation(orm, trip.id, { title, type: 'flight' })).id; const insertEndpoint = ( reservationId: number, role: string, sequence: number, name: string, lat: number, lng: number, code: string, localDate: string, localTime: string, ) => makeReservationEndpoint(orm, reservationId, { role, sequence, name, lat, lng, code, local_date: localDate, local_time: localTime }); const inbound = await insertFlight('BRU-HEL'); await insertEndpoint(inbound, 'from', 0, 'Brussels', 50.9014, 4.4844, 'BRU', day, '07:00'); await insertEndpoint(inbound, 'to', 1, 'Helsinki', 60.3172, 24.9633, 'HEL', day, '09:30'); const onward = await insertFlight('HEL-JFK'); await insertEndpoint(onward, 'from', 0, 'Helsinki', 60.3172, 24.9633, 'HEL', day, '11:00'); await insertEndpoint(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 () => { // Rome. Italy has admin1 coverage in the bundle, so both come back. const res = await request(server) .get('/api/addons/atlas/locate?lat=41.9028&lng=12.4964') .set('Cookie', sessionCookie(userId)); expect(res.status).toBe(200); expect(res.body.country_code).toBe('IT'); expect(typeof res.body.region_code === 'string' || res.body.region_code === null).toBe(true); }); it('answers with nulls out at sea rather than failing', async () => { const res = await request(server) .get('/api/addons/atlas/locate?lat=0&lng=-140') .set('Cookie', sessionCookie(userId)); expect(res.status).toBe(200); expect(res.body).toEqual({ country_code: null, region_code: null, region_name: null }); }); it('rejects a missing or out-of-range coordinate', async () => { for (const query of ['', '?lat=41.9', '?lat=abc&lng=12.5', '?lat=91&lng=12.5', '?lat=41.9&lng=181']) { const res = await request(server).get(`/api/addons/atlas/locate${query}`).set('Cookie', sessionCookie(userId)); expect(res.status).toBe(400); expect(res.body).toEqual({ error: 'Valid lat and lng are required' }); } }); it('needs a session like every other atlas route', async () => { expect((await request(server).get('/api/addons/atlas/locate?lat=41.9&lng=12.5')).status).toBe(401); }); }); // ── GET /api/v1/stats (#1367) ───────────────────────────────────────────── // // Mounted from this module rather than public-api/ because its figures are // AtlasService's; see the controller's docblock. Exercised here because this // is the suite that already boots the real AtlasModule against a full schema — // the public-api e2e builds a hand-rolled minimal one and could not. describe('public API stats', () => { async function mintApiKey(uid: number, kind = 'api'): Promise { // eslint-disable-next-line @typescript-eslint/no-require-imports const { randomBytes } = require('crypto'); const raw = 'trek_' + randomBytes(24).toString('hex'); await makeMcpToken(orm, uid, { rawToken: raw, name: `key-${kind}-${uid}`, kind }); return raw; } it('401 without a key, and a session cookie is not a substitute', async () => { expect((await request(server).get('/api/v1/stats')).status).toBe(401); // The whole point of a machine credential: the browser session does not open it. const res = await request(server).get('/api/v1/stats').set('Cookie', sessionCookie(userId)); expect(res.status).toBe(401); expect(res.body).toEqual({ error: 'API token required', code: 'API_TOKEN_REQUIRED' }); }); it('401 for an MCP token — the wrong kind is indistinguishable from no token', async () => { const mcpKey = await mintApiKey(userId, 'mcp'); const res = await request(server).get('/api/v1/stats').set('X-API-Key', mcpKey); expect(res.status).toBe(401); expect(res.body).toEqual({ error: 'Invalid API token', code: 'API_TOKEN_INVALID' }); }); it('200 with counts and the last started trip, over both header spellings', async () => { const { user } = createUser(db as never, { username: 'stats-e2e', email: 'stats-e2e@test.example' }); const key = await mintApiKey(user.id); const iso = (offset: number) => new Date(Date.now() + offset * 86400000).toISOString().slice(0, 10); createTrip(db as never, user.id, { title: 'Older', start_date: iso(-90), end_date: iso(-80) }); const recent = createTrip(db as never, user.id, { title: 'Recent', start_date: iso(-40), end_date: iso(-30) }); createTrip(db as never, user.id, { title: 'Booked', start_date: iso(30), end_date: iso(40) }); // In the first category, with no coordinates of its own. const placeId = (await makePlace(orm, recent.id, { name: 'Trevi', address: 'Piazza di Trevi, 00187, Roma, Italy', lat: null, lng: null })).id; await upsertRow(orm, PlaceRegions, { place: placeId, country_code: 'IT', region_code: 'IT-62', region_name: 'Lazio' }); const res = await request(server).get('/api/v1/stats').set('Authorization', `Bearer ${key}`); expect(res.status).toBe(200); expect(res.body).toMatchObject({ total_trips: 3, total_countries: 1, total_places: 1, last_trip: { title: 'Recent', country: 'IT', countries: ['IT'] }, next_trip: { title: 'Booked', start_date: iso(30), end_date: iso(40), days_until: 30, country: null, countries: [] }, }); // Scalars all the way — a widget maps fields, it cannot aggregate a list. for (const k of ['total_trips', 'total_countries', 'total_cities', 'total_places', 'total_days', 'total_distance_km']) { expect(typeof res.body[k]).toBe('number'); } const viaHeader = await request(server).get('/api/v1/stats').set('X-API-Key', key); expect(viaHeader.body).toEqual(res.body); }); it('counts only the caller own trips', async () => { const { user: stranger } = createUser(db as never, { username: 'stats-other', email: 'stats-other@test.example' }); const key = await mintApiKey(stranger.id); const res = await request(server).get('/api/v1/stats').set('X-API-Key', key); expect(res.status).toBe(200); expect(res.body).toMatchObject({ total_trips: 0, total_countries: 0, last_trip: null, next_trip: null }); }); }); });