/** * GET /api/v1/stats — the aggregate widget endpoint (#1367). * * The interesting part is not the arithmetic, it is that the numbers come from * AtlasService rather than from a second count of their own: a widget showing 23 * countries next to a dashboard showing 25 is the bug this endpoint exists to * avoid. So the service is stubbed and the assertions pin the mapping — * `countries.length`, not a re-derivation. * * Rate limiting and the missing-user path are shared with PublicApiController * (public-api-request.ts); they are exercised here too because this controller * lives in another module and could stop calling them without anything failing. */ import type { AtlasService } from '../../../src/nest/atlas/atlas.service'; import { PublicStatsController } from '../../../src/nest/atlas/public-stats.controller'; import { RateLimitService } from '../../../src/nest/common/rate-limit.service'; import { PUBLIC_API_RATE_MAX_PER_MINUTE } from '../../../src/nest/public-api/public-api-request'; import type { User } from '../../../src/types'; import { HttpException } from '@nestjs/common'; import { PUBLIC_API_SCOPES, type PublicApiGrant } from '@trek/shared'; import type { Request } from 'express'; import { describe, it, expect, vi } from 'vitest'; // `null` rather than `undefined` for "no user": passing undefined would trip the // default parameter and hand back a request that still has one. // `apiToken` is the read grant the guard resolves alongside the user (#2279); // the full one here, so these cases still test what they always tested. const FULL_GRANT: PublicApiGrant = { mode: 'all', scopes: [...PUBLIC_API_SCOPES] }; const req = (userId: number | null = 7) => ({ headers: {}, user: userId === null ? undefined : ({ id: userId } as User), apiToken: FULL_GRANT, }) as Request; const travel = (o: Partial>> = {}) => ({ countries: ['JP', 'IT'], cities: ['tokyo', 'kyoto', 'rome'], coords: [], totalTrips: 4, totalDays: 31, totalPlaces: 52, totalDistanceKm: 18402, ...o, }); function ctl(atlas: Partial = {}, rl = new RateLimitService()) { return new PublicStatsController( { getTravelStats: vi.fn(() => travel()), lastTrip: vi.fn(() => null), nextTrip: vi.fn(() => null), ...atlas, } as unknown as AtlasService, rl, ); } async function thrown(fn: () => unknown): Promise<{ status: number; body: unknown }> { try { await fn(); } catch (err) { expect(err).toBeInstanceOf(HttpException); const e = err as HttpException; return { status: e.getStatus(), body: e.getResponse() }; } throw new Error('expected throw'); } describe('PublicStatsController', () => { it('PUBSTATS-001: reports counts, not the arrays behind them', async () => { expect(await ctl().stats(req())).toEqual({ total_trips: 4, total_countries: 2, total_cities: 3, total_places: 52, total_days: 31, total_distance_km: 18402, last_trip: null, next_trip: null, }); }); it('PUBSTATS-002: passes the authenticated user through to the service', async () => { const getTravelStats = vi.fn(() => travel()); const lastTrip = vi.fn(() => null); const nextTrip = vi.fn(() => null); await ctl({ getTravelStats, lastTrip, nextTrip } as unknown as Partial).stats(req(42)); expect(getTravelStats).toHaveBeenCalledWith(42); expect(lastTrip).toHaveBeenCalledWith(42); expect(nextTrip).toHaveBeenCalledWith(42); }); it('PUBSTATS-003: last_trip carries the dominant country as the head of the list', async () => { const lastTrip = vi.fn(() => ({ title: 'Interrail', start_date: '2026-03-01', end_date: '2026-03-12', countries: ['CZ', 'AT'], })); const out = await ctl({ lastTrip } as unknown as Partial).stats(req()); expect(out.last_trip).toEqual({ title: 'Interrail', start_date: '2026-03-01', end_date: '2026-03-12', country: 'CZ', countries: ['CZ', 'AT'], }); }); it('PUBSTATS-004: an ungeocoded last trip reports country null rather than a wrong one', async () => { const lastTrip = vi.fn(() => ({ title: 'Roadtrip', start_date: null, end_date: null, countries: [] })); const out = await ctl({ lastTrip } as unknown as Partial).stats(req()); expect(out.last_trip).toMatchObject({ country: null, countries: [] }); }); it('PUBSTATS-008: next_trip counts down and names its countries like last_trip (#2542)', async () => { const nextTrip = vi.fn(() => ({ title: 'Lisbon', start_date: '2026-11-12', end_date: '2026-11-16', days_until: 42, countries: ['PT', 'ES'], })); const out = await ctl({ nextTrip } as unknown as Partial).stats(req()); expect(out.next_trip).toEqual({ title: 'Lisbon', start_date: '2026-11-12', end_date: '2026-11-16', days_until: 42, country: 'PT', countries: ['PT', 'ES'], }); }); it('PUBSTATS-009: an ungeocoded next trip reports country null', async () => { const nextTrip = vi.fn(() => ({ title: 'Somewhere', start_date: '2026-12-01', end_date: null, days_until: 61, countries: [], })); const out = await ctl({ nextTrip } as unknown as Partial).stats(req()); expect(out.next_trip).toMatchObject({ country: null, countries: [], end_date: null }); }); it('PUBSTATS-005: shares one rate-limit budget with the rest of /api/v1', async () => { const rl = new RateLimitService(); const c = ctl({}, rl); for (let i = 0; i < PUBLIC_API_RATE_MAX_PER_MINUTE; i++) await c.stats(req()); expect(await thrown(() => c.stats(req()))).toEqual({ status: 429, body: { error: 'Too many requests. Please slow down.' }, }); // A different caller still gets their own budget. await expect(c.stats(req(8))).resolves.not.toThrow(); }); it('PUBSTATS-006: a request with no resolved user is a 401, not a crash', async () => { const getTravelStats = vi.fn(() => travel()); expect(await thrown(() => ctl({ getTravelStats } as unknown as Partial).stats(req(null)))).toEqual({ status: 401, body: { error: 'API token required', code: 'API_TOKEN_REQUIRED' }, }); // The guard is what should have stopped this; no data was read on the way out. expect(getTravelStats).not.toHaveBeenCalled(); }); it('PUBSTATS-007: the class is listed in its module controllers', async () => { const { AtlasModule } = await import('../../../src/nest/atlas/atlas.module'); const controllers = Reflect.getMetadata('controllers', AtlasModule) as unknown[]; expect(controllers).toContain(PublicStatsController); }); });