Compare commits
4 Commits
task7-clea
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
760f1ab9e8 | ||
|
|
29f6c4275d | ||
|
|
536a43b75e | ||
|
|
a02b06042a |
@ -419,7 +419,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
const routeDayKey = routeDayIds.join(',')
|
||||
|
||||
// Per-segment travel times shown as connectors between a day's located stops.
|
||||
// Groups located places into runs (split at transports), one cached OSRM call per
|
||||
// Groups located places into runs (split at transports), one cached API call per
|
||||
// run keyed by the start place's assignment id, plus the hotel bookend legs. Shares
|
||||
// RouteCalculator's cache with the map. Runs for every day in routeDayIds — one
|
||||
// selected day on desktop, each Route-toggled day on mobile (#1374).
|
||||
@ -430,7 +430,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
const hotelName = (a: Accommodation) => (a as any).place_name || (a as any).reservation_title || ''
|
||||
|
||||
// Pure per-day plan: the drive runs (each ≥2 waypoints) and which hotel bookend
|
||||
// legs to draw. Side-effect free, so the async loop below only does OSRM I/O.
|
||||
// legs to draw. Side-effect free, so the async loop below only does routing I/O.
|
||||
const planDay = (dayId: number) => {
|
||||
const merged = mergedItemsMap[dayId] || []
|
||||
const runs: { id: number; lat: number; lng: number }[][] = []
|
||||
@ -500,7 +500,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
const legsByDay: Record<number, Record<number, RouteSegment>> = {}
|
||||
const hotelByDay: Record<number, { top?: { seg: RouteSegment; name: string }; bottom?: { seg: RouteSegment; name: string } }> = {}
|
||||
|
||||
// One cached OSRM call per waypoint pair; shares RouteCalculator's cache.
|
||||
// One cached API call per waypoint pair; shares RouteCalculator's cache.
|
||||
const legBetween = async (a: { lat: number; lng: number }, b: { lat: number; lng: number }): Promise<RouteSegment | undefined> => {
|
||||
try {
|
||||
const r = await calculateRouteWithLegs([a, b], { signal: controller.signal, profile: routeProfile })
|
||||
|
||||
@ -13,7 +13,7 @@ const NO_ACCOMMODATIONS: Accommodation[] = []
|
||||
|
||||
/**
|
||||
* Manages route calculation state for a selected day. Extracts geo-coded waypoints from
|
||||
* day assignments, draws a straight-line route immediately, then upgrades it to real OSRM
|
||||
* day assignments, draws a straight-line route immediately, then upgrades it to real
|
||||
* road geometry with per-segment durations. Aborts in-flight requests when the day changes.
|
||||
*/
|
||||
export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: number | null, enabled: boolean = true, profile: 'driving' | 'walking' | 'cycling' = 'driving', accommodations: Accommodation[] = NO_ACCOMMODATIONS) {
|
||||
@ -161,7 +161,7 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
|
||||
if (runsWithHotel.length === 0) { setRoute(null); setRouteSegments([]); return }
|
||||
|
||||
// Draw straight lines immediately for snappiness, then upgrade to the real
|
||||
// OSRM road geometry.
|
||||
// road geometry.
|
||||
setRoute(straightLines())
|
||||
|
||||
const controller = new AbortController()
|
||||
@ -176,7 +176,7 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
|
||||
allLegs.push(...r.legs)
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.name === 'AbortError') throw err
|
||||
// OSRM failed for this run — fall back to a straight line, no times.
|
||||
// Routing failed for this run — fall back to a straight line, no times.
|
||||
polylines.push(run.map(p => [p.lat, p.lng] as [number, number]))
|
||||
}
|
||||
}
|
||||
|
||||
@ -22,7 +22,7 @@ const ROAD_PROFILE: Record<string, 'driving' | 'cycling'> = {
|
||||
|
||||
// Beyond this straight-line distance a car/taxi/bike (or even coach) booking is
|
||||
// almost always a data quirk or an inter-continental hop the road router can't
|
||||
// resolve — keep the straight line and don't hammer the public OSRM demo.
|
||||
// resolve — keep the straight line and don't hammer the routing API.
|
||||
const MAX_ROUTE_KM = 2000
|
||||
|
||||
function haversineKm(a: { lat: number; lng: number }, b: { lat: number; lng: number }): number {
|
||||
|
||||
@ -45,6 +45,11 @@ DEMO_MODE=false # Demo mode - resets data hourly
|
||||
|
||||
# UNSPLASH_ACCESS_KEY= # Optional Unsplash Access Key for trip-cover and place-image search. Without one, TREK uses Unsplash's unauthenticated endpoint, which some datacenter/VPS IPs get blocked from (#1449). Get a free key at unsplash.com/developers. Overrides any key set per-admin in Admin > Settings. Can also be configured there instead of here.
|
||||
|
||||
# AMAP_WEB_SERVICE_KEY= # Required. Amap (Gaode Maps) Web Service API key for place search, routing, transit, and geocoding. Get one at https://console.amap.com/dev/
|
||||
# AMAP_JS_API_KEY= # Required for the Amap JS map component. Falls back to AMAP_WEB_SERVICE_KEY if not set. Apply for a "JS API" key at https://console.amap.com/dev/
|
||||
|
||||
# OVERPASS_URL= # Deprecated — maps service uses Amap instead. Ignored when AMAP_WEB_SERVICE_KEY is set.
|
||||
|
||||
# Initial admin account — ONLY applied on the first boot, when the database has no
|
||||
# users yet. Adding these later has no effect (the server logs a reminder if you do);
|
||||
# to change an existing password sign in and use Settings, or reset the admin account.
|
||||
|
||||
@ -5,6 +5,10 @@ const AMAP_JS_BASE = 'https://webapi.amap.com';
|
||||
const AMAP_JS_PATH = '/maps';
|
||||
const CACHE_MAX_AGE = 24 * 60 * 60;
|
||||
|
||||
function getJsKey(): string | undefined {
|
||||
return process.env.AMAP_JS_API_KEY?.trim() || process.env.AMAP_WEB_SERVICE_KEY?.trim();
|
||||
}
|
||||
|
||||
@Controller('api/maps')
|
||||
export class AmapProxyController {
|
||||
private jsCache: { body: string; contentType: string } | null = null;
|
||||
@ -12,9 +16,9 @@ export class AmapProxyController {
|
||||
|
||||
@Get('amap-js')
|
||||
async serveAmapJs(@Query() query: Record<string, string>, @Res() res: Response) {
|
||||
const key = process.env.AMAP_WEB_SERVICE_KEY?.trim();
|
||||
const key = getJsKey();
|
||||
if (!key) {
|
||||
res.status(503).json({ error: 'AMap service not configured' });
|
||||
res.status(503).json({ error: 'AMap JS API key not configured' });
|
||||
return;
|
||||
}
|
||||
|
||||
@ -61,9 +65,9 @@ export class AmapProxyController {
|
||||
|
||||
@All('amap-proxy/*')
|
||||
async proxyAmap(@Req() req: Request, @Res() res: Response) {
|
||||
const key = process.env.AMAP_WEB_SERVICE_KEY?.trim();
|
||||
const key = getJsKey();
|
||||
if (!key) {
|
||||
res.status(503).json({ error: 'AMap service not configured' });
|
||||
res.status(503).json({ error: 'AMap JS API key not configured' });
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@ -41,7 +41,7 @@ const VALID_VALUES: Partial<Record<DefaultableKey, unknown[]>> = {
|
||||
distance_unit: ['metric', 'imperial'],
|
||||
time_format: ['12h', '24h'],
|
||||
dark_mode: [true, false, 'light', 'dark', 'auto'],
|
||||
map_provider: ['leaflet', 'mapbox-gl', 'maplibre-gl'],
|
||||
map_provider: ['leaflet', 'mapbox-gl', 'maplibre-gl', 'amap'],
|
||||
llm_provider: ['local', 'openai', 'anthropic'],
|
||||
};
|
||||
|
||||
|
||||
@ -46,18 +46,24 @@ 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/amap/amap.places', () => ({
|
||||
searchAmapPlaces: vi.fn(),
|
||||
autocompleteAmapPlaces: vi.fn(),
|
||||
getAmapPlaceDetails: vi.fn(),
|
||||
getAmapPlaceDetailsExpanded: vi.fn(),
|
||||
reverseAmapGeocode: vi.fn(),
|
||||
resolveAmapUri: vi.fn().mockImplementation(() => {
|
||||
throw Object.assign(new Error('SSRF or invalid URL'), { status: 400 })
|
||||
}),
|
||||
searchAmapPois: vi.fn(),
|
||||
}));
|
||||
|
||||
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 the maps stack (pulled in via app.module).
|
||||
buildUserAgent: () => 'TREK-Test-UA',
|
||||
}));
|
||||
|
||||
@ -68,6 +74,7 @@ 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 * as amapPlaces from '../../src/services/amap/amap.places';
|
||||
|
||||
let nestApp: INestApplication;
|
||||
let app: Application;
|
||||
@ -222,15 +229,17 @@ 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({
|
||||
vi.mocked(amapPlaces.resolveAmapUri).mockReturnValueOnce({
|
||||
lat: 48.8584,
|
||||
lng: 2.2945,
|
||||
} as any);
|
||||
name: null,
|
||||
address: null,
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/maps/resolve-url')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ url: 'https://maps.google.com/place/eiffel-tower' });
|
||||
.send({ url: 'https://uri.amap.com/marker?position=2.2945,48.8584' });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.lat).toBe(48.8584);
|
||||
|
||||
@ -135,43 +135,6 @@ describe('PlacesController (parity with the legacy /api/trips/:tripId/places rou
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /import/google-list + naver-list', () => {
|
||||
it('400 without a url', async () => {
|
||||
expect(await thrownAsync(() => new PlacesController(svc()).importGoogle(user, '5', undefined))).toEqual({ status: 400, body: { error: 'URL is required' } });
|
||||
});
|
||||
it('400 when url is the wrong type (not a string)', async () => {
|
||||
expect(await thrownAsync(() => new PlacesController(svc()).importNaver(user, '5', 123))).toEqual({ status: 400, body: { error: 'URL is required' } });
|
||||
});
|
||||
it('maps a service { error, status } to the same response', async () => {
|
||||
const s = svc({ importGoogleList: vi.fn().mockResolvedValue({ error: 'List is empty', status: 400 }) } as Partial<PlacesService>);
|
||||
expect(await thrownAsync(() => new PlacesController(s).importGoogle(user, '5', 'http://x'))).toEqual({ status: 400, body: { error: 'List is empty' } });
|
||||
});
|
||||
it('imports a naver list and returns the count + listName', async () => {
|
||||
const s = svc({ importNaverList: vi.fn().mockResolvedValue({ places: [{ id: 1 }], listName: 'Trip', skipped: 2 }), broadcast: vi.fn() } as Partial<PlacesService>);
|
||||
expect(await new PlacesController(s).importNaver(user, '5', 'http://x')).toEqual({ places: [{ id: 1 }], count: 1, listName: 'Trip', skipped: 2 });
|
||||
});
|
||||
it('forwards the enrich flag + userId and broadcasts each imported place', async () => {
|
||||
const importGoogleList = vi.fn().mockResolvedValue({ places: [{ id: 1 }, { id: 2 }], listName: 'L', skipped: 0 });
|
||||
const broadcast = vi.fn();
|
||||
const s = svc({ importGoogleList, broadcast } as Partial<PlacesService>);
|
||||
expect(await new PlacesController(s).importGoogle(user, '5', 'http://x', 'true', 'sock')).toEqual({ places: [{ id: 1 }, { id: 2 }], count: 2, listName: 'L', skipped: 0 });
|
||||
expect(importGoogleList).toHaveBeenCalledWith('5', 'http://x', { enrich: true, userId: 1 });
|
||||
expect(broadcast).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
it('wraps a thrown Error in the provider-specific 400 (Google)', async () => {
|
||||
const s = svc({ importGoogleList: vi.fn().mockRejectedValue(new Error('network down')) } as Partial<PlacesService>);
|
||||
expect(await thrownAsync(() => new PlacesController(s).importGoogle(user, '5', 'http://x'))).toEqual({
|
||||
status: 400, body: { error: 'Failed to import Google Maps list. Make sure the list is shared publicly.' },
|
||||
});
|
||||
});
|
||||
it('wraps a non-Error rejection in the provider-specific 400 (Naver)', async () => {
|
||||
const s = svc({ importNaverList: vi.fn().mockRejectedValue('weird') } as Partial<PlacesService>);
|
||||
expect(await thrownAsync(() => new PlacesController(s).importNaver(user, '5', 'http://x'))).toEqual({
|
||||
status: 400, body: { error: 'Failed to import Naver Maps list. Make sure the list is shared publicly.' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /bulk-delete', () => {
|
||||
it('400 when ids is not an array of numbers', () => {
|
||||
expect(thrown(() => new PlacesController(svc()).bulkDelete(user, '5', ['a']))).toEqual({ status: 400, body: { error: 'ids must be an array of numbers' } });
|
||||
|
||||
@ -296,159 +296,6 @@ describe('reverseGeocode (fetch stubbed)', () => {
|
||||
expect(result.address).toBe('Somewhere');
|
||||
});
|
||||
});
|
||||
|
||||
// Nominatim stub used by resolveGoogleMapsUrl after coordinate extraction
|
||||
const nominatimStub = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ display_name: 'Paris, France', name: null, address: {} }),
|
||||
});
|
||||
|
||||
// ── resolveGoogleMapsUrl coordinate extraction ────────────────────────────────
|
||||
|
||||
describe('resolveGoogleMapsUrl coordinate extraction (ReDoS guards)', () => {
|
||||
it('MAPS-020: extracts lat/lng from @lat,lng pattern', async () => {
|
||||
vi.stubGlobal('fetch', nominatimStub);
|
||||
const { resolveGoogleMapsUrl } = await import('../../../src/services/mapsService');
|
||||
const result = await resolveGoogleMapsUrl('https://www.google.com/maps/@48.8566,2.3522,15z');
|
||||
expect(result.lat).toBeCloseTo(48.8566, 3);
|
||||
expect(result.lng).toBeCloseTo(2.3522, 3);
|
||||
});
|
||||
|
||||
it('MAPS-021: extracts lat/lng from !3d!4d data pattern', async () => {
|
||||
vi.stubGlobal('fetch', nominatimStub);
|
||||
const { resolveGoogleMapsUrl } = await import('../../../src/services/mapsService');
|
||||
const result = await resolveGoogleMapsUrl('https://www.google.com/maps/place/Eiffel+Tower/data=!3d48.8584!4d2.2945');
|
||||
expect(result.lat).toBeCloseTo(48.8584, 3);
|
||||
expect(result.lng).toBeCloseTo(2.2945, 3);
|
||||
});
|
||||
|
||||
it('MAPS-022: extracts lat/lng from ?q=lat,lng pattern', async () => {
|
||||
vi.stubGlobal('fetch', nominatimStub);
|
||||
const { resolveGoogleMapsUrl } = await import('../../../src/services/mapsService');
|
||||
const result = await resolveGoogleMapsUrl('https://www.google.com/maps?q=48.8566,2.3522');
|
||||
expect(result.lat).toBeCloseTo(48.8566, 3);
|
||||
expect(result.lng).toBeCloseTo(2.3522, 3);
|
||||
});
|
||||
|
||||
it('MAPS-023: extracts place name from /place/ path', async () => {
|
||||
vi.stubGlobal('fetch', nominatimStub);
|
||||
const { resolveGoogleMapsUrl } = await import('../../../src/services/mapsService');
|
||||
const result = await resolveGoogleMapsUrl('https://www.google.com/maps/place/Eiffel+Tower/@48.8584,2.2945,15z');
|
||||
expect(result.name).toBe('Eiffel Tower');
|
||||
});
|
||||
|
||||
it('MAPS-CID-001: resolves a cid= URL by following the redirect to a coordinate URL', async () => {
|
||||
// cid URLs (what get_place_details returns, and Google "Share" links) carry no
|
||||
// inline coords; the redirect target carries the !3d!4d data param.
|
||||
const fetchMock = vi.fn(async (u: string) => {
|
||||
if (u.includes('nominatim')) {
|
||||
return { ok: true, json: async () => ({ display_name: 'Paris, France', name: 'Eiffel Tower', address: {} }) };
|
||||
}
|
||||
return { url: 'https://www.google.com/maps/place/Eiffel+Tower/data=!3d48.8584!4d2.2945', text: async () => '' };
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const { resolveGoogleMapsUrl } = await import('../../../src/services/mapsService');
|
||||
const result = await resolveGoogleMapsUrl('https://maps.google.com/?cid=1234567890');
|
||||
expect(result.lat).toBeCloseTo(48.8584, 3);
|
||||
expect(result.lng).toBeCloseTo(2.2945, 3);
|
||||
});
|
||||
|
||||
it('MAPS-CID-002: falls back to parsing coordinates from the page body', async () => {
|
||||
const fetchMock = vi.fn(async (u: string) => {
|
||||
if (u.includes('nominatim')) {
|
||||
return { ok: true, json: async () => ({ display_name: 'NYC, USA', name: null, address: {} }) };
|
||||
}
|
||||
if (u.includes('cid=')) {
|
||||
// Redirect target has no inline coords.
|
||||
return { url: 'https://www.google.com/maps/place/Somewhere', text: async () => '' };
|
||||
}
|
||||
// Body fetch of the resolved URL embeds coords in the map data.
|
||||
return { url: 'https://www.google.com/maps/place/Somewhere', text: async () => 'x!3d40.6892!4d-74.0445y' };
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const { resolveGoogleMapsUrl } = await import('../../../src/services/mapsService');
|
||||
const result = await resolveGoogleMapsUrl('https://www.google.com/maps?cid=999');
|
||||
expect(result.lat).toBeCloseTo(40.6892, 3);
|
||||
expect(result.lng).toBeCloseTo(-74.0445, 3);
|
||||
});
|
||||
|
||||
it('MAPS-024 (ReDoS): /@(-?\\d+\\.?\\d*),(-?\\d+\\.?\\d*)/ on adversarial input < 500ms', () => {
|
||||
const adversarial = '/@' + '1'.repeat(10000) + '.';
|
||||
const start = Date.now();
|
||||
adversarial.match(/@(-?\d+\.?\d*),(-?\d+\.?\d*)/);
|
||||
expect(Date.now() - start).toBeLessThan(500);
|
||||
});
|
||||
|
||||
it('MAPS-025 (ReDoS): /!3d(-?\\d+\\.?\\d*)!4d/ on adversarial input < 500ms', () => {
|
||||
const adversarial = '!3d' + '1'.repeat(10000) + '.';
|
||||
const start = Date.now();
|
||||
adversarial.match(/!3d(-?\d+\.?\d*)!4d(-?\d+\.?\d*)/);
|
||||
expect(Date.now() - start).toBeLessThan(500);
|
||||
});
|
||||
|
||||
it('MAPS-026 (ReDoS): /[?&]q=(-?\\d+\\.?\\d*)/ on adversarial input < 500ms', () => {
|
||||
const adversarial = '?q=' + '1'.repeat(10000) + '.';
|
||||
const start = Date.now();
|
||||
adversarial.match(/[?&]q=(-?\d+\.?\d*),(-?\d+\.?\d*)/);
|
||||
expect(Date.now() - start).toBeLessThan(500);
|
||||
});
|
||||
|
||||
it('MAPS-027 (ReDoS): /<[^>]+>/ HTML strip on adversarial input < 100ms', () => {
|
||||
const adversarial = '<' + 'a'.repeat(10000);
|
||||
const start = Date.now();
|
||||
adversarial.replace(/<[^>]+>/g, '');
|
||||
expect(Date.now() - start).toBeLessThan(100);
|
||||
});
|
||||
|
||||
it('MAPS-028: throws when no coordinates found in URL', async () => {
|
||||
vi.stubGlobal('fetch', nominatimStub);
|
||||
const { resolveGoogleMapsUrl } = await import('../../../src/services/mapsService');
|
||||
await expect(resolveGoogleMapsUrl('https://www.google.com/maps')).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('MAPS-028b: throws 403 when short URL is blocked by SSRF check', async () => {
|
||||
mockCheckSsrf.mockResolvedValueOnce({ allowed: false });
|
||||
const { resolveGoogleMapsUrl } = await import('../../../src/services/mapsService');
|
||||
await expect(
|
||||
resolveGoogleMapsUrl('https://goo.gl/maps/abc123')
|
||||
).rejects.toMatchObject({ status: 403 });
|
||||
});
|
||||
|
||||
it('MAPS-028c: follows redirect for short goo.gl URL and extracts coordinates', async () => {
|
||||
const redirectFetch = vi.fn()
|
||||
// First call: the redirect (goo.gl), returns resolved URL in .url
|
||||
.mockResolvedValueOnce({
|
||||
url: 'https://www.google.com/maps/@48.8566,2.3522,15z',
|
||||
})
|
||||
// Second call: the Nominatim reverse geocode
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ display_name: 'Paris, France', name: 'Paris', address: {} }),
|
||||
});
|
||||
vi.stubGlobal('fetch', redirectFetch);
|
||||
const { resolveGoogleMapsUrl } = await import('../../../src/services/mapsService');
|
||||
const result = await resolveGoogleMapsUrl('https://goo.gl/maps/abc123');
|
||||
expect(result.lat).toBeCloseTo(48.8566, 3);
|
||||
expect(result.lng).toBeCloseTo(2.3522, 3);
|
||||
});
|
||||
|
||||
it('MAPS-028d: falls back to nominatim address fields when no placeName in URL', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
display_name: 'Louvre Museum, Paris',
|
||||
name: null,
|
||||
address: { tourism: 'Louvre Museum' },
|
||||
}),
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const { resolveGoogleMapsUrl } = await import('../../../src/services/mapsService');
|
||||
// URL with coordinates but no /place/ path segment
|
||||
const result = await resolveGoogleMapsUrl('https://www.google.com/maps/@48.8606,2.3376,15z');
|
||||
expect(result.name).toBe('Louvre Museum');
|
||||
});
|
||||
});
|
||||
|
||||
// ── searchNominatim (fetch-dependent) ────────────────────────────────────────
|
||||
|
||||
describe('searchNominatim (fetch stubbed)', () => {
|
||||
|
||||
@ -55,7 +55,7 @@ import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createUser, createTrip, createPlace, createCategory, createTag } from '../../helpers/factories';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { listPlaces, createPlace as svcCreatePlace, getPlace, updatePlace, updatePlacesMany, deletePlace, importGpx, importKmlPlaces, importGoogleList, searchPlaceImage } from '../../../src/services/placeService';
|
||||
import { listPlaces, createPlace as svcCreatePlace, getPlace, updatePlace, updatePlacesMany, deletePlace, importGpx, importKmlPlaces, searchPlaceImage } from '../../../src/services/placeService';
|
||||
|
||||
const GPX_FIXTURE = path.join(__dirname, '../../fixtures/test.gpx');
|
||||
const KML_FIXTURE = path.join(__dirname, '../../fixtures/test.kml');
|
||||
@ -443,134 +443,6 @@ describe('importGpx', () => {
|
||||
expect(result.places[0].name).toBe('morning-hike');
|
||||
});
|
||||
});
|
||||
|
||||
// ── importGoogleList ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('importGoogleList', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('PLACE-SVC-026 — returns error when list ID cannot be extracted from URL', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const result = await importGoogleList(String(trip.id), 'https://example.com/no-id-here') as any;
|
||||
expect(result.error).toMatch(/Could not extract list ID/);
|
||||
expect(result.status).toBe(400);
|
||||
});
|
||||
|
||||
it('PLACE-SVC-026b — a single-place link gives a guiding error instead of the generic one (#1304)', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const url = 'https://www.google.com/maps/place/Eiffel+Tower/@48.8584,2.2945,17z/data=!3m1';
|
||||
const result = await importGoogleList(String(trip.id), url) as any;
|
||||
expect(result.status).toBe(400);
|
||||
expect(result.error).toMatch(/single place/i);
|
||||
});
|
||||
|
||||
it('PLACE-SVC-027 — returns error when Google Maps API responds with non-ok status', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, text: async () => '', status: 502 }));
|
||||
const url = 'https://www.google.com/maps/placelists/list/ABC123DEF456';
|
||||
const result = await importGoogleList(String(trip.id), url) as any;
|
||||
expect(result.error).toMatch(/Failed to fetch list/);
|
||||
expect(result.status).toBe(502);
|
||||
});
|
||||
|
||||
it('PLACE-SVC-028 — imports places from a valid Google Maps list response', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const listPayload = [
|
||||
[null, null, null, null, 'My Test List', null, null, null, [
|
||||
[null, [null, null, null, null, null, [null, null, 48.8566, 2.3522]], 'Paris', null],
|
||||
[null, [null, null, null, null, null, [null, null, 51.5074, -0.1278]], 'London', 'Great city'],
|
||||
]],
|
||||
];
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
text: async () => 'prefix\n' + JSON.stringify(listPayload),
|
||||
}));
|
||||
|
||||
const url = 'https://www.google.com/maps/placelists/list/ABC123DEF456';
|
||||
const result = await importGoogleList(String(trip.id), url) as any;
|
||||
expect(result.listName).toBe('My Test List');
|
||||
expect(result.places).toHaveLength(2);
|
||||
expect(result.places[0].name).toBe('Paris');
|
||||
expect(result.places[1].name).toBe('London');
|
||||
});
|
||||
|
||||
it('PLACE-SVC-028b — stores a Google Maps ftid separately from google_place_id', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const listPayload = [
|
||||
[null, null, null, null, 'My Test List', null, null, null, [
|
||||
[null, [null, null, null, null, '878 Weber St N', [null, null, 43.5118527, -80.5542617], ['-8634542354666695567', '-8822026229683971437']], "St. Jacobs Farmers' Market"],
|
||||
]],
|
||||
];
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
text: async () => 'prefix\n' + JSON.stringify(listPayload),
|
||||
}));
|
||||
|
||||
const url = 'https://www.google.com/maps/placelists/list/ABC123DEF456';
|
||||
const result = await importGoogleList(String(trip.id), url) as any;
|
||||
|
||||
expect(result.places).toHaveLength(1);
|
||||
expect(result.places[0].google_place_id).toBeNull();
|
||||
expect(result.places[0].google_ftid).toBe('0x882bf179e806d471:0x8591dde29c821a93');
|
||||
});
|
||||
|
||||
it('PLACE-SVC-028c — backfills google_ftid when re-import skips a duplicate', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const existing = createPlace(testDb, trip.id, {
|
||||
name: "St. Jacobs Farmers' Market",
|
||||
lat: 43.5118527,
|
||||
lng: -80.5542617,
|
||||
}) as any;
|
||||
|
||||
const listPayload = [
|
||||
[null, null, null, null, 'My Test List', null, null, null, [
|
||||
[null, [null, null, null, null, '878 Weber St N', [null, null, 43.5118527, -80.5542617], ['-8634542354666695567', '-8822026229683971437']], "St. Jacobs Farmers' Market"],
|
||||
]],
|
||||
];
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
text: async () => 'prefix\n' + JSON.stringify(listPayload),
|
||||
}));
|
||||
|
||||
const url = 'https://www.google.com/maps/placelists/list/ABC123DEF456';
|
||||
const result = await importGoogleList(String(trip.id), url) as any;
|
||||
const row = testDb.prepare('SELECT google_place_id, google_ftid FROM places WHERE id = ?').get(existing.id) as any;
|
||||
|
||||
expect(result.places).toHaveLength(0);
|
||||
expect(result.skipped).toBe(1);
|
||||
expect(row.google_place_id).toBeNull();
|
||||
expect(row.google_ftid).toBe('0x882bf179e806d471:0x8591dde29c821a93');
|
||||
});
|
||||
|
||||
it('PLACE-SVC-029 — returns error when list items array is empty', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const listPayload = [[null, null, null, null, 'Empty List', null, null, null, []]];
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
text: async () => 'prefix\n' + JSON.stringify(listPayload),
|
||||
}));
|
||||
|
||||
const url = 'https://www.google.com/maps/placelists/list/ABC123DEF456';
|
||||
const result = await importGoogleList(String(trip.id), url) as any;
|
||||
expect(result.error).toBeDefined();
|
||||
expect(result.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
// ── searchPlaceImage ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('searchPlaceImage', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
|
||||
Loading…
Reference in New Issue
Block a user