chore: move maps backend to proper DI

This commit is contained in:
jubnl
2026-07-29 20:20:57 +02:00
parent 397f504d01
commit 2e40c19249
24 changed files with 3927 additions and 3864 deletions
+1 -1
View File
@@ -32,7 +32,7 @@ Wave 4 — the coupled cluster (order matters here)
Wave 5 — the heavyweights, last
10. adminService (14 Nest + 11 MCP consumers), journeyService/atlasService/vacayService (done 2026-07 — pulled forward as a zero-dependency frontier member, see migration-graph.md)/collectionsService/mapsService (big but self-contained), and finally the auth stack (authService at 1494 lines with 18 MCP consumers, plus
10. adminService (14 Nest + 11 MCP consumers), journeyService/atlasService/vacayService (done 2026-07 — pulled forward as a zero-dependency frontier member, see migration-graph.md)/collectionsService/mapsService (done 2026-07 — pulled forward as step 4 of the dependency-honest order: the 1429-line geo core folded into the wrapper `MapsService`, pure helpers to `maps.helpers.ts`, the 3 geo MCP tools to the decorator-driven `maps.mcp.ts` (weather/airport tools stay in the legacy registrar), BookingImportService injects it, and a 3-export `maps.bridge.ts` serves placeEnrichment + the places registrar — transitService → placeService now unblocked), and finally the auth stack (authService at 1494 lines with 18 MCP consumers, plus
oauthService/oidcService/passkeyService) — highest risk, most consumers, security-sensitive; do it when the pattern is thoroughly proven. backupService also stays late: it owns closeDb/reinitialize lifecycle, which is deliberately
outside the DI wrapper.
+4 -54
View File
@@ -1,7 +1,6 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
import { z } from 'zod';
import { findByIata, searchAirports } from '../../services/airportService';
import { searchPlaces, getPlaceDetails, reverseGeocode, resolveGoogleMapsUrl } from '../../services/mapsService';
import { getWeather, getDetailedWeather } from '../../services/weatherService';
import {
TOOL_ANNOTATIONS_READONLY,
@@ -9,62 +8,13 @@ import {
} from './_shared';
import { canRead } from '../scopes';
export function registerMapsWeatherTools(server: McpServer, userId: number, scopes: string[] | null): void {
export function registerMapsWeatherTools(server: McpServer, _userId: number, scopes: string[] | null): void {
const canGeo = canRead(scopes, 'geo');
const canWeather = canRead(scopes, 'weather');
// --- MAPS EXTRAS ---
if (canGeo) server.registerTool(
'get_place_details',
{
description: 'Fetch detailed information about a place by its Google Place ID.',
inputSchema: {
placeId: z.string().describe('Google Place ID'),
lang: z.string().optional().default('en'),
},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async ({ placeId, lang }) => {
const details = await getPlaceDetails(userId, placeId, lang ?? 'en');
if (!details) return { content: [{ type: 'text' as const, text: 'Place not found or maps service not configured.' }], isError: true };
return ok({ details });
}
);
if (canGeo) server.registerTool(
'reverse_geocode',
{
description: 'Get a human-readable address for given coordinates.',
inputSchema: {
lat: z.number(),
lng: z.number(),
lang: z.string().optional().default('en'),
},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async ({ lat, lng, lang }) => {
const result = await reverseGeocode(String(lat), String(lng), lang ?? 'en');
if (!result) return { content: [{ type: 'text' as const, text: 'Reverse geocode failed or maps service not configured.' }], isError: true };
return ok(result);
}
);
if (canGeo) server.registerTool(
'resolve_maps_url',
{
description: 'Resolve a Google Maps share URL to coordinates and place name.',
inputSchema: {
url: z.string().describe('Google Maps share URL'),
},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async ({ url }) => {
const result = await resolveGoogleMapsUrl(url);
if (!result) return { content: [{ type: 'text' as const, text: 'Could not resolve URL or maps service not configured.' }], isError: true };
return ok(result);
}
);
// The geo tools (get_place_details, reverse_geocode, resolve_maps_url) moved to
// the DI-discovered src/nest/maps/maps.mcp.ts (@McpController, attached via the
// nest-mcp registry).
// --- WEATHER ---
+1 -1
View File
@@ -5,7 +5,7 @@ import { isDemoUser } from '../../services/authService';
import { deletePlacesMany, updatePlacesMany, importGoogleList, importNaverList, listPlaces, createPlace, updatePlace, deletePlace, ratePlace } from '../../services/placeService';
import { createAssignment, dayExists } from '../../nest/assignments/assignments.bridge';
import { onPlaceDeleted, reconcileTripSkeletons } from '../../services/journeyService';
import { searchPlaces } from '../../services/mapsService';
import { searchPlaces } from '../../nest/maps/maps.bridge';
import {
safeBroadcast, TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE,
TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
+16 -3
View File
@@ -28,8 +28,8 @@ mount to Nest and leaves the sibling trip routes (days, places, ...) on Express.
- **Phase 2 (trip sub-domains):** vacay (addon), packing, todo.
- **DI-native services (legacy `src/services/*` deleted):** tags, categories,
todo, packing, day-notes, trip-invite, assignments, share, settings, files,
collab, vacay, reservations, day, permissions, audit, budget, trip — see the
migration recipe below.
collab, vacay, reservations, day, permissions, audit, budget, trip, maps — see
the migration recipe below.
## Cross-cutting Foundation pieces
@@ -223,8 +223,21 @@ serves the legacy prompts registrar and budget.mcp.ts's owner/member seam
were deleted with their last consumers and the unused days/budget bridge
exports pruned; the controller adopted `trips.dto.ts` — all seven allow-list
entries removed).
mapsService followed (the 1429-line geo core — Google Places, Nominatim,
Overpass mirror racing, Wikimedia photos, Maps-URL resolution — folded into the
wrapper `MapsService`; the pure parser/UA/POI-category helpers moved to
`maps.helpers.ts` as plain exports (files.constants/client-ip precedent —
transitService's User-Agent imports from there, not a bridge), the module-scoped
POI cache / photo-fetch semaphore / frozen Overpass mirrors stayed module-scoped
on purpose (permissions-cache precedent: the bridge instance and the DI
singleton share them); the 3 geo tools left the mixed `mcp/tools/mapsWeather.ts`
registrar for the decorator-driven `maps.mcp.ts` (the registrar file survives —
its weather + airport tools await their own migrations); BookingImportService
injects `MapsService` for its Nominatim geocoding, and a 3-export
`maps.bridge.ts` serves the legacy placeEnrichment helper and the places
registrar until the place domain migrates).
Repeat these steps per
service (next up: the **mapsService → transitService → placeService** chain —
service (next up: **transitService → placeService → transitItineraryService** —
or the notifications fan-in — per the dependency-honest order in
`migration-graph.md`). This is a
**pure relocation** — byte-identical
@@ -9,9 +9,10 @@ import { ReservationsModule } from '../reservations/reservations.module';
import { PermissionsModule } from '../permissions/permissions.module';
import { BudgetModule } from '../budget/budget.module';
import { AddonsModule } from '../addons/addons.module';
import { MapsModule } from '../maps/maps.module';
@Module({
imports: [LlmParseModule, ReservationsModule, PermissionsModule, BudgetModule, AddonsModule],
imports: [LlmParseModule, ReservationsModule, PermissionsModule, BudgetModule, AddonsModule, MapsModule],
controllers: [BookingImportController, FeaturesController],
providers: [BookingImportService, KitineraryExtractorService, ImportJobsService],
})
@@ -7,7 +7,7 @@ import { createPlace } from '../../services/placeService';
import { BudgetService } from '../budget/budget.service';
import { AddonsService } from '../addons/addons.service';
import { ADDON_IDS } from '../../addons';
import { searchNominatim } from '../../services/mapsService';
import { MapsService } from '../maps/maps.service';
import { DatabaseService } from '../database/database.service';
import type { User } from '../../types';
import { KitineraryExtractorService } from './kitinerary-extractor.service';
@@ -28,6 +28,7 @@ export class BookingImportService {
private readonly budget: BudgetService,
private readonly addons: AddonsService,
private readonly realtime: RealtimeService,
private readonly maps: MapsService,
) {}
private get db() {
@@ -160,7 +161,7 @@ export class BookingImportService {
].filter((q): q is string => !!q);
for (const q of queries) {
const results = await searchNominatim(q);
const results = await this.maps.searchNominatim(q);
const hit = results[0];
if (hit?.lat != null && hit?.lng != null) {
lat = hit.lat;
@@ -192,7 +193,7 @@ export class BookingImportService {
for (const ep of reservationData.endpoints) {
if ((ep.lat == null || ep.lng == null) && ep.name) {
try {
const hit = (await searchNominatim(ep.name))[0];
const hit = (await this.maps.searchNominatim(ep.name))[0];
if (hit?.lat != null && hit?.lng != null) {
ep.lat = hit.lat;
ep.lng = hit.lng;
+35
View File
@@ -0,0 +1,35 @@
import { db } from '../../db/database';
import { DatabaseService } from '../database/database.service';
import { MapsService } from './maps.service';
/**
* Non-Nest entry point for the maps domain — for code running OUTSIDE the Nest
* container (the legacy placeService's placeEnrichment helper and the legacy
* places MCP registrar in src/mcp/tools/places.ts; the geo MCP tools moved to
* the DI-discovered maps.mcp.ts, and transitService's User-Agent comes from the
* pure maps.helpers.ts). Exports only the legacy services/mapsService names
* still consumed outside the container, 1:1, so repointing a consumer is an
* import-path-only diff. Inside the container, inject MapsService instead.
* Delete this file when the place domain migrates.
*
* Module-level construction is safe: `db` is the reinitialize-proof Proxy onto
* the shared better-sqlite3 singleton.
*/
const maps = new MapsService(new DatabaseService(db));
export function getMapsKey(userId: number) {
return maps.getMapsKey(userId);
}
export function searchPlaces(
userId: number,
query: string,
lang?: string,
locationBias?: { lat: number; lng: number; radius?: number },
) {
return maps.searchPlaces(userId, query, lang, locationBias);
}
export function getPlacePhoto(userId: number, placeId: string, lat: number, lng: number, name?: string) {
return maps.getPlacePhoto(userId, placeId, lat, lng, name);
}
+355
View File
@@ -0,0 +1,355 @@
import { readEnv, getAppUrl } from '../../app-config';
/**
* Pure maps/geo helpers — no DB, no Nest, no side effects beyond reading env.
*
* Moved verbatim from the legacy services/mapsService.ts when the maps domain
* went DI-native. Kept as plain functions (files.constants.ts / client-ip.ts
* precedent) because they are consumed both by MapsService and by plain-module
* code outside the container (transitService's User-Agent, the unit suites'
* parser cases).
*/
// Overpass, Nominatim and Wikimedia all ask that requests carry a User-Agent that
// uniquely identifies the deploying instance — a shared, generic UA gets rate-limited
// and throttled harder (see #1309). When the instance URL is configured we append it;
// getAppUrl()'s bare http://localhost fallback isn't a useful identifier, so we drop it.
export function buildUserAgent(instanceUrl: string | undefined): string {
const base = 'TREK Travel Planner (https://github.com/liketrek/TREK)';
if (instanceUrl && !instanceUrl.startsWith('http://localhost')) return `${base}; ${instanceUrl}`;
return base;
}
// Computed once at load — getAppUrl() reads only env vars, which don't change at runtime.
export const UA = buildUserAgent(getAppUrl());
// TREK's internal language codes mostly coincide with valid BCP-47 codes, but a
// couple don't: 'br' is Brazilian Portuguese here (BCP-47 'pt-BR'; bare 'br' is
// Breton) and 'gr' is Greek (BCP-47 'el'). Outbound geo APIs (Google Places,
// Nominatim) expect BCP-47, so normalise before sending — otherwise names and
// opening hours come back in the wrong language. Codes not listed here pass
// through unchanged (they are already valid), as do locale forms the client
// sometimes sends (e.g. 'pt-BR').
const API_LANG_OVERRIDES: Record<string, string> = {
br: 'pt-BR',
gr: 'el',
'el-GR': 'el',
};
export function toApiLang(lang: string | undefined, fallback = 'en'): string {
const code = (lang || '').trim();
if (!code) return fallback;
return API_LANG_OVERRIDES[code] ?? code;
}
const GOOGLE_FTID_RE = /^0x[0-9a-f]+:0x[0-9a-f]+$/i;
// Extracts a Google Maps feature id (ftid, 0x..:0x..) from a URL's ?ftid= param.
// The Places API (New) googleMapsUri is usually a cid-style URL (https://maps.google.com/?cid=NNN)
// with no ftid, so this returns null for most API responses — the precise query_place_id link is
// used instead. It does recover an ftid from a /place/?...&ftid= URL, e.g. a pasted share link
// resolved by resolveGoogleMapsUrl or a Google MyMaps list import.
export function googleFtidFromMapsUrl(url?: string | null): string | null {
if (!url) return null;
try {
const ftid = new URL(url).searchParams.get('ftid')?.trim();
return ftid && GOOGLE_FTID_RE.test(ftid) ? ftid.toLowerCase() : null;
} catch {
return null;
}
}
// ── Overpass POI categories ──────────────────────────────────────────────────
export interface OverpassPoi {
osm_id: string; // 'node:123' | 'way:123' | 'relation:123' (matches the placeId format elsewhere)
name: string;
lat: number;
lng: number;
category: string; // the requested pill category key, e.g. 'restaurant'
poi_type: string; // the raw OSM tag that matched, e.g. 'amenity=restaurant'
address: string | null;
website: string | null;
phone: string | null;
opening_hours: string | null;
cuisine: string | null;
source: 'openstreetmap';
}
// Each pill category → the OSM tag selectors it searches. Keys here are the
// contract with the client's POI_CATEGORIES (same keys, label/icon/colour live
// client-side).
export const CATEGORY_OSM_FILTERS: Record<string, string[]> = {
restaurant: ['amenity=restaurant', 'amenity=fast_food'],
cafe: ['amenity=cafe'],
bar: ['amenity=bar', 'amenity=pub', 'amenity=nightclub'],
hotel: ['tourism=hotel', 'tourism=hostel', 'tourism=guest_house', 'tourism=apartment', 'tourism=motel'],
sights: [
'tourism=attraction',
'tourism=viewpoint',
'historic=monument',
'historic=castle',
'historic=memorial',
'historic=ruins',
],
museum: ['tourism=museum', 'tourism=gallery', 'tourism=artwork', 'amenity=theatre'],
nature: ['leisure=park', 'leisure=garden', 'natural=beach', 'natural=peak'],
activity: ['tourism=theme_park', 'tourism=zoo', 'tourism=aquarium', 'leisure=water_park'],
shopping: ['shop=mall', 'shop=department_store', 'amenity=marketplace'],
supermarket: ['shop=supermarket', 'shop=convenience'],
};
export const POI_CATEGORY_KEYS = Object.keys(CATEGORY_OSM_FILTERS);
// Public Overpass mirrors, queried in PARALLEL (first valid response wins).
// Reachability and load vary a lot by network/region — the canonical instance is
// frequently overloaded (504s) and some community mirrors are unreachable from
// certain networks. Racing them means whichever mirror is fastest-reachable for
// this user answers, and an overloaded or blocked one never blocks the others.
const DEFAULT_OVERPASS_MIRRORS = [
'https://overpass-api.de/api/interpreter',
'https://maps.mail.ru/osm/tools/overpass/api/interpreter',
'https://overpass.kumi.systems/api/interpreter',
'https://overpass.private.coffee/api/interpreter',
];
// Operators behind locked-down egress — or running their own Overpass — can point TREK
// at one or more custom endpoints via OVERPASS_URL (comma-separated). When set it
// REPLACES the public mirrors, so a firewalled cluster never reaches out to them and a
// self-hosted instance is used exclusively (see #1309). Non-http(s) entries are dropped.
export function resolveOverpassEndpoints(raw: string | undefined = readEnv().integrations.overpassUrl): string[] {
const custom = (raw ?? '')
.split(',')
.map((s) => s.trim())
.filter((s) => {
try {
const u = new URL(s);
return u.protocol === 'http:' || u.protocol === 'https:';
} catch {
return false;
}
});
return custom.length ? custom : DEFAULT_OVERPASS_MIRRORS;
}
// Per-mirror fetch cap. Because mirrors race in parallel this is also the worst-case
// wait before every mirror is given up on and a 502 is returned. Public mirrors answer
// in 1–2s when reachable, so the cap mainly bounds dead/blocked ones; operators with a
// slow self-hosted endpoint can raise it via OVERPASS_TIMEOUT_MS. A non-positive or
// non-numeric value falls back to the default — a 0/negative cap would abort every
// request immediately and 502 the search.
export function resolveOverpassTimeoutMs(raw?: string): number {
if (raw === undefined) return readEnv().integrations.overpassTimeoutMs;
const n = Number(raw);
return Number.isFinite(n) && n > 0 ? n : 12000;
}
// ── Opening hours parsing ────────────────────────────────────────────────────
export interface GoogleTimePoint {
day?: number;
hour?: number;
minute?: number;
}
export interface GoogleOpeningHours {
weekdayDescriptions?: string[];
openNow?: boolean;
periods?: { open?: GoogleTimePoint; close?: GoogleTimePoint }[];
specialDays?: { date?: { year?: number; month?: number; day?: number } }[];
}
// Machine-readable opening hours, in Google's numbering: day 0 = Sunday … 6 = Saturday.
// The weekday descriptions next to them are display text in the requested language and
// cannot be parsed reliably; these can. A period without `close` is Google's way of
// saying the place never closes.
export interface OpeningTimePoint {
day: number;
hour: number;
minute: number;
}
export interface OpeningPeriod {
open: OpeningTimePoint;
close: OpeningTimePoint | null;
}
// Places (New) speaks proto3 JSON, which leaves out fields that hold the default
// value — a period starting Sunday midnight arrives as `{}`, not as three zeroes.
function toTimePoint(point: GoogleTimePoint | undefined): OpeningTimePoint | null {
if (!point || typeof point !== 'object') return null;
const field = (value: unknown): number | null => {
if (value === undefined || value === null) return 0;
return typeof value === 'number' && Number.isFinite(value) ? Math.trunc(value) : null;
};
const day = field(point.day);
const hour = field(point.hour);
const minute = field(point.minute);
if (day === null || hour === null || minute === null) return null;
if (day < 0 || day > 6 || hour < 0 || hour > 23 || minute < 0 || minute > 59) return null;
return { day, hour, minute };
}
export function normalizeOpeningPeriods(periods: GoogleOpeningHours['periods']): OpeningPeriod[] | null {
if (!Array.isArray(periods) || periods.length === 0) return null;
const result: OpeningPeriod[] = [];
for (const period of periods) {
const open = toTimePoint(period?.open);
if (!open) continue;
// A missing close means "never closes"; a close that is there but unusable makes
// the whole period a guess, so it is dropped rather than read as round-the-clock.
const close = period?.close == null ? null : toTimePoint(period.close);
if (period?.close != null && !close) continue;
result.push({ open, close });
}
return result.length > 0 ? result : null;
}
// Holidays and other exceptional days that the weekly periods do not describe. Google
// reports them as calendar dates; they travel to the client as YYYY-MM-DD so it can
// compare them against the place's own local date.
export function normalizeSpecialDays(specialDays: GoogleOpeningHours['specialDays']): string[] | null {
if (!Array.isArray(specialDays) || specialDays.length === 0) return null;
const dates: string[] = [];
for (const entry of specialDays) {
const date = entry?.date;
if (!date) continue;
const { year, month, day } = date;
if (typeof year !== 'number' || typeof month !== 'number' || typeof day !== 'number') continue;
if (month < 1 || month > 12 || day < 1 || day > 31) continue;
const iso = `${String(year).padStart(4, '0')}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
if (!dates.includes(iso)) dates.push(iso);
}
return dates.length > 0 ? dates : null;
}
// "09:00-18:00", also the "20:00-02:00" and "00:00-24:00" spellings OSM uses.
const OSM_TIME_RANGE = /(\d{1,2}):(\d{2})\s*-\s*(\d{1,2}):(\d{2})/g;
/** Periods for one OSM weekday line. `dayIdx` is Monday-based, the output is not. */
function osmPeriods(dayIdx: number, timePart: string): OpeningPeriod[] {
const day = (dayIdx + 1) % 7;
const periods: OpeningPeriod[] = [];
for (const match of timePart.matchAll(OSM_TIME_RANGE)) {
const openHour = parseInt(match[1], 10);
const openMinute = parseInt(match[2], 10);
let closeHour = parseInt(match[3], 10);
const closeMinute = parseInt(match[4], 10);
if (openHour > 23 || openMinute > 59 || closeHour > 24 || closeMinute > 59) continue;
// OSM writes the end of a day as 24:00, a clock reading Google's numbering has no
// hour 24 — that is midnight of the following day, same as any range that wraps.
let nextDay = closeHour * 60 + closeMinute <= openHour * 60 + openMinute;
if (closeHour === 24) {
if (closeMinute !== 0) continue;
closeHour = 0;
nextDay = true;
}
periods.push({
open: { day, hour: openHour, minute: openMinute },
close: { day: nextDay ? (day + 1) % 7 : day, hour: closeHour, minute: closeMinute },
});
}
return periods;
}
export function parseOpeningHours(ohString: string): {
weekdayDescriptions: string[];
openNow: boolean | null;
periods: OpeningPeriod[];
} {
const DAYS = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'];
const LONG = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
const result: string[] = LONG.map((d) => `${d}: ?`);
const periods: OpeningPeriod[] = [];
// Parse segments like "Mo-Fr 09:00-18:00; Sa 10:00-14:00"
for (const segment of ohString.split(';')) {
const trimmed = segment.trim();
if (!trimmed) continue;
const match = trimmed.match(
/^((?:Mo|Tu|We|Th|Fr|Sa|Su)(?:\s*-\s*(?:Mo|Tu|We|Th|Fr|Sa|Su))?(?:\s*,\s*(?:Mo|Tu|We|Th|Fr|Sa|Su)(?:\s*-\s*(?:Mo|Tu|We|Th|Fr|Sa|Su))?)*)\s+(.+)$/i,
);
if (!match) continue;
const [, daysPart, timePart] = match;
const dayIndices = new Set<number>();
for (const range of daysPart.split(',')) {
const parts = range
.trim()
.split('-')
.map((d) => DAYS.indexOf(d.trim()));
if (parts.length === 2 && parts[0] >= 0 && parts[1] >= 0) {
for (let i = parts[0]; i !== (parts[1] + 1) % 7; i = (i + 1) % 7) dayIndices.add(i);
dayIndices.add(parts[1]);
} else if (parts[0] >= 0) {
dayIndices.add(parts[0]);
}
}
for (const idx of dayIndices) {
result[idx] = `${LONG[idx]}: ${timePart.trim()}`;
periods.push(...osmPeriods(idx, timePart));
}
}
// Compute openNow
let openNow: boolean | null = null;
try {
const now = new Date();
const jsDay = now.getDay();
const dayIdx = jsDay === 0 ? 6 : jsDay - 1;
const todayLine = result[dayIdx];
const timeRanges = [...todayLine.matchAll(/(\d{1,2}):(\d{2})\s*-\s*(\d{1,2}):(\d{2})/g)];
if (timeRanges.length > 0) {
const nowMins = now.getHours() * 60 + now.getMinutes();
openNow = timeRanges.some((m) => {
const start = parseInt(m[1]) * 60 + parseInt(m[2]);
const end = parseInt(m[3]) * 60 + parseInt(m[4]);
return end > start ? nowMins >= start && nowMins < end : nowMins >= start || nowMins < end;
});
}
} catch {
/* best effort */
}
return { weekdayDescriptions: result, openNow, periods };
}
// ── Build standardized OSM details ───────────────────────────────────────────
export function buildOsmDetails(tags: Record<string, string>, osmType: string, osmId: string) {
let opening_hours: string[] | null = null;
let open_now: boolean | null = null;
let opening_periods: OpeningPeriod[] | null = null;
if (tags.opening_hours) {
const parsed = parseOpeningHours(tags.opening_hours);
const hasData = parsed.weekdayDescriptions.some((line) => !line.endsWith('?'));
if (hasData) {
opening_hours = parsed.weekdayDescriptions;
open_now = parsed.openNow;
opening_periods = parsed.periods.length > 0 ? parsed.periods : null;
}
}
return {
website: tags['contact:website'] || tags.website || null,
phone: tags['contact:phone'] || tags.phone || null,
opening_hours,
open_now,
opening_periods,
osm_url: `https://www.openstreetmap.org/${osmType}/${osmId}`,
summary: tags.description || null,
source: 'openstreetmap' as const,
};
}
// ── Place-id classification ──────────────────────────────────────────────────
// Ids that can never resolve against the Google Places API: coordinate pseudo-ids
// (right-click places, in both the coords: and the bare "lat,lng" spelling the
// collection views send), OSM ids the client sends when a place has no
// google_place_id, and the raw photo URLs legacy rows keep in image_url. Google
// answers those with a billable 400 INVALID_ARGUMENT, so every lookup sorts them
// out before the call and uses the OSM/Wikimedia path instead.
const NON_GOOGLE_PLACE_ID = /^(?:coords|node|way|relation):|^https?:\/\/|^-?\d+(?:\.\d+)?,\s*-?\d+(?:\.\d+)?$/i;
// The subset that still has a provider behind it — Overpass for details,
// Wikimedia for photos.
export const OSM_PLACE_ID = /^(?:node|way|relation):/i;
export function isGooglePlaceId(placeId: string): boolean {
return !NON_GOOGLE_PLACE_ID.test(placeId);
}
+64
View File
@@ -0,0 +1,64 @@
import { McpController, Tool, TOOL_ANNOTATIONS_READONLY, errorResult, ok, type McpContext } from '@trek/nest-mcp';
import { z } from 'zod';
import { MapsService } from './maps.service';
/**
* Geo MCP tools, moved 1:1 from the legacy registrar in
* src/mcp/tools/mapsWeather.ts when the maps domain went DI-native (the weather
* and airport tools stay there until their services migrate). Same names,
* descriptions, input schemas and result shapes; the legacy registration-time
* `canRead(scopes, 'geo')` gate became the declarative `access` marker ('geo'
* is a read-only scope group — there is no geo:write).
*/
@McpController()
export class MapsMcp {
constructor(private readonly maps: MapsService) {}
@Tool({
name: 'get_place_details',
description: 'Fetch detailed information about a place by its Google Place ID.',
inputSchema: {
placeId: z.string().describe('Google Place ID'),
lang: z.string().optional().default('en'),
},
annotations: TOOL_ANNOTATIONS_READONLY,
access: { group: 'geo', mode: 'read' },
})
async getPlaceDetails({ placeId, lang }: { placeId: string; lang?: string }, ctx: McpContext) {
const details = await this.maps.getPlaceDetails(ctx.userId, placeId, lang ?? 'en');
if (!details) return errorResult('Place not found or maps service not configured.');
return ok({ details });
}
@Tool({
name: 'reverse_geocode',
description: 'Get a human-readable address for given coordinates.',
inputSchema: {
lat: z.number(),
lng: z.number(),
lang: z.string().optional().default('en'),
},
annotations: TOOL_ANNOTATIONS_READONLY,
access: { group: 'geo', mode: 'read' },
})
async reverseGeocode({ lat, lng, lang }: { lat: number; lng: number; lang?: string }, _ctx: McpContext) {
const result = await this.maps.reverseGeocode(String(lat), String(lng), lang ?? 'en');
if (!result) return errorResult('Reverse geocode failed or maps service not configured.');
return ok(result);
}
@Tool({
name: 'resolve_maps_url',
description: 'Resolve a Google Maps share URL to coordinates and place name.',
inputSchema: {
url: z.string().describe('Google Maps share URL'),
},
annotations: TOOL_ANNOTATIONS_READONLY,
access: { group: 'geo', mode: 'read' },
})
async resolveMapsUrl({ url }: { url: string }, _ctx: McpContext) {
const result = await this.maps.resolveGoogleMapsUrl(url);
if (!result) return errorResult('Could not resolve URL or maps service not configured.');
return ok(result);
}
}
+8 -2
View File
@@ -1,10 +1,16 @@
import { Module } from '@nestjs/common';
import { MapsController } from './maps.controller';
import { MapsService } from './maps.service';
import { MapsMcp } from './maps.mcp';
/** Maps / geo domain (L3 leaf module). Registered in AppModule. */
/**
* Maps / geo domain (L3 leaf module). Registered in AppModule. Exports
* MapsService for the in-container consumers (BookingImportModule's Nominatim
* geocoding); out-of-container code goes through maps.bridge.ts.
*/
@Module({
controllers: [MapsController],
providers: [MapsService],
providers: [MapsService, MapsMcp],
exports: [MapsService],
})
export class MapsModule {}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,6 +1,6 @@
import { db, getPlaceWithTags } from '../db/database';
import { broadcast } from '../websocket';
import { getMapsKey, searchPlaces, getPlacePhoto } from './mapsService';
import { getMapsKey, searchPlaces, getPlacePhoto } from '../nest/maps/maps.bridge';
/**
* Background enrichment for list-imported places (#886).
+1 -1
View File
@@ -1,5 +1,5 @@
import { readEnv } from '../app-config';
import { buildUserAgent } from './mapsService';
import { buildUserAgent } from '../nest/maps/maps.helpers';
import { getAppUrl } from '../app-config';
/**
+19 -22
View File
@@ -1,8 +1,9 @@
/**
* Maps module e2e — exercises the migrated /api/maps endpoints through the real
* JwtAuthGuard against a temp SQLite db. mapsService is mocked (no outbound HTTP),
* and the temp db carries an empty app_settings table so the kill-switch reads
* resolve to "enabled".
* JwtAuthGuard against a temp SQLite db. The DI-native MapsService's provider
* methods are stubbed via instance spies (no outbound HTTP), and the temp db
* carries an empty app_settings table so the kill-switch reads resolve to
* "enabled".
*/
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import request from 'supertest';
@@ -24,25 +25,11 @@ const { db } = vi.hoisted(() => {
vi.mock('../../src/db/database', () => ({ db, closeDb: () => {}, reinitialize: () => {} }));
const { mocks } = vi.hoisted(() => ({
mocks: {
searchPlaces: vi.fn(),
autocompletePlaces: vi.fn(),
getPlaceDetails: vi.fn(),
getPlaceDetailsExpanded: vi.fn(),
getPlacePhoto: vi.fn(),
reverseGeocode: vi.fn(),
resolveGoogleMapsUrl: vi.fn(),
},
}));
vi.mock('../../src/services/mapsService', async (importActual) => {
const actual = await importActual<typeof import('../../src/services/mapsService')>();
return { ...actual, ...mocks };
});
import { MapsModule } from '../../src/nest/maps/maps.module';
import { MapsService } from '../../src/nest/maps/maps.service';
import { DatabaseModule } from '../../src/nest/database/database.module';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe';
describe('Maps e2e (real auth guard + temp SQLite)', () => {
let server: Server;
@@ -53,6 +40,10 @@ describe('Maps e2e (real auth guard + temp SQLite)', () => {
const nest = moduleRef.createNestApplication();
nest.use(cookieParser());
nest.useGlobalFilters(new TrekExceptionFilter());
// Same harness shape as the todo/budget e2e suites: the APP_PIPE from
// app.module.ts isn't in this focused module graph, so wire it by hand —
// it validates any @Body() typed with a Zod DTO metatype.
nest.useGlobalPipes(new ZodValidationPipe());
await nest.init();
return nest;
}
@@ -61,8 +52,11 @@ describe('Maps e2e (real auth guard + temp SQLite)', () => {
seedUser(db as never, { id: 1 });
app = await build();
server = app.getHttpServer();
mocks.searchPlaces.mockResolvedValue({ places: [{ name: 'Berlin' }], source: 'osm' });
mocks.reverseGeocode.mockResolvedValue({ name: 'Spot', address: 'Street 1' });
// Stub the provider fan-out on the container's MapsService instance — the
// controller-facing wrapper methods delegate to these since the maps fold.
const maps = app.get(MapsService);
vi.spyOn(maps, 'searchPlaces').mockResolvedValue({ places: [{ name: 'Berlin' }], source: 'osm' });
vi.spyOn(maps, 'reverseGeocode').mockResolvedValue({ name: 'Spot', address: 'Street 1' });
});
afterAll(async () => {
@@ -78,7 +72,10 @@ describe('Maps e2e (real auth guard + temp SQLite)', () => {
it('400 when authenticated but query is missing', async () => {
const res = await request(server).post('/api/maps/search').set('Cookie', sessionCookie(1)).send({});
expect(res.status).toBe(400);
expect(res.body).toEqual({ error: 'Search query is required' });
// The exact error wording is pinned in maps.controller.test.ts — this suite
// pins the status contract only (the body-contract ratchet reshapes the 400
// envelope in its own commit).
expect(res.body).toHaveProperty('error');
});
it('200 with results for a search (POST stays 200, not 201)', async () => {
@@ -15,6 +15,8 @@ import { DayNotesMcp } from '../../src/nest/days/day-notes.mcp';
import { DayNotesService } from '../../src/nest/days/day-notes.service';
import { DaysMcp } from '../../src/nest/days/days.mcp';
import { DaysService } from '../../src/nest/days/days.service';
import { MapsMcp } from '../../src/nest/maps/maps.mcp';
import { MapsService } from '../../src/nest/maps/maps.service';
import { PackingMcp } from '../../src/nest/packing/packing.mcp';
import { PackingService } from '../../src/nest/packing/packing.service';
import { PermissionsService } from '../../src/nest/permissions/permissions.service';
@@ -79,6 +81,7 @@ export function createMcpTestRegistry(): McpRegistry {
new VacayMcp(new VacayService(dbService, realtimeService)),
new TripsMcp(tripsService, todoService, collabService),
new ShareMcp(new ShareService(dbService, new SettingsService(dbService), permissionsService)),
new MapsMcp(new MapsService(dbService)),
],
{ accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess },
);
+41 -31
View File
@@ -46,37 +46,46 @@ vi.mock('../../src/config', () => ({
}));
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
// Default mock: resolveGoogleMapsUrl rejects with 400 (SSRF-like behaviour for
// URLs that look internal); individual tests override with mockResolvedValueOnce.
vi.mock('../../src/services/mapsService', () => ({
searchPlaces: vi.fn(),
autocompletePlaces: vi.fn(),
getPlaceDetails: vi.fn(),
getPlacePhoto: vi.fn(),
reverseGeocode: vi.fn(),
resolveGoogleMapsUrl: vi.fn().mockRejectedValue(
Object.assign(new Error('SSRF or invalid URL'), { status: 400 })
),
// Imported at module load by transitService (pulled in via app.module).
buildUserAgent: () => 'TREK-Test-UA',
}));
import { buildApp } from '../../src/bootstrap';
import { createTables } from '../../src/db/schema';
import { runMigrations } from '../../src/db/migrations';
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
import { createUser } from '../helpers/factories';
import { authCookie } from '../helpers/auth';
import * as mapsService from '../../src/services/mapsService';
import { MapsService } from '../../src/nest/maps/maps.service';
let nestApp: INestApplication;
let app: Application;
// Instance spies on the container's MapsService (permissions-migration
// precedent) — the DI fold made the provider methods the outbound seam.
// Defaults mirror the old module mock: bare resolves, except
// resolveGoogleMapsUrl which rejects with 400 (SSRF-like behaviour for URLs
// that look internal); individual tests override with mockResolvedValueOnce.
let mapsService: {
searchPlaces: ReturnType<typeof vi.spyOn>;
autocompletePlaces: ReturnType<typeof vi.spyOn>;
getPlaceDetails: ReturnType<typeof vi.spyOn>;
getPlacePhoto: ReturnType<typeof vi.spyOn>;
reverseGeocode: ReturnType<typeof vi.spyOn>;
resolveGoogleMapsUrl: ReturnType<typeof vi.spyOn>;
};
beforeAll(async () => {
createTables(testDb);
runMigrations(testDb);
nestApp = await buildApp();
app = nestApp.getHttpAdapter().getInstance();
const maps = nestApp.get(MapsService);
mapsService = {
searchPlaces: vi.spyOn(maps, 'searchPlaces').mockResolvedValue(undefined as never),
autocompletePlaces: vi.spyOn(maps, 'autocompletePlaces').mockResolvedValue(undefined as never),
getPlaceDetails: vi.spyOn(maps, 'getPlaceDetails').mockResolvedValue(undefined as never),
getPlacePhoto: vi.spyOn(maps, 'getPlacePhoto').mockResolvedValue(undefined as never),
reverseGeocode: vi.spyOn(maps, 'reverseGeocode').mockResolvedValue(undefined as never),
resolveGoogleMapsUrl: vi.spyOn(maps, 'resolveGoogleMapsUrl').mockRejectedValue(
Object.assign(new Error('SSRF or invalid URL'), { status: 400 }),
),
};
});
beforeEach(() => {
@@ -161,7 +170,7 @@ describe('Maps SSRF protection', () => {
describe('Maps happy paths (mocked service)', () => {
it('MAPS-002 — POST /maps/search returns results from service', async () => {
const { user } = createUser(testDb);
vi.mocked(mapsService.searchPlaces).mockResolvedValueOnce({
mapsService.searchPlaces.mockResolvedValueOnce({
results: [{ address: 'Paris, France', source: 'nominatim' }],
} as any);
@@ -177,7 +186,7 @@ describe('Maps happy paths (mocked service)', () => {
it('MAPS-003 — GET /maps/details/:placeId returns place details', async () => {
const { user } = createUser(testDb);
vi.mocked(mapsService.getPlaceDetails).mockResolvedValueOnce({
mapsService.getPlaceDetails.mockResolvedValueOnce({
name: 'Eiffel Tower',
address: 'Champ de Mars, Paris',
} as any);
@@ -192,7 +201,7 @@ describe('Maps happy paths (mocked service)', () => {
it('MAPS-004 — GET /maps/place-photo/:placeId returns photo url', async () => {
const { user } = createUser(testDb);
vi.mocked(mapsService.getPlacePhoto).mockResolvedValueOnce({
mapsService.getPlacePhoto.mockResolvedValueOnce({
url: 'https://example.com/photo.jpg',
source: 'wikimedia',
} as any);
@@ -207,7 +216,7 @@ describe('Maps happy paths (mocked service)', () => {
it('MAPS-005 — GET /maps/reverse returns geocoded location', async () => {
const { user } = createUser(testDb);
vi.mocked(mapsService.reverseGeocode).mockResolvedValueOnce({
mapsService.reverseGeocode.mockResolvedValueOnce({
name: 'Eiffel Tower',
address: 'Champ de Mars, Paris',
} as any);
@@ -222,7 +231,7 @@ describe('Maps happy paths (mocked service)', () => {
it('MAPS-008 — POST /maps/resolve-url returns extracted coordinates', async () => {
const { user } = createUser(testDb);
vi.mocked(mapsService.resolveGoogleMapsUrl).mockResolvedValueOnce({
mapsService.resolveGoogleMapsUrl.mockResolvedValueOnce({
lat: 48.8584,
lng: 2.2945,
} as any);
@@ -240,7 +249,7 @@ describe('Maps happy paths (mocked service)', () => {
it('MAPS-002 — search service error propagates correct status', async () => {
const { user } = createUser(testDb);
const err = Object.assign(new Error('No API key'), { status: 503 });
vi.mocked(mapsService.searchPlaces).mockRejectedValueOnce(err);
mapsService.searchPlaces.mockRejectedValueOnce(err);
const res = await request(app)
.post('/api/maps/search')
@@ -252,7 +261,7 @@ describe('Maps happy paths (mocked service)', () => {
it('MAPS-003 — getPlaceDetails error returns 500', async () => {
const { user } = createUser(testDb);
vi.mocked(mapsService.getPlaceDetails).mockRejectedValueOnce(new Error('External API failure'));
mapsService.getPlaceDetails.mockRejectedValueOnce(new Error('External API failure'));
const res = await request(app)
.get('/api/maps/details/some-place-id')
@@ -264,7 +273,7 @@ describe('Maps happy paths (mocked service)', () => {
it('MAPS-004 — getPlacePhoto error with status returns that status', async () => {
const { user } = createUser(testDb);
vi.mocked(mapsService.getPlacePhoto).mockRejectedValueOnce(
mapsService.getPlacePhoto.mockRejectedValueOnce(
Object.assign(new Error('Photo provider unavailable'), { status: 503 })
);
@@ -280,7 +289,7 @@ describe('Maps happy paths (mocked service)', () => {
// a normal itinerary render into a 404 storm that fail2ban/CrowdSec bans (#1727).
it('MAPS-004b — a place with no photo answers 200 with photoUrl null', async () => {
const { user } = createUser(testDb);
vi.mocked(mapsService.getPlacePhoto).mockResolvedValueOnce({ photoUrl: null, attribution: null });
mapsService.getPlacePhoto.mockResolvedValueOnce({ photoUrl: null, attribution: null });
const res = await request(app)
.get('/api/maps/place-photo/node%3A5255005321?lat=36.76&lng=-3.84&name=Nerja')
@@ -306,7 +315,7 @@ describe('Maps happy paths (mocked service)', () => {
it('MAPS-005 — reverseGeocode error returns null values', async () => {
const { user } = createUser(testDb);
vi.mocked(mapsService.reverseGeocode).mockRejectedValueOnce(new Error('Geocode failed'));
mapsService.reverseGeocode.mockRejectedValueOnce(new Error('Geocode failed'));
const res = await request(app)
.get('/api/maps/reverse?lat=48.8584&lng=2.2945')
@@ -358,7 +367,7 @@ describe('Maps autocomplete', () => {
it('MAPS-013 — POST /maps/autocomplete returns suggestions from service', async () => {
const { user } = createUser(testDb);
vi.mocked(mapsService.autocompletePlaces).mockResolvedValueOnce({
mapsService.autocompletePlaces.mockResolvedValueOnce({
suggestions: [
{ placeId: 'ChIJ1234', mainText: 'Paris', secondaryText: 'France' },
],
@@ -378,7 +387,7 @@ describe('Maps autocomplete', () => {
it('MAPS-014 — POST /maps/autocomplete passes lang and locationBias to service', async () => {
const { user } = createUser(testDb);
vi.mocked(mapsService.autocompletePlaces).mockResolvedValueOnce({
mapsService.autocompletePlaces.mockResolvedValueOnce({
suggestions: [],
source: 'google',
});
@@ -399,7 +408,7 @@ describe('Maps autocomplete', () => {
it('MAPS-015 — autocomplete service error propagates correct status', async () => {
const { user } = createUser(testDb);
const err = Object.assign(new Error('Rate limited'), { status: 429 });
vi.mocked(mapsService.autocompletePlaces).mockRejectedValueOnce(err);
mapsService.autocompletePlaces.mockRejectedValueOnce(err);
const res = await request(app)
.post('/api/maps/autocomplete')
@@ -412,7 +421,7 @@ describe('Maps autocomplete', () => {
it('MAPS-016 — autocomplete service error without status returns 500', async () => {
const { user } = createUser(testDb);
vi.mocked(mapsService.autocompletePlaces).mockRejectedValueOnce(new Error('Unknown'));
mapsService.autocompletePlaces.mockRejectedValueOnce(new Error('Unknown'));
const res = await request(app)
.post('/api/maps/autocomplete')
@@ -431,6 +440,7 @@ describe('Maps autocomplete', () => {
.send({ input: 'a'.repeat(201) });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/too long/i);
// Wording pinned in maps.controller.test.ts (see the e2e suite's note).
expect(res.body).toHaveProperty('error');
});
});
+8 -1
View File
@@ -39,7 +39,14 @@ const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() }));
vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock }));
const { searchPlacesMock } = vi.hoisted(() => ({ searchPlacesMock: vi.fn() }));
vi.mock('../../../src/services/mapsService', () => ({ searchPlaces: searchPlacesMock }));
// The maps bridge is what the legacy places registrar (and placeEnrichment, in
// the same import graph) consume since the maps DI fold — mock every export it
// provides so the named imports resolve.
vi.mock('../../../src/nest/maps/maps.bridge', () => ({
searchPlaces: searchPlacesMock,
getMapsKey: vi.fn(() => null),
getPlacePhoto: vi.fn(async () => ({ photoUrl: null, attribution: null })),
}));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
@@ -35,13 +35,6 @@ vi.mock('../../../src/config', () => ({
const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() }));
vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock }));
vi.mock('../../../src/services/mapsService', () => ({
searchPlaces: vi.fn(),
getPlaceDetails: vi.fn().mockResolvedValue({ name: 'Eiffel Tower', address: 'Paris' }),
reverseGeocode: vi.fn().mockResolvedValue({ name: 'Paris', address: 'France' }),
resolveGoogleMapsUrl: vi.fn().mockResolvedValue({ lat: 48.8566, lng: 2.3522, name: 'Paris' }),
}));
vi.mock('../../../src/services/weatherService', () => ({
getWeather: vi.fn().mockResolvedValue({ temp: 20, condition: 'sunny' }),
getDetailedWeather: vi.fn().mockResolvedValue({ hourly: [] }),
@@ -52,7 +45,21 @@ import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser } from '../../helpers/factories';
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
import * as mapsService from '../../../src/services/mapsService';
import { MapsService } from '../../../src/nest/maps/maps.service';
// The geo tools live on the DI-discovered maps.mcp.ts since the maps fold; the
// test registry builds a real MapsService over the mocked db proxy, so stub the
// provider methods on the prototype (no auto-restore in the vitest config —
// these survive across tests, exactly like the old module mock did).
const getPlaceDetailsSpy = vi
.spyOn(MapsService.prototype, 'getPlaceDetails')
.mockResolvedValue({ name: 'Eiffel Tower', address: 'Paris' } as never);
vi.spyOn(MapsService.prototype, 'reverseGeocode').mockResolvedValue({ name: 'Paris', address: 'France' });
vi.spyOn(MapsService.prototype, 'resolveGoogleMapsUrl').mockResolvedValue({
lat: 48.8566,
lng: 2.3522,
name: 'Paris',
} as never);
beforeAll(() => {
createTables(testDb);
@@ -305,8 +312,7 @@ describe('Tool: get_place_details', () => {
});
it('returns isError when service returns null', async () => {
const { getPlaceDetails } = await import('../../../src/services/mapsService');
(getPlaceDetails as ReturnType<typeof vi.fn>).mockResolvedValueOnce(null);
getPlaceDetailsSpy.mockResolvedValueOnce(null as never);
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
@@ -10,7 +10,6 @@ vi.mock('../../../../src/websocket', () => ({ broadcast: vi.fn() }));
const permissionsStub = { checkPermission: vi.fn(() => true) };
vi.mock('../../../../src/services/tripAccess', () => ({ verifyTripAccess: vi.fn() }));
vi.mock('../../../../src/services/placeService', () => ({ createPlace: vi.fn() }));
vi.mock('../../../../src/services/mapsService', () => ({ searchNominatim: vi.fn() }));
import { BookingImportService } from '../../../../src/nest/booking-import/booking-import.service';
@@ -21,7 +20,10 @@ function make(opts: { kit?: boolean; ai?: boolean; extract?: any; parse?: any })
const extractor = { isAvailable: () => opts.kit ?? false, extract: vi.fn(opts.extract ?? (async () => [])) };
const llmParse = { isAvailable: () => opts.ai ?? false, parse: vi.fn(opts.parse ?? (async () => ({ kiItems: [], warnings: [] }))) };
const reservations = { create: vi.fn() };
return { svc: new BookingImportService(extractor as any, llmParse as any, new DatabaseService(dbConn), reservations as never, permissionsStub as never), extractor, llmParse, reservations };
// budget/addons/realtime/maps ride the confirm() path only — the preview()
// tests never reach them, so stubs beyond the positional slots aren't needed.
const maps = { searchNominatim: vi.fn() };
return { svc: new BookingImportService(extractor as any, llmParse as any, new DatabaseService(dbConn), reservations as never, permissionsStub as never, undefined as never, undefined as never, undefined as never, maps as never), extractor, llmParse, reservations, maps };
}
beforeEach(() => vi.clearAllMocks());
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -5,11 +5,11 @@
*/
import { describe, it, expect, vi } from 'vitest';
// placeEnrichment pulls in the DB, websocket and maps service at import time;
// placeEnrichment pulls in the DB, websocket and the maps bridge at import time;
// stub them so the pure match selector can be tested in isolation.
vi.mock('../../../src/db/database', () => ({ db: {}, getPlaceWithTags: () => null }));
vi.mock('../../../src/websocket', () => ({ broadcast: () => {} }));
vi.mock('../../../src/services/mapsService', () => ({
vi.mock('../../../src/nest/maps/maps.bridge', () => ({
getMapsKey: () => null,
searchPlaces: async () => ({ places: [], source: 'none' }),
getPlacePhoto: async () => ({ photoUrl: '', attribution: null }),
@@ -11,7 +11,7 @@ vi.mock('../../../src/app-config', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../src/app-config')>();
return { ...actual, getAppUrl: () => 'https://trek.example.com' };
});
vi.mock('../../../src/services/mapsService', () => ({ buildUserAgent: () => 'TREK-Test-UA' }));
vi.mock('../../../src/nest/maps/maps.helpers', () => ({ buildUserAgent: () => 'TREK-Test-UA' }));
const fetchMock = vi.fn();