mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
ae3ba18b9e
Fold the 1612-line services/atlasService.ts into the DI-native AtlasService: the stats aggregation, visited countries/regions with the #1490 tombstone and cascade logic, and the bucket-list CRUD become methods over the injected DatabaseService. The ~750-line pure-geo half — the bundled admin0/admin1 boundary stores with their #1576 streaming builders, the point-in-polygon indexes, Nominatim geocoding with its shared throttle, and every module-scoped cache including the import-time unref'd cleanup interval — moves verbatim to the plain module atlas-geo.ts so the caches stay process-global across the container instance, the bridge instance and test helpers (assetPath re-anchored one directory deeper, the only non-verbatim line). The 10-tool mcp/tools/atlas.ts registrar plus all four atlas resources in mcp/resources.ts move onto the decorator registry as atlas.mcp.ts — the when: atlas-addon gate is parity here (legacy tools and resources both gated; the REST controller deliberately does not). resources.test.ts retires with its last two cases, which move to tools-atlas-expanded.test.ts. The plugin RPC host swaps its 9 atlas imports for the injected AtlasService (23rd constructor dep); a minimal 2-export atlas.bridge.ts serves the one legacy consumer, authService.getTravelStats, and dies when authService migrates. Adopts atlas.dto.ts over the existing shared schemas and clears all three AtlasController entries from body-contract-allow-list.ts: the hand-rolled 'name and country_code are required' 400 becomes the ZodValidationPipe envelope (todo/places trade) while the whitespace-only bucket name keeps its legacy 'Name is required' trim guard. Fixes the verified defects the relocation had faithfully carried, each with a regression test (ATLAS-SVC-031..036 plus two MCP casing cases): the four multi-statement mark/unmark writes now run in db.transaction() (the region cascade nests as a savepoint), the trip-less countryPlaces early return honours manually_marked, the '|| null' bindings that dropped lat/lng 0 and empty-string notes on bucket update become '?? null', the mutating bucket SQL is user-scoped, and the MCP region/country-places tools uppercase their codes to match REST. Quirks deliberately preserved: the detached GET-path place_regions backfill writes, getStats' two divergent return shapes, the import-time cache-cleanup interval, getCountryFromAddress's unvalidated bare 2-letter codes, and the whitespace-only-name silent no-op on bucket update. Tests move with their case IDs: atlasService.test.ts becomes unit/nest/atlas.service.test.ts (pure-geo imports from atlas-geo, DB calls through the constructed service, plus bridge-delegation cases), the atlas e2e converts to the DI-native pattern (real SQL against the full temp-db schema), and the plugin-host path mock becomes a constructor stub.
152 lines
6.6 KiB
TypeScript
152 lines
6.6 KiB
TypeScript
import { describe, it, expect, vi } from 'vitest';
|
|
import { HttpException } from '@nestjs/common';
|
|
import type { Response } from 'express';
|
|
import { AtlasController } from '../../../src/nest/atlas/atlas.controller';
|
|
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 });
|
|
});
|
|
});
|
|
});
|