Files
TREK/server/tests/unit/nest/atlas.controller.test.ts
T
Maurice 724c88c201 style: format server and shared with prettier
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.
2026-10-10 21:23:37 +02:00

270 lines
12 KiB
TypeScript

import { AtlasController } from '../../../src/nest/atlas/atlas.controller';
import { BucketItemExistsError } from '../../../src/nest/atlas/atlas.service';
import type { AtlasService } from '../../../src/nest/atlas/atlas.service';
import { TravelStatsController } from '../../../src/nest/atlas/travel-stats.controller';
import { JwtAuthGuard } from '../../../src/nest/auth-core/jwt-auth.guard';
import type { User } from '../../../src/types';
import { HttpException, RequestMethod } from '@nestjs/common';
import type { Response } from 'express';
import { describe, it, expect, vi } from 'vitest';
const user = { id: 8 } as User;
function makeController(svc: Partial<AtlasService>) {
return new AtlasController(svc as AtlasService);
}
function makeRes() {
return { setHeader: vi.fn(), send: vi.fn(), json: vi.fn() } as unknown as Response & {
setHeader: ReturnType<typeof vi.fn>;
send: ReturnType<typeof vi.fn>;
json: ReturnType<typeof vi.fn>;
};
}
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 the handler to throw');
}
describe('AtlasController (parity with the legacy /api/addons/atlas route)', () => {
it('GET /stats delegates with the user id', async () => {
const stats = vi.fn().mockReturnValue({ countries: 3 });
expect(await makeController({ stats }).stats(user)).toEqual({ countries: 3 });
expect(stats).toHaveBeenCalledWith(8);
});
describe('GET /regions/geo', () => {
it('returns an empty FeatureCollection without a cache header when no countries given', async () => {
const regionGeo = vi.fn();
const res = makeRes();
const out = await makeController({ regionGeo }).regionGeo(undefined, res);
expect(out).toEqual({ type: 'FeatureCollection', features: [] });
expect(regionGeo).not.toHaveBeenCalled();
expect(res.setHeader).not.toHaveBeenCalled();
});
it('caches a non-empty result for a day', async () => {
const regionGeo = vi.fn().mockResolvedValue({ type: 'FeatureCollection', features: [{ id: 1 }] });
const res = makeRes();
const out = await makeController({ regionGeo }).regionGeo('DE,FR', res);
expect(out).toEqual({ type: 'FeatureCollection', features: [{ id: 1 }] });
expect(regionGeo).toHaveBeenCalledWith(['DE', 'FR']);
expect(res.setHeader).toHaveBeenCalledWith('Cache-Control', 'public, max-age=86400');
});
});
it('GET /countries/geo serves the gzipped admin-0 bundle with Content-Encoding', () => {
const gz = Buffer.from('gzipped-bytes');
const countryGeoGz = vi.fn().mockReturnValue(gz);
const res = makeRes();
makeController({ countryGeoGz }).countryGeo(res);
expect(res.setHeader).toHaveBeenCalledWith('Content-Encoding', 'gzip');
expect(res.setHeader).toHaveBeenCalledWith('Cache-Control', 'public, max-age=86400');
expect(res.send).toHaveBeenCalledWith(gz);
});
it('GET /countries/geo falls back to an empty FeatureCollection when the bundle is missing', () => {
const countryGeoGz = vi.fn().mockReturnValue(null);
const res = makeRes();
makeController({ countryGeoGz }).countryGeo(res);
expect(res.json).toHaveBeenCalledWith({ type: 'FeatureCollection', features: [] });
expect(res.setHeader).toHaveBeenCalledWith('Cache-Control', 'no-store');
});
describe('country', () => {
it('GET /country/:code upper-cases the code', async () => {
const countryPlaces = vi.fn().mockReturnValue([]);
await makeController({ countryPlaces }).countryPlaces(user, 'de');
expect(countryPlaces).toHaveBeenCalledWith(8, 'DE');
});
it('POST mark returns success and upper-cases', async () => {
const markCountry = vi.fn();
expect(await makeController({ markCountry }).markCountry(user, 'de')).toEqual({ success: true });
expect(markCountry).toHaveBeenCalledWith(8, 'DE');
});
it('DELETE mark returns success', async () => {
const unmarkCountry = vi.fn();
expect(await makeController({ unmarkCountry }).unmarkCountry(user, 'FR')).toEqual({ success: true });
});
});
describe('region', () => {
// The legacy 'name and country_code are required' 400 is now produced by
// the global ZodValidationPipe (atlas.dto.ts / markRegionRequestSchema)
// before the handler runs — covered by the e2e suite.
it('marks a region, upper-casing both codes', async () => {
const markRegion = vi.fn();
expect(
await makeController({ markRegion }).markRegion(user, 'by', { name: 'Bavaria', country_code: 'de' }),
).toEqual({ success: true });
expect(markRegion).toHaveBeenCalledWith(8, 'BY', 'Bavaria', 'DE');
});
});
describe('bucket list', () => {
it('GET wraps the items', async () => {
const bucketList = vi.fn().mockReturnValue([{ id: 1 }]);
expect(await makeController({ bucketList }).bucketList(user)).toEqual({ items: [{ id: 1 }] });
});
it('400 on create with a blank name', () => {
const createBucketItem = vi.fn();
return thrown(() => makeController({ createBucketItem }).createBucketItem(user, { name: ' ' })).then((r) =>
expect(r).toEqual({ status: 400, body: { error: 'Name is required' } }),
);
});
it('201-shape create returns { item }', async () => {
const createBucketItem = vi.fn().mockReturnValue({ id: 1, name: 'Tokyo' });
expect(
await makeController({ createBucketItem }).createBucketItem(user, { name: 'Tokyo', lat: 35, lng: 139 }),
).toEqual({ item: { id: 1, name: 'Tokyo' } });
expect(createBucketItem).toHaveBeenCalledWith(8, {
name: 'Tokyo',
lat: 35,
lng: 139,
country_code: undefined,
notes: undefined,
target_date: undefined,
});
});
it('forwards a wished-for region, as the MCP tool does (#1901)', async () => {
const createBucketItem = vi.fn().mockReturnValue({ id: 2 });
await makeController({ createBucketItem }).createBucketItem(user, {
name: 'Bayern',
country_code: 'DE',
region_code: 'DE-BY',
});
expect(createBucketItem).toHaveBeenCalledWith(
8,
expect.objectContaining({ country_code: 'DE', region_code: 'DE-BY' }),
);
});
it('404 on update of a missing item', () => {
const updateBucketItem = vi.fn().mockReturnValue(null);
return thrown(() => makeController({ updateBucketItem }).updateBucketItem(user, '9', { name: 'X' })).then((r) =>
expect(r).toEqual({ status: 404, body: { error: 'Item not found' } }),
);
});
it('updates an existing item', async () => {
const updateBucketItem = vi.fn().mockReturnValue({ id: 1, name: 'Kyoto' });
expect(await makeController({ updateBucketItem }).updateBucketItem(user, '1', { name: 'Kyoto' })).toEqual({
item: { id: 1, name: 'Kyoto' },
});
});
it('409 when the service refuses a duplicate create (#1898)', () => {
const createBucketItem = vi.fn(() => {
throw new BucketItemExistsError();
});
return thrown(() => makeController({ createBucketItem }).createBucketItem(user, { name: 'Japan' })).then((r) =>
expect(r).toEqual({ status: 409, body: { error: 'Already on your bucket list' } }),
);
});
it('409 when the service refuses a duplicate update (#1898)', () => {
const updateBucketItem = vi.fn(() => {
throw new BucketItemExistsError();
});
return thrown(() =>
makeController({ updateBucketItem }).updateBucketItem(user, '1', { target_date: '2027-05' }),
).then((r) => expect(r).toEqual({ status: 409, body: { error: 'Already on your bucket list' } }));
});
it('lets any other create/update failure through untouched', async () => {
const boom = new Error('disk on fire');
const createBucketItem = vi.fn(() => {
throw boom;
});
await expect(makeController({ createBucketItem }).createBucketItem(user, { name: 'Japan' })).rejects.toThrow(
boom,
);
const updateBucketItem = vi.fn(() => {
throw boom;
});
await expect(makeController({ updateBucketItem }).updateBucketItem(user, '1', { name: 'Japan' })).rejects.toThrow(
boom,
);
});
it('404 on delete of a missing item', () => {
const deleteBucketItem = vi.fn().mockReturnValue(false);
return thrown(() => makeController({ deleteBucketItem }).deleteBucketItem(user, '9')).then((r) =>
expect(r).toEqual({ status: 404, body: { error: 'Item not found' } }),
);
});
it('deletes an existing item', async () => {
const deleteBucketItem = vi.fn().mockReturnValue(true);
expect(await makeController({ deleteBucketItem }).deleteBucketItem(user, '1')).toEqual({ success: true });
});
});
});
// ---------------------------------------------------------------------------
// 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', async () => {
const getTravelStats = vi.fn().mockReturnValue({ countries: ['JP'], totalTrips: 2 });
const controller = new TravelStatsController({ getTravelStats } as unknown as AtlasService);
expect(await controller.travelStats(user)).toEqual({ countries: ['JP'], totalTrips: 2 });
expect(getTravelStats).toHaveBeenCalledWith(8);
});
it('ATLAS-TRAVEL-002: passes the payload through untouched', async () => {
const payload = {
countries: [],
cities: [],
coords: [],
totalTrips: 0,
totalDays: 0,
totalPlaces: 0,
totalDistanceKm: 0,
};
const controller = new TravelStatsController({ getTravelStats: () => payload } as unknown as AtlasService);
expect(await 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);
});
// No module-registration check here: importing AtlasModule drags AtlasMcp and
// the whole MCP SDK chain into what is otherwise a plain controller test, and
// it fails to resolve when the file runs on its own. PROFILE-014 covers the
// registration better anyway — it calls the route through the full app.
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]));
});
});