mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
724c88c201
A mechanical prettier --write over server/src, server/tests, server/scripts and shared/src with the workspaces' .prettierrc; nothing but layout and import order changes, and every file keeps the same set of imports. shared is formatted throughout and its format baseline is empty. The server baseline keeps the 35 files that Prettier would grow past their lint:size limit (they need a split first, which is not a formatting change), and the generated entity files stay as their generator writes them.
177 lines
6.7 KiB
TypeScript
177 lines
6.7 KiB
TypeScript
/**
|
|
* 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<Awaited<ReturnType<AtlasService['getTravelStats']>>> = {}) => ({
|
|
countries: ['JP', 'IT'],
|
|
cities: ['tokyo', 'kyoto', 'rome'],
|
|
coords: [],
|
|
totalTrips: 4,
|
|
totalDays: 31,
|
|
totalPlaces: 52,
|
|
totalDistanceKm: 18402,
|
|
...o,
|
|
});
|
|
|
|
function ctl(atlas: Partial<AtlasService> = {}, 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<AtlasService>).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<AtlasService>).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<AtlasService>).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<AtlasService>).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<AtlasService>).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<AtlasService>).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);
|
|
});
|
|
});
|