Files
TREK/server/tests/unit/nest/places.helpers.test.ts
T
jubnl 4f0f80ad69 chore: move place backend to proper DI
Fold the 1029-line services/placeService.ts into the DI-native PlacesService:
the CRUD + ratings SQL, the GPX/KML/KMZ importers and the Google/Naver list
importers become methods over the injected DatabaseService, byte-identical
statements, COALESCE semantics and the If-Match conflict protocol (#1135).
The pure half — frozen XML parsers, the KMZ unpacker, the dedup predicates,
the Google hex-id parsers, reclaimPhotoCache — moves to places.helpers.ts
(maps.helpers precedent).

The 10-tool mcp/tools/places.ts registrar plus the trek://trips/{tripId}/places
resource move onto the decorator registry as places.mcp.ts. search_place travels
with them because its gate is places:read, not the read-only geo group, and now
injects MapsService. TripsService, DaysMcp, BookingImportService and the plugin
RPC host (PluginHostDepsFactory's 21st constructor dep) all inject PlacesService,
so the domain needs no bridge at all. The two assignments.bridge imports that
places.mcp.ts keeps are a deliberate cycle break: AssignmentsModule imports
DaysModule and DaysModule now imports PlacesModule for days.mcp.ts's place
creation, so injecting AssignmentsService there would close
DaysModule -> PlacesModule -> AssignmentsModule -> DaysModule. Same seam
reservations.mcp.ts uses, same trade trips.bridge.ts documents.

Also folds services/placeEnrichment.ts in — its DB/websocket/Maps half becomes
PlacesService methods over the injected DatabaseService/RealtimeService/
MapsService, its pure match selector joins places.helpers.ts — which retires
nest/maps/maps.bridge.ts with its last consumer.

Adopts places.dto.ts for the seven grandfathered body contracts and clears every
PlacesController entry from body-contract-allow-list.ts. Two consequences worth
knowing: the bespoke 400s 'Place name is required', 'ids must be an array of
numbers' and 'URL is required' are now the ZodValidationPipe envelope, and
because a pipe runs before the handler a malformed body 400s ahead of the
trip-access 404 it used to follow — the same trade the todo and trips migrations
took. placeBulkUpdateRequestSchema.ids drops .min(1) so the endpoint's
empty-list short-circuit stays reachable.

Fixes four defects the relocation had faithfully carried, each with a
failing-first regression test:

- create dropped lat/lng of exactly 0, so a place on the equator or the prime
  meridian lost its coordinates (`x || null` cannot tell absent from zero).
- duration_minutes: 0 was unsettable — replaced by the 60-minute default on
  create, and read as "absent" by COALESCE on update.
- the journey delete hook fired on unscoped ids: DELETE /:id, POST /bulk-delete
  and the delete_place MCP tool could detach another trip's journey entries for
  an id they then refused. All three scope first via the new
  PlacesService.scopedIds, mirroring the guard PluginHostDepsFactory already had.
  bulk_delete_places also moves its hook ahead of the DELETE —
  journey_entries.source_place_id is ON DELETE SET NULL, so running it afterwards
  left the entries as orphans.
- the place search treated % and _ as LIKE wildcards; a bare % returned the whole
  trip. The three clauses now carry ESCAPE with the term escaped.

Plus hardening on the Google list fetch: the response is capped at 8 MB
(declared content-length and post-read length, the transit.service precedent)
and its JSON.parse is guarded, so a malformed provider payload produces the
existing 'Invalid list data received from Google Maps' 400 instead of throwing.

Quirks deliberately preserved: the non-COALESCE route_color (#776),
currency/transport_mode staying unclearable, importGpx returning null for a file
that parses but yields no usable geometry, update skipping the If-Match check
when the stored updated_at is null, and every string-valued `x || null`.

Tests move with their case IDs: placeService.test.ts and the wrapper suite merge
into unit/nest/places.service.test.ts, kmzUnpack into places.helpers.test.ts,
placeEnrichment into places.enrichment.test.ts. The places e2e converts to the
DI-native pattern (real SQL against temp-db DDL), tools-places.test.ts is kept
and extended so it exercises the decorator path through the test registry, and
the plugin-host and booking-import path mocks become constructor stubs.
2026-07-30 17:07:12 +02:00

170 lines
6.8 KiB
TypeScript

/**
* Unit tests for the pure places helpers (moved from
* tests/unit/services/kmzUnpack.test.ts when placeService went DI-native — the
* KMZ unpacker touches no DB, so it lives in places.helpers.ts).
*/
import { describe, it, expect, vi } from 'vitest';
import path from 'path';
import fs from 'fs';
vi.mock('../../../src/db/database', () => ({
db: { prepare: vi.fn() },
getPlaceWithTags: vi.fn(),
}));
vi.mock('../../../src/config', () => ({
JWT_SECRET: 'test',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
}));
import {
COORD_DEDUP_TOLERANCE,
googleMapsFeatureIdFromItem,
googleMapsHexId,
isPlaceDuplicate,
KMZ_DECOMPRESSED_SIZE_LIMIT,
mapWithConcurrency,
trackInsertedInDedupSet,
trimOrNull,
unpackKmzToKml,
type DedupSet,
} from '../../../src/nest/places/places.helpers';
const KMZ_FIXTURE = path.join(__dirname, '../../fixtures/test.kmz');
describe('unpackKmzToKml', () => {
it('extracts the KML entry from a valid KMZ', async () => {
const kmzBuffer = fs.readFileSync(KMZ_FIXTURE);
const kmlBuffer = await unpackKmzToKml(kmzBuffer);
expect(kmlBuffer.length).toBeGreaterThan(0);
expect(kmlBuffer.toString('utf-8')).toContain('<kml');
});
it('rejects a KMZ whose KML entry exceeds the decompressed size limit', async () => {
const kmzBuffer = fs.readFileSync(KMZ_FIXTURE);
// test.kmz contains a KML with uncompressedSize 634 — set limit to 1 byte
await expect(unpackKmzToKml(kmzBuffer, 1)).rejects.toThrow('exceeds the maximum allowed decompressed size');
});
it('rejects a KMZ that contains no KML file', async () => {
// Craft a minimal ZIP containing only a non-KML entry using raw ZIP bytes
// We use the test GPX fixture (a real file) re-zipped via Node's zlib/archiver
// Simplest: a KMZ whose only file has a .txt extension
const Archiver = await import('archiver');
const archiver = Archiver.default;
const { PassThrough } = await import('stream');
const chunks: Buffer[] = [];
const output = new PassThrough();
output.on('data', (chunk) => chunks.push(chunk));
const archive = archiver('zip', { zlib: { level: 1 } });
archive.pipe(output);
archive.append(Buffer.from('not a kml'), { name: 'data.txt' });
await archive.finalize();
const zipBuffer = Buffer.concat(chunks);
await expect(unpackKmzToKml(zipBuffer)).rejects.toThrow('does not contain a KML file');
});
it('rejects a buffer that is not a valid ZIP archive', async () => {
await expect(unpackKmzToKml(Buffer.from('this is not a zip'))).rejects.toThrow('Invalid KMZ archive');
});
it('exports KMZ_DECOMPRESSED_SIZE_LIMIT as 50 MB', () => {
expect(KMZ_DECOMPRESSED_SIZE_LIMIT).toBe(50 * 1024 * 1024);
});
});
// ── Import dedup predicates ───────────────────────────────────────────────────
const emptyDedup = (): DedupSet => ({ names: new Set(), coords: [] });
describe('isPlaceDuplicate / trackInsertedInDedupSet', () => {
it('matches a named place case- and whitespace-insensitively', () => {
const dedup = emptyDedup();
trackInsertedInDedupSet({ name: ' Eiffel Tower ', lat: 1, lng: 2 }, dedup);
expect(isPlaceDuplicate({ name: 'eiffel tower', lat: null, lng: null }, dedup)).toBe(true);
expect(isPlaceDuplicate({ name: 'Louvre', lat: null, lng: null }, dedup)).toBe(false);
});
it('falls back to coordinate proximity for unnamed places', () => {
const dedup = emptyDedup();
// No name — tracked by coordinates instead.
trackInsertedInDedupSet({ name: null, lat: 48.85, lng: 2.35 }, dedup);
expect(dedup.names.size).toBe(0);
expect(isPlaceDuplicate({ name: undefined, lat: 48.85 + COORD_DEDUP_TOLERANCE / 2, lng: 2.35 }, dedup)).toBe(true);
expect(isPlaceDuplicate({ name: undefined, lat: 48.9, lng: 2.35 }, dedup)).toBe(false);
});
it('a named candidate never falls through to the coordinate check', () => {
const dedup = emptyDedup();
trackInsertedInDedupSet({ name: null, lat: 48.85, lng: 2.35 }, dedup);
// Same spot, but it carries a name — name lookup misses, so it is not a dup.
expect(isPlaceDuplicate({ name: 'Named', lat: 48.85, lng: 2.35 }, dedup)).toBe(false);
});
it('a candidate with neither a name nor coordinates is never a duplicate', () => {
expect(isPlaceDuplicate({ name: null, lat: null, lng: null }, emptyDedup())).toBe(false);
});
});
// ── Google Maps feature ids ───────────────────────────────────────────────────
describe('googleMapsHexId / googleMapsFeatureIdFromItem', () => {
it('passes an already-hex id through lower-cased', () => {
expect(googleMapsHexId('0x882BF179E806D471')).toBe('0x882bf179e806d471');
});
it("converts Google's signed 64-bit decimals to unsigned hex", () => {
expect(googleMapsHexId('-8634542354666695567')).toBe('0x882bf179e806d471');
expect(googleMapsHexId(255)).toBe('0xff');
});
it('rejects anything that is not a hex or decimal id', () => {
expect(googleMapsHexId('not-an-id')).toBeNull();
expect(googleMapsHexId(null)).toBeNull();
expect(googleMapsHexId({})).toBeNull();
});
it('reads the ftid pair from either item slot, else null', () => {
expect(googleMapsFeatureIdFromItem([null, [0, 0, 0, 0, 0, 0, ['0x1', '0x2']]])).toBe('0x1:0x2');
expect(googleMapsFeatureIdFromItem([null, null, null, null, null, null, null, [null, ['0x3', '0x4']]])).toBe('0x3:0x4');
expect(googleMapsFeatureIdFromItem([null, [0, 0, 0, 0, 0, 0, ['0x1']]])).toBeNull();
expect(googleMapsFeatureIdFromItem('not an array')).toBeNull();
});
});
// ── Enrichment plumbing ───────────────────────────────────────────────────────
describe('mapWithConcurrency / trimOrNull', () => {
it('visits every item and never runs more than `limit` at once', async () => {
const items = [1, 2, 3, 4, 5, 6, 7];
const seen: number[] = [];
let inFlight = 0;
let peak = 0;
await mapWithConcurrency(items, 3, async (n) => {
inFlight += 1;
peak = Math.max(peak, inFlight);
await Promise.resolve();
seen.push(n);
inFlight -= 1;
});
expect(seen.sort((a, b) => a - b)).toEqual(items);
expect(peak).toBeLessThanOrEqual(3);
});
it('is a no-op for an empty list', async () => {
const fn = vi.fn();
await mapWithConcurrency([], 3, fn);
expect(fn).not.toHaveBeenCalled();
});
it('trimOrNull keeps real strings and nulls everything else', () => {
expect(trimOrNull(' Paris ')).toBe('Paris');
expect(trimOrNull(' ')).toBeNull();
expect(trimOrNull(42)).toBeNull();
expect(trimOrNull(undefined)).toBeNull();
});
});