mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
c674df72a2
Asserting the module registration meant importing AtlasModule, which pulls AtlasMcp and the whole MCP SDK behind it into a file that otherwise constructs a controller with a stub. It does not resolve when the file runs on its own. PROFILE-014 already covers the registration, and covers it better: it calls GET /api/auth/travel-stats through the full app.
200 lines
9.3 KiB
TypeScript
200 lines
9.3 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 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', () => {
|
|
const stats = vi.fn().mockReturnValue({ countries: 3 });
|
|
expect(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', () => {
|
|
const countryPlaces = vi.fn().mockReturnValue([]);
|
|
makeController({ countryPlaces }).countryPlaces(user, 'de');
|
|
expect(countryPlaces).toHaveBeenCalledWith(8, 'DE');
|
|
});
|
|
|
|
it('POST mark returns success and upper-cases', () => {
|
|
const markCountry = vi.fn();
|
|
expect(makeController({ markCountry }).markCountry(user, 'de')).toEqual({ success: true });
|
|
expect(markCountry).toHaveBeenCalledWith(8, 'DE');
|
|
});
|
|
|
|
it('DELETE mark returns success', () => {
|
|
const unmarkCountry = vi.fn();
|
|
expect(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', () => {
|
|
const markRegion = vi.fn();
|
|
expect(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', () => {
|
|
const bucketList = vi.fn().mockReturnValue([{ id: 1 }]);
|
|
expect(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 }', () => {
|
|
const createBucketItem = vi.fn().mockReturnValue({ id: 1, name: 'Tokyo' });
|
|
expect(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', () => {
|
|
const updateBucketItem = vi.fn().mockReturnValue({ id: 1, name: 'Kyoto' });
|
|
expect(makeController({ updateBucketItem }).updateBucketItem(user, '1', { name: 'Kyoto' }))
|
|
.toEqual({ item: { id: 1, name: 'Kyoto' } });
|
|
});
|
|
|
|
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', () => {
|
|
const deleteBucketItem = vi.fn().mockReturnValue(true);
|
|
expect(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', () => {
|
|
const getTravelStats = vi.fn().mockReturnValue({ countries: ['JP'], totalTrips: 2 });
|
|
const controller = new TravelStatsController({ getTravelStats } as unknown as AtlasService);
|
|
|
|
expect(controller.travelStats(user)).toEqual({ countries: ['JP'], totalTrips: 2 });
|
|
expect(getTravelStats).toHaveBeenCalledWith(8);
|
|
});
|
|
|
|
it('ATLAS-TRAVEL-002: passes the payload through untouched', () => {
|
|
const payload = { countries: [], cities: [], coords: [], totalTrips: 0, totalDays: 0, totalPlaces: 0, totalDistanceKm: 0 };
|
|
const controller = new TravelStatsController({ getTravelStats: () => payload } as unknown as AtlasService);
|
|
|
|
expect(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]));
|
|
});
|
|
|
|
});
|