Files
TREK/server/tests/unit/nest/atlas.controller.test.ts
T

221 lines
11 KiB
TypeScript

import { describe, it, expect, vi } from 'vitest';
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 { BucketItemExistsError } from '../../../src/nest/atlas/atlas.service';
import type { AtlasService } from '../../../src/nest/atlas/atlas.service';
import type { User } from '../../../src/types';
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('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]));
});
});