Compare commits

..

No commits in common. "main" and "task7-cleanup-imports" have entirely different histories.

10 changed files with 340 additions and 40 deletions

View File

@ -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 API call per
// Groups located places into runs (split at transports), one cached OSRM 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 routing I/O.
// legs to draw. Side-effect free, so the async loop below only does OSRM 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 API call per waypoint pair; shares RouteCalculator's cache.
// One cached OSRM 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 })

View File

@ -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
* day assignments, draws a straight-line route immediately, then upgrades it to real OSRM
* 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
// road geometry.
// OSRM 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
// Routing failed for this run — fall back to a straight line, no times.
// OSRM failed for this run — fall back to a straight line, no times.
polylines.push(run.map(p => [p.lat, p.lng] as [number, number]))
}
}

View File

@ -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 routing API.
// resolve — keep the straight line and don't hammer the public OSRM demo.
const MAX_ROUTE_KM = 2000
function haversineKm(a: { lat: number; lng: number }, b: { lat: number; lng: number }): number {

View File

@ -45,11 +45,6 @@ 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.

View File

@ -5,10 +5,6 @@ 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;
@ -16,9 +12,9 @@ export class AmapProxyController {
@Get('amap-js')
async serveAmapJs(@Query() query: Record<string, string>, @Res() res: Response) {
const key = getJsKey();
const key = process.env.AMAP_WEB_SERVICE_KEY?.trim();
if (!key) {
res.status(503).json({ error: 'AMap JS API key not configured' });
res.status(503).json({ error: 'AMap service not configured' });
return;
}
@ -65,9 +61,9 @@ export class AmapProxyController {
@All('amap-proxy/*')
async proxyAmap(@Req() req: Request, @Res() res: Response) {
const key = getJsKey();
const key = process.env.AMAP_WEB_SERVICE_KEY?.trim();
if (!key) {
res.status(503).json({ error: 'AMap JS API key not configured' });
res.status(503).json({ error: 'AMap service not configured' });
return;
}

View File

@ -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', 'amap'],
map_provider: ['leaflet', 'mapbox-gl', 'maplibre-gl'],
llm_provider: ['local', 'openai', 'anthropic'],
};

View File

@ -46,24 +46,18 @@ vi.mock('../../src/config', () => ({
}));
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
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(),
}));
// 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 the maps stack (pulled in via app.module).
buildUserAgent: () => 'TREK-Test-UA',
}));
@ -74,7 +68,6 @@ 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;
@ -229,17 +222,15 @@ describe('Maps happy paths (mocked service)', () => {
it('MAPS-008 — POST /maps/resolve-url returns extracted coordinates', async () => {
const { user } = createUser(testDb);
vi.mocked(amapPlaces.resolveAmapUri).mockReturnValueOnce({
vi.mocked(mapsService.resolveGoogleMapsUrl).mockResolvedValueOnce({
lat: 48.8584,
lng: 2.2945,
name: null,
address: null,
});
} as any);
const res = await request(app)
.post('/api/maps/resolve-url')
.set('Cookie', authCookie(user.id))
.send({ url: 'https://uri.amap.com/marker?position=2.2945,48.8584' });
.send({ url: 'https://maps.google.com/place/eiffel-tower' });
expect(res.status).toBe(200);
expect(res.body.lat).toBe(48.8584);

View File

@ -135,6 +135,43 @@ 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' } });

View File

@ -296,6 +296,159 @@ 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)', () => {

View File

@ -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, searchPlaceImage } from '../../../src/services/placeService';
import { listPlaces, createPlace as svcCreatePlace, getPlace, updatePlace, updatePlacesMany, deletePlace, importGpx, importKmlPlaces, importGoogleList, searchPlaceImage } from '../../../src/services/placeService';
const GPX_FIXTURE = path.join(__dirname, '../../fixtures/test.gpx');
const KML_FIXTURE = path.join(__dirname, '../../fixtures/test.kml');
@ -443,6 +443,134 @@ 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();