mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
c8cb88133f
A map keeps the datum it was built with, and Leaflet cannot change one afterwards. The basemap could still turn into Amap's after the fact: a first settings request that fails leaves the store on defaults for the session and is healed later, and the raster layer then drew GCJ-02 tiles under a map still projecting WGS-84, putting every marker, route and cluster a few hundred metres off the street beneath it until the planner was left and reopened. The datum is part of the element's identity now, so a flip rebuilds the map. Only the datum: keying on the whole tile URL would throw the view away on every template edit. Index POIs were labelled by an exact lookup of a term the index matches as a substring, so any leaf that is not literally a term missed and took the first category of the request instead. In a corridor search that is whichever pill was tapped first, and the panel groups and colours on that field: a trattoria came back as a petrol station, or a petrol station as tonight's hotel. The label now comes from the term that actually matched, longest first. Two outbound reads were missing what the house rule asks for. The brand-logo fetch had no deadline at all next to a Wikidata hop that allows six seconds, so a stalled CDN held a request context and a socket for undici's five-minute default, and its size cap only applied after the bytes were in memory. The Wikimedia photo proxy beside it had the same shape. And the Amap provider buffered every answer uncapped and trusted a cast for its arrays, so a `pois` object rather than a list threw a TypeError that surfaced as a 500 instead of the OpenStreetMap answer.
691 lines
29 KiB
TypeScript
691 lines
29 KiB
TypeScript
/**
|
|
* Unit tests for the Amap (高德) places provider and the provider selection it
|
|
* plugs into — AMAP-001 onwards.
|
|
*
|
|
* What these are really about is the two things that make Amap different from
|
|
* Google and that no amount of care at the call site can fix: the datum (GCJ-02
|
|
* in, WGS-84 out, in BOTH directions) and the fact that Amap reports failure in
|
|
* a 200 response body. Everything else is field mapping, which is checked
|
|
* against the shapes Amap actually sends — including `address: []` for a missing
|
|
* string, which is the one that bites.
|
|
*
|
|
* fetch is stubbed; the SSRF guard and the database are mocked, the same way
|
|
* maps.service.test.ts does it.
|
|
*/
|
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
|
import { gcj02ToWgs84, wgs84ToGcj02 } from '@trek/shared';
|
|
|
|
const { mockDbGet, mockDbRun, mockInstanceGet, mockProviderGet } = vi.hoisted(() => ({
|
|
mockDbGet: vi.fn((..._args: unknown[]) => undefined as any),
|
|
mockDbRun: vi.fn(),
|
|
mockInstanceGet: vi.fn((..._args: unknown[]) => undefined as any),
|
|
mockProviderGet: vi.fn((..._args: unknown[]) => undefined as any),
|
|
}));
|
|
|
|
vi.mock('../../../src/db/database', () => ({
|
|
db: {
|
|
prepare: (sql: string) => ({
|
|
get: (...args: unknown[]) => {
|
|
if (!sql.includes('app_settings')) return mockDbGet(...args);
|
|
return args[0] === 'places_provider' ? mockProviderGet(...args) : mockInstanceGet(...args);
|
|
},
|
|
all: vi.fn(() => []),
|
|
run: mockDbRun,
|
|
}),
|
|
},
|
|
}));
|
|
|
|
vi.mock('../../../src/utils/ssrfGuard', () => {
|
|
class SsrfBlockedError extends Error {}
|
|
return {
|
|
SsrfBlockedError,
|
|
checkSsrf: vi.fn(async () => ({ allowed: true })),
|
|
safeFetchFollow: vi.fn(async (url: string, init?: any) => (globalThis.fetch as any)(url, init)),
|
|
};
|
|
});
|
|
|
|
vi.mock('../../../src/nest/common/crypto/apiKeyCrypto', () => ({
|
|
decrypt_api_key: (v: string | null) => v,
|
|
maybe_encrypt_api_key: (v: string | null) => v,
|
|
}));
|
|
|
|
vi.mock('../../../src/config', () => ({ JWT_SECRET: 'test-secret', ENCRYPTION_KEY: '0'.repeat(64) }));
|
|
|
|
// The index answers search and autocomplete before any keyed provider, and
|
|
// trekPlacesEnabled fails open, so a service-level case that reached it with
|
|
// the Amap fetch stub in place would leave the runner for places.liketrek.com.
|
|
// Stubbed to "nothing found", which is the state that hands the query on.
|
|
vi.mock('../../../src/nest/maps/trek-places.client', async (importOriginal) => ({
|
|
...(await importOriginal<typeof import('../../../src/nest/maps/trek-places.client')>()),
|
|
trekPlacesSearch: vi.fn(async (): Promise<unknown[]> => []),
|
|
}));
|
|
|
|
import { db } from '../../../src/db/database';
|
|
import { DatabaseService } from '../../../src/nest/database/database.service';
|
|
import { MapsService } from '../../../src/nest/maps/maps.service';
|
|
import {
|
|
AmapPlacesProvider,
|
|
amapPoiId,
|
|
isAmapHost,
|
|
isAmapPlaceId,
|
|
parseAmapUrl,
|
|
} from '../../../src/nest/maps/providers/amap.provider';
|
|
import { isGooglePlaceId } from '../../../src/nest/maps/maps.helpers';
|
|
import type { PlacePhotoCacheService } from '../../../src/nest/place-photos/place-photo-cache.service';
|
|
|
|
const photoCacheStub = {
|
|
get: vi.fn(() => null),
|
|
getErrored: vi.fn(() => false),
|
|
put: vi.fn(),
|
|
markError: vi.fn(),
|
|
getInFlight: vi.fn(() => undefined),
|
|
setInFlight: vi.fn(),
|
|
serveKey: vi.fn(() => null),
|
|
} as unknown as PlacePhotoCacheService;
|
|
|
|
const svc = new MapsService(new DatabaseService(db as never), photoCacheStub);
|
|
|
|
/** A provider over a fixed key, which is all these cases need. */
|
|
function provider(): AmapPlacesProvider {
|
|
return new AmapPlacesProvider({ key: 'amap-test-key', source: 'instance', userId: 3 });
|
|
}
|
|
|
|
/** Amap's success envelope. */
|
|
function ok(body: Record<string, unknown>) {
|
|
return { ok: true, status: 200, json: async () => ({ status: '1', info: 'OK', infocode: '10000', ...body }) };
|
|
}
|
|
|
|
/** Amap's failure envelope — note the HTTP 200. */
|
|
function amapError(infocode: string, info = 'SOMETHING_WRONG') {
|
|
return { ok: true, status: 200, json: async () => ({ status: '0', info, infocode }) };
|
|
}
|
|
|
|
/** The URL the single stubbed call was made with. */
|
|
function calledUrl(): string {
|
|
return String((globalThis.fetch as any).mock.calls[0][0]);
|
|
}
|
|
|
|
/** One POI in Beijing, at a GCJ-02 coordinate. */
|
|
const TIANANMEN_GCJ = wgs84ToGcj02(39.90869, 116.39124);
|
|
const TIANANMEN_LOCATION = `${TIANANMEN_GCJ.lng.toFixed(6)},${TIANANMEN_GCJ.lat.toFixed(6)}`;
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
mockDbGet.mockReset();
|
|
mockDbGet.mockReturnValue(undefined);
|
|
mockDbRun.mockReset();
|
|
mockInstanceGet.mockReset();
|
|
mockInstanceGet.mockReturnValue(undefined);
|
|
mockProviderGet.mockReset();
|
|
mockProviderGet.mockReturnValue(undefined);
|
|
});
|
|
|
|
// ── Place id namespace ───────────────────────────────────────────────────────
|
|
|
|
describe('Amap place ids', () => {
|
|
it('AMAP-001: namespaces an Amap id so it can never be sent to Google', () => {
|
|
expect(isAmapPlaceId('amap:B000A83M61')).toBe(true);
|
|
expect(amapPoiId('amap:B000A83M61')).toBe('B000A83M61');
|
|
// The whole point: Google's own id check must reject it, because Google
|
|
// answers a foreign id with a BILLABLE 400 INVALID_ARGUMENT.
|
|
expect(isGooglePlaceId('amap:B000A83M61')).toBe(false);
|
|
// And a bare Amap id, which is what we would have had without the prefix,
|
|
// is indistinguishable from a Google one — hence the prefix.
|
|
expect(isGooglePlaceId('B000A83M61')).toBe(true);
|
|
});
|
|
|
|
it('AMAP-002: is not confused with an OSM id or a coordinate pseudo-id', () => {
|
|
expect(isAmapPlaceId('node:240109189')).toBe(false);
|
|
expect(isAmapPlaceId('coords:39.9,116.4')).toBe(false);
|
|
expect(isAmapPlaceId('ChIJ_____')).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ── searchText ───────────────────────────────────────────────────────────────
|
|
|
|
describe('AmapPlacesProvider.searchText', () => {
|
|
it('AMAP-010: converts the returned GCJ-02 coordinate back to WGS-84', async () => {
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn().mockResolvedValue(
|
|
ok({
|
|
pois: [
|
|
{
|
|
id: 'B000A83M61',
|
|
name: '天安门',
|
|
address: '东长安街',
|
|
location: TIANANMEN_LOCATION,
|
|
pname: '北京市',
|
|
cityname: '北京市',
|
|
adname: '东城区',
|
|
type: '风景名胜;风景名胜相关;旅游景点',
|
|
},
|
|
],
|
|
}),
|
|
),
|
|
);
|
|
|
|
const [place] = await provider().searchText('天安门');
|
|
|
|
// Back within a metre of where it started, i.e. the datum round-tripped.
|
|
expect(place.lat as number).toBeCloseTo(39.90869, 4);
|
|
expect(place.lng as number).toBeCloseTo(116.39124, 4);
|
|
expect(place.amap_poi_id).toBe('amap:B000A83M61');
|
|
expect(place.source).toBe('amap');
|
|
expect(place.types).toEqual(['风景名胜', '风景名胜相关', '旅游景点']);
|
|
});
|
|
|
|
it('AMAP-011: assembles a full address without repeating a municipality', async () => {
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn().mockResolvedValue(
|
|
ok({
|
|
pois: [
|
|
{
|
|
id: 'B1',
|
|
name: '故宫',
|
|
address: '景山前街4号',
|
|
location: TIANANMEN_LOCATION,
|
|
pname: '北京市',
|
|
cityname: '北京市',
|
|
adname: '东城区',
|
|
},
|
|
],
|
|
}),
|
|
),
|
|
);
|
|
const [place] = await provider().searchText('故宫');
|
|
expect(place.address).toBe('北京市东城区景山前街4号');
|
|
});
|
|
|
|
it('AMAP-012: survives the empty arrays Amap sends for absent string fields', async () => {
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn().mockResolvedValue(
|
|
ok({
|
|
pois: [
|
|
{
|
|
id: 'B2',
|
|
name: '某小店',
|
|
// These are what Amap really returns when the field has no value —
|
|
// not undefined, and not '': an empty ARRAY.
|
|
address: [],
|
|
tel: [],
|
|
website: [],
|
|
location: TIANANMEN_LOCATION,
|
|
pname: [],
|
|
cityname: [],
|
|
adname: [],
|
|
},
|
|
],
|
|
}),
|
|
),
|
|
);
|
|
const [place] = await provider().searchText('某小店');
|
|
// Not "[]" and not "[object Object]" — the failure this coercion prevents.
|
|
expect(place.address).toBe('');
|
|
expect(place.phone).toBeNull();
|
|
expect(place.website).toBeNull();
|
|
});
|
|
|
|
it('AMAP-013: uses place/around with a bias, because text search cannot be biased', async () => {
|
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(ok({ pois: [] })));
|
|
await provider().searchText('咖啡', 'zh', { lat: 31.2304, lng: 121.4737, radius: 3000 });
|
|
|
|
const url = calledUrl();
|
|
expect(url).toContain('/v3/place/around');
|
|
expect(url).toContain('radius=3000');
|
|
// The outbound coordinate is GCJ-02, i.e. NOT the WGS-84 value we passed in.
|
|
const location = new URL(url).searchParams.get('location')!;
|
|
const [lng, lat] = location.split(',').map(Number);
|
|
const back = gcj02ToWgs84(lat, lng);
|
|
expect(back.lat).toBeCloseTo(31.2304, 4);
|
|
expect(back.lng).toBeCloseTo(121.4737, 4);
|
|
expect(lat).not.toBeCloseTo(31.2304, 4);
|
|
});
|
|
|
|
it('AMAP-014: clamps the radius to the 50 km Amap accepts', async () => {
|
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(ok({ pois: [] })));
|
|
await provider().searchText('x', undefined, { lat: 31.2304, lng: 121.4737, radius: 900000 });
|
|
expect(calledUrl()).toContain('radius=50000');
|
|
});
|
|
|
|
it('AMAP-015: uses place/text without a bias', async () => {
|
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(ok({ pois: [] })));
|
|
await provider().searchText('外滩');
|
|
expect(calledUrl()).toContain('/v3/place/text');
|
|
});
|
|
|
|
it('AMAP-015b: a pois field that is not a list answers empty instead of throwing a 500', async () => {
|
|
// `as T` only ever described what the answer was meant to look like. The
|
|
// base URL is configurable, and an object here used to reach `.map` and
|
|
// throw a TypeError nobody caught between the provider and the controller.
|
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(ok({ pois: { id: 'B3' } })));
|
|
await expect(provider().searchText('外滩')).resolves.toEqual([]);
|
|
});
|
|
|
|
it('AMAP-015c: a body larger than the cap is refused before it is read', async () => {
|
|
const cancel = vi.fn().mockResolvedValue(undefined);
|
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
status: 200,
|
|
headers: { get: (n: string) => (n === 'content-length' ? String(50 * 1024 * 1024) : null) },
|
|
body: { getReader: () => ({ read: async () => ({ done: true }), cancel }), cancel },
|
|
json: async () => ({ status: '1', pois: [] }),
|
|
}));
|
|
|
|
await expect(provider().searchText('外滩')).rejects.toThrow(/more than/);
|
|
// The socket goes back rather than staying pinned on a body nobody reads.
|
|
expect(cancel).toHaveBeenCalled();
|
|
});
|
|
|
|
it('AMAP-016: keeps a POI that has no geometry rather than dropping it', async () => {
|
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(ok({ pois: [{ id: 'B3', name: '无坐标', location: [] }] })));
|
|
const [place] = await provider().searchText('无坐标');
|
|
expect(place.lat).toBeNull();
|
|
expect(place.lng).toBeNull();
|
|
expect(place.name).toBe('无坐标');
|
|
});
|
|
});
|
|
|
|
// ── Error translation ────────────────────────────────────────────────────────
|
|
|
|
describe('Amap error handling', () => {
|
|
it('AMAP-020: turns an invalid-key body into a 403, despite the HTTP 200', async () => {
|
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(amapError('10001', 'INVALID_USER_KEY')));
|
|
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
await expect(provider().searchText('x')).rejects.toMatchObject({ status: 403 });
|
|
// The hint has to name what an admin can change; Amap's own wording does not.
|
|
await expect(provider().searchText('x')).rejects.toThrow(/Web 服务/);
|
|
errorSpy.mockRestore();
|
|
});
|
|
|
|
it('AMAP-021: turns quota exhaustion into a 429', async () => {
|
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(amapError('10003')));
|
|
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
await expect(provider().searchText('x')).rejects.toMatchObject({ status: 429 });
|
|
errorSpy.mockRestore();
|
|
});
|
|
|
|
it('AMAP-022: reports the key source and the user, never the key', async () => {
|
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(amapError('10009')));
|
|
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
await expect(provider().searchText('x')).rejects.toMatchObject({ status: 403 });
|
|
const logged = errorSpy.mock.calls.map((c) => String(c[0])).join('\n');
|
|
expect(logged).toContain('keySource=instance');
|
|
expect(logged).toContain('userId=3');
|
|
expect(logged).not.toContain('amap-test-key');
|
|
errorSpy.mockRestore();
|
|
});
|
|
|
|
it('AMAP-023: an unknown infocode is a 502 and keeps Amap\u2019s own wording', async () => {
|
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(amapError('20800', 'OUT_OF_SERVICE')));
|
|
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
await expect(provider().searchText('x')).rejects.toMatchObject({ status: 502, message: /OUT_OF_SERVICE/ });
|
|
errorSpy.mockRestore();
|
|
});
|
|
});
|
|
|
|
// ── autocomplete ─────────────────────────────────────────────────────────────
|
|
|
|
describe('AmapPlacesProvider.autocomplete', () => {
|
|
it('AMAP-030: namespaces every suggestion id and caps the list at five', async () => {
|
|
const tips = Array.from({ length: 8 }, (_, i) => ({
|
|
id: `T${i}`,
|
|
name: `候选${i}`,
|
|
district: '北京市东城区',
|
|
address: '某路',
|
|
}));
|
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(ok({ tips })));
|
|
|
|
const suggestions = await provider().autocomplete('候选');
|
|
expect(suggestions).toHaveLength(5);
|
|
expect(suggestions[0].placeId).toBe('amap:T0');
|
|
expect(suggestions[0].mainText).toBe('候选0');
|
|
expect(suggestions[0].secondaryText).toBe('北京市东城区 某路');
|
|
expect(calledUrl()).toContain('/v3/assistant/inputtips');
|
|
});
|
|
|
|
it('AMAP-031: drops a tip with no id, which cannot be looked up afterwards', async () => {
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn().mockResolvedValue(ok({ tips: [{ name: '一条路', id: [] }, { id: 'T9', name: '有效的' }] })),
|
|
);
|
|
const suggestions = await provider().autocomplete('路');
|
|
expect(suggestions).toHaveLength(1);
|
|
expect(suggestions[0].placeId).toBe('amap:T9');
|
|
});
|
|
|
|
it('AMAP-032: biases around the centre of the viewport, in GCJ-02', async () => {
|
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(ok({ tips: [] })));
|
|
await provider().autocomplete('咖啡', 'zh', {
|
|
low: { lat: 31.2, lng: 121.4 },
|
|
high: { lat: 31.3, lng: 121.5 },
|
|
});
|
|
const location = new URL(calledUrl()).searchParams.get('location')!;
|
|
const [lng, lat] = location.split(',').map(Number);
|
|
const back = gcj02ToWgs84(lat, lng);
|
|
expect(back.lat).toBeCloseTo(31.25, 3);
|
|
expect(back.lng).toBeCloseTo(121.45, 3);
|
|
});
|
|
});
|
|
|
|
// ── placeDetails ─────────────────────────────────────────────────────────────
|
|
|
|
describe('AmapPlacesProvider.placeDetails', () => {
|
|
it('AMAP-040: looks up the bare poi id via v5 and returns WGS-84', async () => {
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn().mockResolvedValue(
|
|
ok({
|
|
pois: [
|
|
{
|
|
id: 'B000A83M61',
|
|
name: '天安门',
|
|
address: '东长安街',
|
|
location: TIANANMEN_LOCATION,
|
|
business: { rating: '4.8', tel: '010-12345678' },
|
|
},
|
|
],
|
|
}),
|
|
),
|
|
);
|
|
|
|
const place = await provider().placeDetails('amap:B000A83M61');
|
|
const url = calledUrl();
|
|
expect(url).toContain('/v5/place/detail');
|
|
// The prefix is ours, not Amap's — it must not reach the API.
|
|
expect(new URL(url).searchParams.get('id')).toBe('B000A83M61');
|
|
expect(place!.rating).toBe(4.8);
|
|
expect(place!.phone).toBe('010-12345678');
|
|
expect(place!.lat as number).toBeCloseTo(39.90869, 4);
|
|
});
|
|
|
|
it('AMAP-041: returns null for an id that is not an Amap id, without calling out', async () => {
|
|
const fetchSpy = vi.fn();
|
|
vi.stubGlobal('fetch', fetchSpy);
|
|
expect(await provider().placeDetails('ChIJsomething')).toBeNull();
|
|
expect(fetchSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('AMAP-042: returns null when Amap knows the id but has no POI for it', async () => {
|
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(ok({ pois: [] })));
|
|
expect(await provider().placeDetails('amap:B000A83M61')).toBeNull();
|
|
});
|
|
});
|
|
|
|
// ── reverse ──────────────────────────────────────────────────────────────────
|
|
|
|
describe('AmapPlacesProvider.reverse', () => {
|
|
it('AMAP-050: sends GCJ-02 and prefers the AOI the click landed inside', async () => {
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn().mockResolvedValue(
|
|
ok({
|
|
regeocode: {
|
|
formatted_address: '北京市东城区天安门广场',
|
|
aois: [{ name: '天安门广场' }],
|
|
pois: [{ name: '国旗杆' }],
|
|
addressComponent: { building: { name: '某楼' } },
|
|
},
|
|
}),
|
|
),
|
|
);
|
|
|
|
const answer = await provider().reverse(39.90869, 116.39124);
|
|
expect(answer).toEqual({ name: '天安门广场', address: '北京市东城区天安门广场' });
|
|
const location = new URL(calledUrl()).searchParams.get('location')!;
|
|
const [lng, lat] = location.split(',').map(Number);
|
|
expect(gcj02ToWgs84(lat, lng).lat).toBeCloseTo(39.90869, 4);
|
|
});
|
|
|
|
it('AMAP-051: falls through the name candidates when there is no AOI', async () => {
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn().mockResolvedValue(
|
|
ok({ regeocode: { formatted_address: '某地址', aois: [], pois: [], addressComponent: { neighborhood: { name: '某社区' } } } }),
|
|
),
|
|
);
|
|
expect((await provider().reverse(39.9, 116.4))!.name).toBe('某社区');
|
|
});
|
|
});
|
|
|
|
// ── Shared-link parsing ──────────────────────────────────────────────────────
|
|
|
|
describe('parseAmapUrl', () => {
|
|
it('AMAP-060: reads uri.amap.com marker links as lng,lat in GCJ-02', () => {
|
|
const parsed = parseAmapUrl(`https://uri.amap.com/marker?position=${TIANANMEN_LOCATION}&name=%E5%A4%A9%E5%AE%89%E9%97%A8`);
|
|
expect(parsed).not.toBeNull();
|
|
// Longitude first AND a datum shift: reading this link the Google way would
|
|
// put a Beijing landmark in the Indian Ocean.
|
|
expect(parsed!.lat).toBeCloseTo(39.90869, 4);
|
|
expect(parsed!.lng).toBeCloseTo(116.39124, 4);
|
|
expect(parsed!.name).toBe('天安门');
|
|
});
|
|
|
|
it('AMAP-061: picks the poi id out of a place page', () => {
|
|
const parsed = parseAmapUrl('https://www.amap.com/place/B000A83M61');
|
|
expect(parsed!.poiId).toBe('B000A83M61');
|
|
expect(Number.isNaN(parsed!.lat)).toBe(true);
|
|
});
|
|
|
|
it('AMAP-062: refuses a host that is not Amap', () => {
|
|
expect(parseAmapUrl('https://amap.com.evil.example/marker?position=116,39')).toBeNull();
|
|
expect(parseAmapUrl('not a url')).toBeNull();
|
|
});
|
|
|
|
it('AMAP-063: recognises only Amap\u2019s own hosts', () => {
|
|
expect(isAmapHost('uri.amap.com')).toBe(true);
|
|
expect(isAmapHost('www.amap.com')).toBe(true);
|
|
expect(isAmapHost('surl.amap.com')).toBe(true);
|
|
expect(isAmapHost('amap.com.attacker.net')).toBe(false);
|
|
expect(isAmapHost('notamap.com')).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ── Provider selection ───────────────────────────────────────────────────────
|
|
|
|
/** The user row resolveApiKey reads, with both key columns on it. */
|
|
type KeyRow = { maps_api_key: string | null; amap_api_key: string | null };
|
|
|
|
/** Make the Google key chain answer, the Amap chain answer, or neither. */
|
|
function keys(opts: { google?: string; amap?: string }) {
|
|
mockDbGet.mockImplementation((..._args: unknown[]): KeyRow => {
|
|
// resolveApiKey reads the caller's own row per name; the SQL differs but
|
|
// the stub only sees the bound userId, so answer both columns at once.
|
|
return { maps_api_key: opts.google ?? null, amap_api_key: opts.amap ?? null };
|
|
});
|
|
}
|
|
|
|
describe('MapsService.keyedProvider', () => {
|
|
it('AMAP-070: auto keeps Google when a Google key is configured', () => {
|
|
keys({ google: 'gkey', amap: 'akey' });
|
|
expect(svc.keyedProvider(1)).toMatchObject({ id: 'google', key: 'gkey', source: 'user-row' });
|
|
expect(svc.resolvePlacesProvider(1)).toBeNull();
|
|
});
|
|
|
|
it('AMAP-071: auto falls to Amap only when there is no Google key', () => {
|
|
keys({ amap: 'akey' });
|
|
expect(svc.keyedProvider(1)?.id).toBe('amap');
|
|
expect(svc.resolvePlacesProvider(1)).toBeInstanceOf(AmapPlacesProvider);
|
|
});
|
|
|
|
it('AMAP-072: auto with no key at all means the OpenStreetMap stack', () => {
|
|
keys({});
|
|
expect(svc.keyedProvider(1)).toBeNull();
|
|
expect(svc.resolvePlacesProvider(1)).toBeNull();
|
|
});
|
|
|
|
it('AMAP-073: an explicit amap choice wins over a configured Google key', () => {
|
|
mockProviderGet.mockReturnValue({ value: 'amap' });
|
|
keys({ google: 'gkey', amap: 'akey' });
|
|
expect(svc.resolvePlacesProvider(1)).toBeInstanceOf(AmapPlacesProvider);
|
|
});
|
|
|
|
it('AMAP-074: an explicit google choice never silently uses Amap instead', () => {
|
|
mockProviderGet.mockReturnValue({ value: 'google' });
|
|
keys({ amap: 'akey' });
|
|
// Misconfigured means "answer with OSM", not "bill somebody else's provider".
|
|
expect(svc.keyedProvider(1)).toBeNull();
|
|
});
|
|
|
|
it('AMAP-075: openstreetmap ignores both keys', () => {
|
|
mockProviderGet.mockReturnValue({ value: 'openstreetmap' });
|
|
keys({ google: 'gkey', amap: 'akey' });
|
|
expect(svc.keyedProvider(1)).toBeNull();
|
|
});
|
|
|
|
it('AMAP-076: a hand-edited nonsense value degrades to auto instead of failing', () => {
|
|
mockProviderGet.mockReturnValue({ value: 'not-a-provider' });
|
|
keys({ google: 'gkey' });
|
|
expect(svc.placesProviderChoice()).toBe('auto');
|
|
expect(svc.keyedProvider(1)?.id).toBe('google');
|
|
});
|
|
|
|
it('AMAP-077: an Amap place stays with Amap even while Google is selected', async () => {
|
|
mockProviderGet.mockReturnValue({ value: 'google' });
|
|
keys({ google: 'gkey', amap: 'akey' });
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn().mockResolvedValue(ok({ pois: [{ id: 'B9', name: '旧地点', location: TIANANMEN_LOCATION }] })),
|
|
);
|
|
|
|
const { place } = await svc.getPlaceDetails(1, 'amap:B9');
|
|
// Reached Amap's endpoint, not Google's — an id outlives the setting.
|
|
expect(calledUrl()).toContain('restapi.amap.com');
|
|
expect(place!.name).toBe('旧地点');
|
|
});
|
|
|
|
it('AMAP-077b: a Google place still opens after the admin switches to Amap', async () => {
|
|
// The mirror of AMAP-077, and the one that matters on a real migration: an
|
|
// install with a year of google_place_id rows switches provider, and every
|
|
// one of those places has to keep opening against the Google key that is
|
|
// still configured.
|
|
mockProviderGet.mockReturnValue({ value: 'amap' });
|
|
keys({ google: 'gkey', amap: 'akey' });
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => ({ id: 'ChIJ123', displayName: { text: 'Old Google place' }, location: { latitude: 48.8, longitude: 2.3 } }),
|
|
}),
|
|
);
|
|
|
|
const { place } = await svc.getPlaceDetails(1, 'ChIJ123');
|
|
expect(calledUrl()).toContain('places.googleapis.com');
|
|
expect(place!.name).toBe('Old Google place');
|
|
});
|
|
|
|
it('AMAP-078: an Amap id is never mistaken for an OSM type:id pair', async () => {
|
|
// `amap:B9` contains a colon, like `node:123`. Read as OSM it would go to
|
|
// Overpass as element type "amap" and answer every Chinese place with a
|
|
// blank record — so the ordering of those two branches is load-bearing.
|
|
mockProviderGet.mockReturnValue({ value: 'amap' });
|
|
keys({ amap: 'akey' });
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn().mockResolvedValue(ok({ pois: [{ id: 'B9', name: '天安门', location: TIANANMEN_LOCATION }] })),
|
|
);
|
|
const { place } = await svc.getPlaceDetails(1, 'amap:B9');
|
|
expect(place!.source).toBe('amap');
|
|
expect(calledUrl()).not.toContain('overpass');
|
|
});
|
|
});
|
|
|
|
describe('MapsService with Amap in the keyed slot', () => {
|
|
// The index and OpenStreetMap are asked first either way; these cases are
|
|
// about what answers once they have nothing, so the index is switched off
|
|
// the way maps.service.test.ts does it for the Google cases.
|
|
let indexSpy: { mockRestore: () => void } | null = null;
|
|
afterEach(() => {
|
|
indexSpy?.mockRestore();
|
|
indexSpy = null;
|
|
});
|
|
function amapSelected() {
|
|
indexSpy = vi.spyOn(svc, 'trekPlacesEnabled').mockReturnValue(false);
|
|
mockProviderGet.mockReturnValue({ value: 'amap' });
|
|
keys({ amap: 'akey' });
|
|
}
|
|
|
|
it('AMAP-080: search reports amap as the source so the client can credit it', async () => {
|
|
amapSelected();
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn().mockResolvedValue(ok({ pois: [{ id: 'B1', name: '外滩', location: TIANANMEN_LOCATION }] })),
|
|
);
|
|
|
|
const result = await svc.searchPlaces(1, '外滩');
|
|
expect(result.source).toBe('amap');
|
|
expect(result.places).toHaveLength(1);
|
|
expect(result.places[0].amap_poi_id).toBe('amap:B1');
|
|
});
|
|
|
|
it('AMAP-081: autocomplete goes to inputtips instead of Nominatim', async () => {
|
|
amapSelected();
|
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(ok({ tips: [{ id: 'T1', name: '外滩', district: '上海市黄浦区' }] })));
|
|
|
|
const result = await svc.autocompletePlaces(1, '外滩');
|
|
expect(result.source).toBe('amap');
|
|
expect(result.suggestions[0].placeId).toBe('amap:T1');
|
|
expect(calledUrl()).toContain('/v3/assistant/inputtips');
|
|
});
|
|
|
|
it('AMAP-082: reverse geocoding asks Amap at the instance key, and falls back to Nominatim when it fails', async () => {
|
|
// No user behind a reverse lookup: the chain stops at the instance-wide row.
|
|
mockProviderGet.mockReturnValue({ value: 'amap' });
|
|
mockInstanceGet.mockReturnValue({ value: 'akey' });
|
|
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
const fetchSpy = vi
|
|
.fn()
|
|
.mockResolvedValueOnce(amapError('10003'))
|
|
.mockResolvedValueOnce({ ok: true, json: async () => ({ name: '某处', display_name: '某地址' }) });
|
|
vi.stubGlobal('fetch', fetchSpy);
|
|
|
|
const answer = await svc.reverseGeocode('39.9', '116.4');
|
|
expect(answer).toEqual({ name: '某处', address: '某地址' });
|
|
const calls = fetchSpy.mock.calls.map(call => String(call[0]));
|
|
expect(calls[0]).toContain('/v3/geocode/regeo');
|
|
expect(calls[1]).toContain('nominatim');
|
|
errorSpy.mockRestore();
|
|
});
|
|
|
|
it('AMAP-082b: a point outside the Amap box goes straight to Nominatim', async () => {
|
|
// Lisbon. Amap holds the slot, but it knows nothing out here, so asking it
|
|
// buys a round trip and an empty answer before the fallback runs anyway.
|
|
mockProviderGet.mockReturnValue({ value: 'amap' });
|
|
mockInstanceGet.mockReturnValue({ value: 'akey' });
|
|
const fetchSpy = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ name: 'Belém', display_name: 'Lisboa' }) });
|
|
vi.stubGlobal('fetch', fetchSpy);
|
|
|
|
const answer = await svc.reverseGeocode('38.6916', '-9.2160');
|
|
|
|
expect(answer).toEqual({ name: 'Belém', address: 'Lisboa' });
|
|
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
expect(String(fetchSpy.mock.calls[0][0])).toContain('nominatim');
|
|
});
|
|
|
|
it('AMAP-083: a pasted Amap marker link resolves without touching the Google path', async () => {
|
|
// Auto with no key at all: the address comes from Nominatim, the coordinate
|
|
// and the name from the link itself, already converted to WGS-84.
|
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => ({ display_name: '北京市东城区' }) }));
|
|
|
|
const result = await svc.resolveGoogleMapsUrl(
|
|
`https://uri.amap.com/marker?position=${TIANANMEN_LOCATION}&name=%E5%A4%A9%E5%AE%89%E9%97%A8`,
|
|
);
|
|
expect(result.lat).toBeCloseTo(39.90869, 4);
|
|
expect(result.lng).toBeCloseTo(116.39124, 4);
|
|
expect(result.name).toBe('天安门');
|
|
expect(result.address).toBe('北京市东城区');
|
|
expect(result.google_ftid).toBeNull();
|
|
expect(calledUrl()).toContain('nominatim');
|
|
});
|
|
|
|
it('AMAP-084: an Amap POI page with no coordinate is the same 400 a bare Google page gets', async () => {
|
|
const fetchSpy = vi.fn();
|
|
vi.stubGlobal('fetch', fetchSpy);
|
|
await expect(svc.resolveGoogleMapsUrl('https://www.amap.com/place/B000A83M61')).rejects.toMatchObject({ status: 400 });
|
|
expect(fetchSpy).not.toHaveBeenCalled();
|
|
});
|
|
});
|