diff --git a/server/src/middleware/globalMiddleware.ts b/server/src/middleware/globalMiddleware.ts index e430b0f1..1a9e65d5 100644 --- a/server/src/middleware/globalMiddleware.ts +++ b/server/src/middleware/globalMiddleware.ts @@ -107,15 +107,11 @@ export function applyGlobalMiddleware( imgSrc: ["'self'", "data:", "blob:", "https:"], connectSrc: [ "'self'", "ws:", "wss:", - "https://nominatim.openstreetmap.org", "https://overpass-api.de", - "https://places.googleapis.com", "https://api.openweathermap.org", + "https://restapi.amap.com", "https://webapi.amap.com", "https://*.amap.com", + "https://api.openweathermap.org", "https://en.wikipedia.org", "https://commons.wikimedia.org", - "https://*.basemaps.cartocdn.com", "https://*.tile.openstreetmap.org", "https://unpkg.com", "https://open-meteo.com", "https://api.open-meteo.com", "https://geocoding-api.open-meteo.com", "https://api.frankfurter.dev", - "https://router.project-osrm.org/route/v1/", "https://routing.openstreetmap.de/", - "https://api.mapbox.com", "https://*.tiles.mapbox.com", "https://events.mapbox.com", - "https://tiles.openfreemap.org" ], workerSrc: ["'self'", "blob:"], childSrc: ["'self'", "blob:"], diff --git a/server/src/nest/maps/maps.service.ts b/server/src/nest/maps/maps.service.ts index 87c71384..9de3c7c6 100644 --- a/server/src/nest/maps/maps.service.ts +++ b/server/src/nest/maps/maps.service.ts @@ -9,24 +9,23 @@ import type { } from '@trek/shared'; import { DatabaseService } from '../database/database.service'; import { - searchPlaces, - autocompletePlaces, - getPlaceDetails, - getPlaceDetailsExpanded, - getPlacePhoto, - reverseGeocode, - resolveGoogleMapsUrl, - searchOverpassPois, -} from '../../services/mapsService'; + autocompleteAmapPlaces, + getAmapPlaceDetails, + getAmapPlaceDetailsExpanded, + resolveAmapUri, + reverseAmapGeocode, + searchAmapPlaces, + searchAmapPois, +} from '../../services/amap/amap.places'; +import { AmapClient } from '../../services/amap/amap.client'; import { serveFilePath } from '../../services/placePhotoCache'; type LocationBias = { low: { lat: number; lng: number }; high: { lat: number; lng: number } }; /** - * Thin Nest wrapper around the existing maps service. All geocoding, the - * provider fan-out (Nominatim/Overpass/Google) and — importantly — the SSRF - * guard live in mapsService and are reused unchanged, so behaviour and the - * outbound-URL protection are identical. + * Thin Nest wrapper around the AMap-only adapter. Historical fields remain in + * response envelopes where clients still expect them, but runtime map calls go + * through the server-side AMap Web Service key only. * * The per-endpoint kill-switches are settings reads the legacy route does * inline; they're encapsulated here as `*Disabled()` helpers over the same @@ -34,6 +33,8 @@ type LocationBias = { low: { lat: number; lng: number }; high: { lat: number; ln */ @Injectable() export class MapsService { + private readonly amap = new AmapClient(); + constructor(private readonly database: DatabaseService) {} private isSettingDisabled(key: string): boolean { @@ -57,23 +58,32 @@ export class MapsService { } search(userId: number, query: string, lang?: string, locationBias?: { lat: number; lng: number; radius?: number }): Promise { - return searchPlaces(userId, query, lang, locationBias) as Promise; + void userId; + return searchAmapPlaces(this.amap, query, lang, locationBias) as Promise; } autocomplete(userId: number, input: string, lang?: string, locationBias?: LocationBias): Promise { - return autocompletePlaces(userId, input, lang, locationBias) as Promise; + void userId; + return autocompleteAmapPlaces(this.amap, input, lang, locationBias) as Promise; } details(userId: number, placeId: string, lang?: string): Promise { - return getPlaceDetails(userId, placeId, lang) as Promise; + void userId; + return getAmapPlaceDetails(this.amap, placeId, lang) as Promise; } detailsExpanded(userId: number, placeId: string, lang: string | undefined, refresh: boolean): Promise { - return getPlaceDetailsExpanded(userId, placeId, lang, refresh) as Promise; + void userId; + return getAmapPlaceDetailsExpanded(this.amap, placeId, lang, refresh) as Promise; } photo(userId: number, placeId: string, lat: number, lng: number, name?: string): Promise { - return getPlacePhoto(userId, placeId, lat, lng, name) as Promise; + void userId; + void placeId; + void lat; + void lng; + void name; + return Promise.resolve({ photoUrl: null }); } photoBytesPath(placeId: string): string | null { @@ -81,15 +91,14 @@ export class MapsService { } reverse(lat: string, lng: string, lang?: string): Promise { - return reverseGeocode(lat, lng, lang) as Promise; + return reverseAmapGeocode(this.amap, lat, lng, lang) as Promise; } resolveUrl(url: string): Promise { - return resolveGoogleMapsUrl(url) as Promise; + return Promise.resolve(resolveAmapUri(url) as MapsResolveUrlResult); } - // OSM-only POI search by category within a viewport bbox (never calls Google). pois(category: string, bbox: { south: number; west: number; north: number; east: number }) { - return searchOverpassPois(category, bbox); + return searchAmapPois(this.amap, category, bbox); } } diff --git a/server/src/services/amap/amap.client.ts b/server/src/services/amap/amap.client.ts new file mode 100644 index 00000000..6bcc330e --- /dev/null +++ b/server/src/services/amap/amap.client.ts @@ -0,0 +1,66 @@ +type AmapEnvelope = { + status?: string | number; + info?: string; + infocode?: string; +}; + +export type AmapErrorCode = 'AMAP_NOT_CONFIGURED' | 'AMAP_PROVIDER_ERROR' | 'AMAP_NETWORK_ERROR'; + +export class AmapServiceError extends Error { + constructor( + message: string, + readonly code: AmapErrorCode, + readonly status: number, + ) { + super(message); + this.name = 'AmapServiceError'; + } +} + +export class AmapClient { + constructor( + private readonly baseUrl = 'https://restapi.amap.com', + private readonly timeoutMs = 8000, + ) {} + + async get(path: string, params: URLSearchParams = new URLSearchParams()): Promise { + const key = process.env.AMAP_WEB_SERVICE_KEY?.trim(); + if (!key) { + throw new AmapServiceError('AMap Web Service key is not configured', 'AMAP_NOT_CONFIGURED', 503); + } + + const url = this.buildUrl(path, params, key); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), this.timeoutMs); + + try { + const response = await fetch(url, { signal: controller.signal }); + const body = await response.json().catch(() => ({})) as T; + + if (!response.ok || String(body.status ?? '1') === '0') { + throw new AmapServiceError( + `AMap provider error${body.infocode ? ` ${body.infocode}` : ''}${body.info ? `: ${body.info}` : ''}`, + 'AMAP_PROVIDER_ERROR', + response.ok ? 502 : response.status, + ); + } + + return body; + } catch (err) { + if (err instanceof AmapServiceError) throw err; + throw new AmapServiceError('AMap network error', 'AMAP_NETWORK_ERROR', 502); + } finally { + clearTimeout(timer); + } + } + + private buildUrl(path: string, params: URLSearchParams, key: string): string { + const normalizedPath = path.startsWith('/') ? path : `/${path}`; + const url = new URL(normalizedPath, this.baseUrl); + for (const [paramKey, value] of params.entries()) { + url.searchParams.set(paramKey, value); + } + url.searchParams.set('key', key); + return url.toString(); + } +} diff --git a/server/src/services/amap/amap.places.ts b/server/src/services/amap/amap.places.ts new file mode 100644 index 00000000..f637fec8 --- /dev/null +++ b/server/src/services/amap/amap.places.ts @@ -0,0 +1,253 @@ +import { gcj02ToWgs84, wgs84ToGcj02 } from '@trek/shared'; + +import { AmapClient } from './amap.client'; + +type AmapHttpClient = Pick; + +type LocationBias = { low: { lat: number; lng: number }; high: { lat: number; lng: number } }; + +type AmapPoi = { + id?: string; + name?: string; + address?: string | unknown[]; + location?: string; + tel?: string | unknown[]; + type?: string; + typecode?: string; + website?: string; +}; + +type AmapPoiResponse = { + status?: string; + pois?: AmapPoi[]; +}; + +type AmapTip = { + id?: string | unknown[]; + name?: string; + district?: string; + address?: string | unknown[]; + location?: string; +}; + +type AmapTipsResponse = { + status?: string; + tips?: AmapTip[]; +}; + +type AmapRegeoResponse = { + status?: string; + regeocode?: { + formatted_address?: string; + addressComponent?: { + province?: string; + city?: string | unknown[]; + district?: string; + township?: string; + }; + }; +}; + +const defaultClient = new AmapClient(); + +export async function searchAmapPlaces( + client: AmapHttpClient = defaultClient, + query: string, + lang?: string, + locationBias?: { lat: number; lng: number; radius?: number }, +): Promise<{ places: Record[]; source: 'amap' }> { + const params = new URLSearchParams({ + keywords: query, + offset: '10', + page: '1', + extensions: 'all', + }); + if (lang) params.set('language', toAmapLanguage(lang)); + if (locationBias) { + const gcj = wgs84ToGcj02({ lat: locationBias.lat, lng: locationBias.lng }); + params.set('location', `${gcj.lng},${gcj.lat}`); + params.set('radius', String(locationBias.radius ?? 50000)); + } + + const data = await client.get('/v3/place/text', params); + return { + places: (data.pois ?? []).map(mapPoi).filter((place): place is Record => place !== null), + source: 'amap', + }; +} + +export async function autocompleteAmapPlaces( + client: AmapHttpClient = defaultClient, + input: string, + lang?: string, + locationBias?: LocationBias, +): Promise<{ suggestions: { placeId: string; mainText: string; secondaryText: string }[]; source: 'amap' }> { + const params = new URLSearchParams({ keywords: input }); + if (lang) params.set('language', toAmapLanguage(lang)); + if (locationBias) { + const center = { + lat: (locationBias.low.lat + locationBias.high.lat) / 2, + lng: (locationBias.low.lng + locationBias.high.lng) / 2, + }; + const gcj = wgs84ToGcj02(center); + params.set('location', `${gcj.lng},${gcj.lat}`); + } + + const data = await client.get('/v3/assistant/inputtips', params); + const suggestions = (data.tips ?? []) + .map(tip => { + const id = firstString(tip.id); + if (!id || !tip.name) return null; + return { + placeId: id, + mainText: tip.name, + secondaryText: [firstString(tip.address), tip.district].filter(Boolean).join(', '), + }; + }) + .filter((suggestion): suggestion is { placeId: string; mainText: string; secondaryText: string } => suggestion !== null) + .slice(0, 5); + + return { suggestions, source: 'amap' }; +} + +export async function getAmapPlaceDetails( + client: AmapHttpClient = defaultClient, + placeId: string, + lang?: string, +): Promise<{ place: Record | null }> { + const params = new URLSearchParams({ + id: placeId, + extensions: 'all', + }); + if (lang) params.set('language', toAmapLanguage(lang)); + + const data = await client.get('/v3/place/detail', params); + return { place: mapPoi(data.pois?.[0]) }; +} + +export function getAmapPlaceDetailsExpanded( + client: AmapHttpClient = defaultClient, + placeId: string, + lang?: string, + _refresh = false, +): Promise<{ place: Record | null }> { + return getAmapPlaceDetails(client, placeId, lang); +} + +export async function reverseAmapGeocode( + client: AmapHttpClient = defaultClient, + lat: string, + lng: string, + lang?: string, +): Promise<{ name: string | null; address: string | null }> { + const wgsLat = Number(lat); + const wgsLng = Number(lng); + if (!Number.isFinite(wgsLat) || !Number.isFinite(wgsLng)) return { name: null, address: null }; + + const gcj = wgs84ToGcj02({ lat: wgsLat, lng: wgsLng }); + const params = new URLSearchParams({ + location: `${gcj.lng},${gcj.lat}`, + extensions: 'base', + }); + if (lang) params.set('language', toAmapLanguage(lang)); + + const data = await client.get('/v3/geocode/regeo', params); + const component = data.regeocode?.addressComponent; + const name = [component?.province, firstString(component?.city), component?.district, component?.township].filter(Boolean).join(' ') || null; + return { + name, + address: data.regeocode?.formatted_address ?? null, + }; +} + +export async function searchAmapPois( + client: AmapHttpClient = defaultClient, + category: string, + bbox: { south: number; west: number; north: number; east: number }, +): Promise<{ pois: Record[]; source: 'amap'; truncated: boolean; clamped: boolean }> { + const center = { + lat: (bbox.south + bbox.north) / 2, + lng: (bbox.west + bbox.east) / 2, + }; + const gcj = wgs84ToGcj02(center); + const params = new URLSearchParams({ + keywords: category, + location: `${gcj.lng},${gcj.lat}`, + radius: String(radiusMetersForBbox(bbox)), + offset: '60', + page: '1', + extensions: 'all', + }); + + const data = await client.get('/v3/place/around', params); + return { + pois: (data.pois ?? []).map(mapPoi).filter((place): place is Record => place !== null), + source: 'amap', + truncated: false, + clamped: false, + }; +} + +export function resolveAmapUri(url: string): { lat: number; lng: number; name: string | null; address: string | null } { + const parsed = new URL(url); + if (!/(^|\.)amap\.com$/i.test(parsed.hostname)) { + throw Object.assign(new Error('Only AMap links are supported'), { status: 422 }); + } + + const position = parsed.searchParams.get('position') ?? parsed.searchParams.get('to') ?? parsed.searchParams.get('from'); + const point = parseAmapLocation(position ?? undefined); + if (!point) throw Object.assign(new Error('AMap link does not contain coordinates'), { status: 422 }); + + return { + lat: point.lat, + lng: point.lng, + name: parsed.searchParams.get('name'), + address: null, + }; +} + +function mapPoi(poi: AmapPoi | undefined): Record | null { + if (!poi) return null; + const point = parseAmapLocation(poi.location); + if (!poi.id || !poi.name || !point) return null; + + return { + google_place_id: null, + google_ftid: null, + osm_id: null, + amap_place_id: poi.id, + name: poi.name, + address: firstString(poi.address) ?? '', + lat: point.lat, + lng: point.lng, + rating: null, + website: poi.website ?? null, + phone: firstString(poi.tel) ?? null, + types: [poi.type, poi.typecode].filter(Boolean), + source: 'amap', + }; +} + +function parseAmapLocation(location: string | undefined): { lat: number; lng: number } | null { + if (!location) return null; + const [lngRaw, latRaw] = location.split(','); + const lng = Number(lngRaw); + const lat = Number(latRaw); + if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null; + return gcj02ToWgs84({ lat, lng }); +} + +function firstString(value: string | unknown[] | undefined): string | undefined { + if (typeof value === 'string') return value; + return undefined; +} + +function toAmapLanguage(lang: string): string { + return lang.toLowerCase().startsWith('zh') ? 'zh_cn' : 'en'; +} + +function radiusMetersForBbox(bbox: { south: number; west: number; north: number; east: number }): number { + const latMeters = Math.abs(bbox.north - bbox.south) * 111_000; + const lngMeters = Math.abs(bbox.east - bbox.west) * 111_000; + return Math.max(200, Math.min(50_000, Math.ceil(Math.max(latMeters, lngMeters) / 2))); +} diff --git a/server/tests/unit/nest/maps.service.test.ts b/server/tests/unit/nest/maps.service.test.ts index 25cda7f2..3c200e82 100644 --- a/server/tests/unit/nest/maps.service.test.ts +++ b/server/tests/unit/nest/maps.service.test.ts @@ -1,18 +1,17 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -const { maps } = vi.hoisted(() => ({ - maps: { - searchPlaces: vi.fn(), - autocompletePlaces: vi.fn(), - getPlaceDetails: vi.fn(), - getPlaceDetailsExpanded: vi.fn(), - getPlacePhoto: vi.fn(), - reverseGeocode: vi.fn(), - resolveGoogleMapsUrl: vi.fn(), - searchOverpassPois: vi.fn(), +const { amapPlaces } = vi.hoisted(() => ({ + amapPlaces: { + searchAmapPlaces: vi.fn(), + autocompleteAmapPlaces: vi.fn(), + getAmapPlaceDetails: vi.fn(), + getAmapPlaceDetailsExpanded: vi.fn(), + reverseAmapGeocode: vi.fn(), + searchAmapPois: vi.fn(), + resolveAmapUri: vi.fn(), }, })); -vi.mock('../../../src/services/mapsService', () => maps); +vi.mock('../../../src/services/amap/amap.places', () => amapPlaces); const { serveFilePath } = vi.hoisted(() => ({ serveFilePath: vi.fn() })); vi.mock('../../../src/services/placePhotoCache', () => ({ serveFilePath })); @@ -65,54 +64,53 @@ describe('MapsService', () => { }); }); - describe('delegation to the legacy maps service', () => { + describe('delegation to the AMap adapter', () => { it('search forwards userId, query, lang and bias', () => { - maps.searchPlaces.mockResolvedValue({ places: [], source: 'osm' }); + amapPlaces.searchAmapPlaces.mockResolvedValue({ places: [], source: 'amap' }); const bias = { lat: 1, lng: 2, radius: 5 }; svc().search(3, 'berlin', 'de', bias); - expect(maps.searchPlaces).toHaveBeenCalledWith(3, 'berlin', 'de', bias); + expect(amapPlaces.searchAmapPlaces).toHaveBeenCalledWith(expect.anything(), 'berlin', 'de', bias); }); it('search works without optional args', () => { svc().search(3, 'berlin'); - expect(maps.searchPlaces).toHaveBeenCalledWith(3, 'berlin', undefined, undefined); + expect(amapPlaces.searchAmapPlaces).toHaveBeenCalledWith(expect.anything(), 'berlin', undefined, undefined); }); it('autocomplete forwards through', () => { const bias = { low: { lat: 1, lng: 2 }, high: { lat: 3, lng: 4 } }; svc().autocomplete(3, 'be', 'en', bias); - expect(maps.autocompletePlaces).toHaveBeenCalledWith(3, 'be', 'en', bias); + expect(amapPlaces.autocompleteAmapPlaces).toHaveBeenCalledWith(expect.anything(), 'be', 'en', bias); }); it('details forwards through', () => { svc().details(3, 'p1', 'de'); - expect(maps.getPlaceDetails).toHaveBeenCalledWith(3, 'p1', 'de'); + expect(amapPlaces.getAmapPlaceDetails).toHaveBeenCalledWith(expect.anything(), 'p1', 'de'); }); it('detailsExpanded forwards refresh through', () => { svc().detailsExpanded(3, 'p1', 'de', true); - expect(maps.getPlaceDetailsExpanded).toHaveBeenCalledWith(3, 'p1', 'de', true); + expect(amapPlaces.getAmapPlaceDetailsExpanded).toHaveBeenCalledWith(expect.anything(), 'p1', 'de', true); }); - it('photo forwards coords and name through', () => { - svc().photo(3, 'p1', 1.5, 2.5, 'Spot'); - expect(maps.getPlacePhoto).toHaveBeenCalledWith(3, 'p1', 1.5, 2.5, 'Spot'); + it('photo returns an empty AMap-compatible result without calling legacy providers', async () => { + await expect(svc().photo(3, 'p1', 1.5, 2.5, 'Spot')).resolves.toEqual({ photoUrl: null }); }); it('reverse forwards through', () => { svc().reverse('1', '2', 'de'); - expect(maps.reverseGeocode).toHaveBeenCalledWith('1', '2', 'de'); + expect(amapPlaces.reverseAmapGeocode).toHaveBeenCalledWith(expect.anything(), '1', '2', 'de'); }); it('resolveUrl forwards through', () => { - svc().resolveUrl('https://maps.app.goo.gl/x'); - expect(maps.resolveGoogleMapsUrl).toHaveBeenCalledWith('https://maps.app.goo.gl/x'); + svc().resolveUrl('https://uri.amap.com/marker?position=116.39747,39.908823'); + expect(amapPlaces.resolveAmapUri).toHaveBeenCalledWith('https://uri.amap.com/marker?position=116.39747,39.908823'); }); it('pois forwards category and bbox through', () => { const bbox = { south: 1, west: 2, north: 3, east: 4 }; svc().pois('cafe', bbox); - expect(maps.searchOverpassPois).toHaveBeenCalledWith('cafe', bbox); + expect(amapPlaces.searchAmapPois).toHaveBeenCalledWith(expect.anything(), 'cafe', bbox); }); }); diff --git a/server/tests/unit/services/amap/amap.client.test.ts b/server/tests/unit/services/amap/amap.client.test.ts new file mode 100644 index 00000000..af3226e4 --- /dev/null +++ b/server/tests/unit/services/amap/amap.client.test.ts @@ -0,0 +1,64 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { AmapClient } from '../../../../src/services/amap/amap.client'; + +describe('AmapClient', () => { + const originalKey = process.env.AMAP_WEB_SERVICE_KEY; + + beforeEach(() => { + vi.stubGlobal('fetch', vi.fn()); + delete process.env.AMAP_WEB_SERVICE_KEY; + }); + + afterEach(() => { + vi.unstubAllGlobals(); + if (originalKey === undefined) delete process.env.AMAP_WEB_SERVICE_KEY; + else process.env.AMAP_WEB_SERVICE_KEY = originalKey; + }); + + it('returns a configuration error without AMAP_WEB_SERVICE_KEY', async () => { + const client = new AmapClient(); + + await expect(client.get('/v3/place/text', new URLSearchParams())).rejects.toMatchObject({ + code: 'AMAP_NOT_CONFIGURED', + status: 503, + }); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('uses the fixed AMap host and appends the server-only key', async () => { + process.env.AMAP_WEB_SERVICE_KEY = 'secret-web-key'; + vi.mocked(fetch).mockResolvedValue( + new Response(JSON.stringify({ status: '1', pois: [] }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ); + + await new AmapClient().get('/v3/place/text', new URLSearchParams({ keywords: 'beijing' })); + + const url = new URL(String(vi.mocked(fetch).mock.calls[0]?.[0])); + expect(url.origin).toBe('https://restapi.amap.com'); + expect(url.pathname).toBe('/v3/place/text'); + expect(url.searchParams.get('keywords')).toBe('beijing'); + expect(url.searchParams.get('key')).toBe('secret-web-key'); + }); + + it('redacts the key from provider failures', async () => { + process.env.AMAP_WEB_SERVICE_KEY = 'secret-web-key'; + vi.mocked(fetch).mockResolvedValue( + new Response(JSON.stringify({ status: '0', info: 'INVALID_USER_KEY', infocode: '10001' }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ); + + try { + await new AmapClient().get('/v3/place/text', new URLSearchParams()); + throw new Error('expected request to fail'); + } catch (err) { + expect(err).toMatchObject({ code: 'AMAP_PROVIDER_ERROR', status: 502 }); + expect(String((err as Error).message)).not.toContain('secret-web-key'); + } + }); +}); diff --git a/server/tests/unit/services/amap/amap.places.test.ts b/server/tests/unit/services/amap/amap.places.test.ts new file mode 100644 index 00000000..501f718e --- /dev/null +++ b/server/tests/unit/services/amap/amap.places.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { autocompleteAmapPlaces, searchAmapPlaces } from '../../../../src/services/amap/amap.places'; + +describe('AMap places adapter', () => { + it('converts AMap GCJ POI coordinates back to WGS-84', async () => { + const client = { + get: vi.fn().mockResolvedValue({ + status: '1', + pois: [ + { + id: 'B000A83M61', + name: 'Tiananmen', + address: 'Beijing', + location: '116.403714,39.910627', + type: 'scenic', + }, + ], + }), + }; + + await expect(searchAmapPlaces(client, '天安门')).resolves.toMatchObject({ + source: 'amap', + places: [ + { + google_place_id: null, + google_ftid: null, + amap_place_id: 'B000A83M61', + name: 'Tiananmen', + lat: expect.closeTo(39.908823, 3), + lng: expect.closeTo(116.39747, 3), + source: 'amap', + }, + ], + }); + }); + + it('converts a WGS location bias to AMap longitude-latitude order for input tips', async () => { + const client = { get: vi.fn().mockResolvedValue({ status: '1', tips: [] }) }; + + await autocompleteAmapPlaces(client, '天安门', 'zh', { + low: { lat: 39.9, lng: 116.3 }, + high: { lat: 40.0, lng: 116.5 }, + }); + + const params = client.get.mock.calls[0]?.[1] as URLSearchParams; + expect(client.get).toHaveBeenCalledWith('/v3/assistant/inputtips', expect.any(URLSearchParams)); + expect(params.get('location')).toMatch(/^116\./); + }); +});