mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
7397e33c31
A Google place URL carries two points: @lat,lng is the centre of the map view, the !3d!4d data param is the place itself. extractCoords took the camera first, so a pasted share link put the place around 170 m off and reverse geocoded the wrong street. The data param is now read before @; the order of @ and ?q=/?ll= is unchanged. REST and the resolve_maps_url MCP tool share the resolver, so both pick this up. Refs #2635
3801 lines
160 KiB
TypeScript
3801 lines
160 KiB
TypeScript
/**
|
||
* Unit tests for the DI-native MapsService — MAPS-001 through MAPS-110.
|
||
* Moved 1:1 from the legacy tests/unit/services/mapsService.test.ts when the
|
||
* maps domain folded into src/nest/maps/ (case IDs preserved; the wrapper
|
||
* suite's delegation cases died with the delegation itself — the kill-switch,
|
||
* photoBytesPath and bridge cases live at the bottom).
|
||
* Covers parseOpeningHours, buildOsmDetails, getMapsKey, reverseGeocode,
|
||
* resolveGoogleMapsUrl (coordinate extraction + short URL / SSRF),
|
||
* searchNominatim, fetchOverpassDetails, fetchWikimediaPhoto, searchPlaces,
|
||
* getPlaceDetails, and getPlacePhoto (all branches including cache logic).
|
||
* fetch is stubbed; DB and ssrfGuard are mocked.
|
||
*/
|
||
import {
|
||
parseOpeningHours,
|
||
normalizeOpeningPeriods,
|
||
normalizeSpecialDays,
|
||
buildOsmDetails,
|
||
googleFtidFromMapsUrl,
|
||
isGooglePlaceId,
|
||
clampPoiBbox,
|
||
MAX_POI_BBOX_SPAN_DEG,
|
||
buildUserAgent,
|
||
resolveOverpassEndpoints,
|
||
resolveOverpassTimeoutMs,
|
||
OVERPASS_QUERY_TIMEOUT_S,
|
||
OVERPASS_TIMEOUT_DEFAULT_MS,
|
||
stripWikiMarkup,
|
||
parseWikipediaTag,
|
||
rankCommonsCandidates,
|
||
type RankableCommonsCandidate,
|
||
} from '../../../src/nest/maps/maps.helpers';
|
||
|
||
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
|
||
import { Jimp } from 'jimp';
|
||
import { noGoogleQuota } from '../../helpers/google-quota';
|
||
|
||
// The seams below stand in for real collaborators, so they are typed from those
|
||
// collaborators' signatures rather than from their own default implementations.
|
||
// checkSsrf answers with a full SsrfResult; the guard path exercised here reads
|
||
// only these two fields, so the stub returns that slice instead of inventing a
|
||
// resolved IP and a privacy verdict no assertion looks at.
|
||
type SsrfCheckStub = Pick<SsrfResult, 'allowed' | 'error'>;
|
||
|
||
const {
|
||
mockDbGet,
|
||
mockDbRun,
|
||
mockInstanceGet,
|
||
preparedSql,
|
||
mockCheckSsrf,
|
||
mockCacheGet,
|
||
mockCacheGetErrored,
|
||
mockCacheMarkError,
|
||
mockCachePut,
|
||
mockCacheGetInFlight,
|
||
mockCacheSetInFlight,
|
||
mockServeFilePath,
|
||
mockProviderGet,
|
||
} = vi.hoisted(() => ({
|
||
mockDbGet: vi.fn((..._args: unknown[]) => undefined as any),
|
||
mockDbRun: vi.fn(),
|
||
// The instance-wide key row (#1939) is read before the caller's own row, so it
|
||
// gets its own seam: every case below that stubs mockDbGet means "this user's
|
||
// row holds a key", and would otherwise have its stub eaten by the
|
||
// app_settings lookup.
|
||
mockInstanceGet: vi.fn((..._args: unknown[]) => undefined as any),
|
||
// The places_provider row is read before the key chain, so it needs a seam of
|
||
// its own: routed through mockInstanceGet it would eat the mockReturnValueOnce
|
||
// that a case meant for the instance-wide KEY, and the service would resolve a
|
||
// key value as a provider name. Default undefined = the 'auto' choice, which is
|
||
// what every install that predates Amap has.
|
||
mockProviderGet: vi.fn((..._args: unknown[]) => undefined as any),
|
||
preparedSql: [] as string[],
|
||
mockCheckSsrf: vi.fn(async (_url: string, _bypassInternalIpAllowed?: boolean): Promise<SsrfCheckStub> => ({
|
||
allowed: true,
|
||
})),
|
||
mockCacheGet: vi.fn((_placeId: string) => null as ReturnType<PlacePhotoCacheService['get']>),
|
||
mockCacheGetErrored: vi.fn((_placeId: string) => false),
|
||
mockCacheMarkError: vi.fn(),
|
||
mockCachePut: vi.fn(async (placeId: string, _bytes: Buffer, attribution: string | null) => ({
|
||
photoUrl: `/api/maps/place-photo/${encodeURIComponent(placeId)}/bytes`,
|
||
filePath: `/tmp/${placeId}.jpg`,
|
||
attribution,
|
||
})),
|
||
mockCacheGetInFlight: vi.fn(
|
||
(_placeId: string) => undefined as ReturnType<PlacePhotoCacheService['getInFlight']>,
|
||
),
|
||
mockCacheSetInFlight: vi.fn(),
|
||
mockServeFilePath: vi.fn((_placeId: string) => null as string | null),
|
||
}));
|
||
|
||
vi.mock('../../../src/db/database', () => ({
|
||
db: {
|
||
prepare: (sql: string) => {
|
||
preparedSql.push(sql);
|
||
return {
|
||
get: (...args: unknown[]) => {
|
||
if (!sql.includes('app_settings')) return mockDbGet(...args);
|
||
// Keyed by the bound parameter, not just by the table: app_settings
|
||
// holds both the instance-wide API keys and the provider choice.
|
||
return args[0] === 'places_provider' ? mockProviderGet(...args) : mockInstanceGet(...args);
|
||
},
|
||
all: vi.fn(() => []),
|
||
run: mockDbRun,
|
||
};
|
||
},
|
||
},
|
||
}));
|
||
|
||
vi.mock('../../../src/utils/ssrfGuard', () => {
|
||
class SsrfBlockedError extends Error {
|
||
constructor(message: string) {
|
||
super(message);
|
||
this.name = 'SsrfBlockedError';
|
||
}
|
||
}
|
||
return {
|
||
checkSsrf: mockCheckSsrf,
|
||
SsrfBlockedError,
|
||
// Mirror the real per-hop helper closely enough for unit tests: run the
|
||
// (mocked) SSRF check, then fetch through the (stubbed) global fetch. The
|
||
// fetch stubs in these tests already return the final resolved response.
|
||
safeFetchFollow: vi.fn(async (url: string, init?: any) => {
|
||
const ssrf = await mockCheckSsrf(url);
|
||
if (!ssrf.allowed) throw new SsrfBlockedError(ssrf.error ?? 'Request blocked by SSRF guard');
|
||
return (globalThis.fetch as any)(url, init);
|
||
}),
|
||
};
|
||
});
|
||
|
||
vi.mock('../../../src/nest/common/crypto/apiKeyCrypto', () => ({
|
||
decrypt_api_key: (v: string | null) => v,
|
||
// Unused by the read paths here, but instance-api-keys imports it.
|
||
maybe_encrypt_api_key: (v: string | null) => v,
|
||
}));
|
||
|
||
// pois(), searchPlaces() and autocompletePlaces() all ask the index before the
|
||
// old path, and trekPlacesEnabled fails open — so a case that reaches one of
|
||
// them with no fetch stub in place leaves the runner for places.liketrek.com.
|
||
//
|
||
// Search is stubbed for a second reason. The fetch stubs in this file answer
|
||
// with `json()` and no `text()`, which is not what the index client reads, so
|
||
// the index branch was entered and then failed on the stub. That made the cases
|
||
// pass for the wrong reason — 'uses Nominatim when user has no API key' was
|
||
// really testing that a stub is incomplete — and it left the client's circuit
|
||
// breaker counting real consecutive failures, which is process state and leaks
|
||
// into whatever file runs next. The index paths themselves are covered in
|
||
// maps.pois.test.ts, maps.autocomplete.test.ts and maps.search-merge.test.ts.
|
||
const { mockNearby, mockSearch } = vi.hoisted(() => ({
|
||
mockNearby: vi.fn(async (): Promise<unknown[]> => []),
|
||
mockSearch: vi.fn(async (): Promise<unknown[]> => []),
|
||
}));
|
||
vi.mock('../../../src/nest/maps/trek-places.client', async (importOriginal) => ({
|
||
...(await importOriginal<typeof import('../../../src/nest/maps/trek-places.client')>()),
|
||
trekPlacesNearby: mockNearby,
|
||
trekPlacesSearch: mockSearch,
|
||
}));
|
||
|
||
// Injected stub since the photo-cache fold (was a path mock of the module).
|
||
// Same seven seams, same mock functions behind them.
|
||
const photoCacheStub = {
|
||
get: (placeId: string) => mockCacheGet(placeId),
|
||
getErrored: (placeId: string) => mockCacheGetErrored(placeId),
|
||
put: (placeId: string, bytes: Buffer, attribution: string | null) => mockCachePut(placeId, bytes, attribution),
|
||
markError: (placeId: string, kind?: string) => mockCacheMarkError(placeId, kind),
|
||
getInFlight: (placeId: string) => mockCacheGetInFlight(placeId),
|
||
setInFlight: (placeId: string, p: Promise<any>) => mockCacheSetInFlight(placeId, p),
|
||
serveKey: (placeId: string) => mockServeFilePath(placeId),
|
||
} as unknown as PlacePhotoCacheService;
|
||
|
||
import { MapsService, withPhotoFetchSlot, readWikiIdentity } from '../../../src/nest/maps/maps.service';
|
||
import { OsmClient } from '../../../src/nest/maps/providers/osm.client';
|
||
import { buildMapsParts, buildMapsService } from '../../helpers/maps-service';
|
||
import type { PlacePhotoCacheService } from '../../../src/nest/place-photos/place-photo-cache.service';
|
||
import type { AppSettingsRepository } from '../../../src/db/repositories/AppSettings.repository';
|
||
import type { UsersRepository } from '../../../src/db/repositories/Users.repository';
|
||
import type { PlaceDetailsCacheRepository } from '../../../src/db/repositories/PlaceDetailsCache.repository';
|
||
import type { PlacesRepository } from '../../../src/db/repositories/Places.repository';
|
||
// Type-only, so the module stays mocked: this import is erased at runtime.
|
||
import type { SsrfResult } from '../../../src/utils/ssrfGuard';
|
||
|
||
// resolveMapsKey/resolveAmapKey (maps.service.ts) now read AppSettingsRepository/
|
||
// UsersRepository directly (Plan 3a Task 5) instead of raw SQL through the
|
||
// mocked db module above — these two stubs wire the SAME mockInstanceGet/
|
||
// mockDbGet seams the rest of this file already controls into the new
|
||
// repository methods, so every existing mockInstanceGet/mockDbGet call below
|
||
// keeps its meaning unchanged. `places_provider` keeps its own dedicated
|
||
// mockProviderGet seam (mirroring the pre-conversion raw-SQL mock's own
|
||
// `args[0] === 'places_provider'` branch) — MAP2 (`placesProviderChoice`)
|
||
// reads that key through this SAME stub now, and the amap-provider-choice
|
||
// suite (`mockProviderGet.mockReturnValue(...)`) still drives it.
|
||
const appSettingsStub = {
|
||
getValue: async (key: string) =>
|
||
(key === 'places_provider' ? mockProviderGet(key) : (mockInstanceGet(key) as { value: string | null } | undefined))?.value ?? null,
|
||
} as unknown as AppSettingsRepository;
|
||
const usersStub = {
|
||
getApiKeyColumn: async (userId: number, name: 'maps_api_key' | 'amap_api_key') => {
|
||
const row = mockDbGet(userId) as { maps_api_key?: string | null; amap_api_key?: string | null } | undefined;
|
||
return row?.[name] ?? null;
|
||
},
|
||
} as unknown as UsersRepository;
|
||
|
||
// Plan 3h Task 4 (R8/MAP9): MAP3-8 (`place_details_cache`) and MAP9
|
||
// (`places.image_url`) used to be raw `this.database.get`/`.run` calls,
|
||
// intercepted by the SAME mockDbGet/mockDbRun seams every other bare `db.get`/
|
||
// `db.run` call in this file already flows through. These stubs preserve that
|
||
// exact positional-argument shape (the SQL text itself was never bound, so
|
||
// dropping it costs nothing) so every existing mockDbGet/mockDbRun
|
||
// configuration and assertion below keeps its meaning unchanged.
|
||
const placeDetailsCacheStub = {
|
||
findEntry: async (placeId: string, lang: string, _kind: number) => {
|
||
const row = mockDbGet(placeId, lang) as { payload_json: string; fetched_at: number } | undefined;
|
||
return row ? { payload_json: row.payload_json, fetched_at: row.fetched_at } : null;
|
||
},
|
||
upsertEntry: async (row: { place_id: string; lang: string; expanded: number; payload_json: string; fetched_at: number }) => {
|
||
mockDbRun(row.place_id, row.lang, row.payload_json, row.fetched_at);
|
||
},
|
||
} as unknown as PlaceDetailsCacheRepository;
|
||
const placesStub = {
|
||
setImageUrlIfUnset: async (google_place_id: string, image_url: string) => {
|
||
mockDbRun(image_url, google_place_id);
|
||
return 1;
|
||
},
|
||
} as unknown as PlacesRepository;
|
||
|
||
// The service under test, constructed over the mocked seams above — every
|
||
// collaborator that used to reach the mocked db module directly now routes
|
||
// through a repository stub that flows into the SAME mockDbGet/mockDbRun/
|
||
// mockInstanceGet/mockProviderGet functions, so they keep firing exactly as
|
||
// they did for the legacy module.
|
||
const { svc, google, osm, wiki } = buildMapsParts(photoCacheStub, appSettingsStub, usersStub, placeDetailsCacheStub, placesStub, noGoogleQuota);
|
||
|
||
/**
|
||
* Switch the TREK Places index off for one case.
|
||
*
|
||
* The index now answers search and autocomplete before Google is asked, which
|
||
* is the point of it. A case that is about the shape of the GOOGLE request has
|
||
* to take the index out of the way first, or it asserts against the index's URL
|
||
* and fails for the right reason.
|
||
*
|
||
* Spied on the method rather than driven through app_settings, because
|
||
* trekPlacesEnabled writes its key straight into the SQL and the db stub only
|
||
* sees bound parameters. The default and the 'false' path are covered in
|
||
* maps.area.test.ts, which does go through the setting.
|
||
*/
|
||
let indexSpy: { mockRestore: () => void } | null = null;
|
||
function indexOff(): void {
|
||
indexSpy = vi.spyOn(svc, 'trekPlacesEnabled').mockReturnValue(false);
|
||
}
|
||
|
||
afterEach(() => {
|
||
indexSpy?.mockRestore();
|
||
indexSpy = null;
|
||
vi.unstubAllGlobals();
|
||
mockDbGet.mockReset();
|
||
mockDbGet.mockReturnValue(undefined);
|
||
mockInstanceGet.mockReset();
|
||
mockInstanceGet.mockReturnValue(undefined);
|
||
mockProviderGet.mockReset();
|
||
mockProviderGet.mockReturnValue(undefined);
|
||
preparedSql.length = 0;
|
||
mockDbRun.mockReset();
|
||
mockCheckSsrf.mockReset();
|
||
mockCheckSsrf.mockResolvedValue({ allowed: true });
|
||
mockCacheGet.mockReset();
|
||
mockCacheGet.mockReturnValue(null);
|
||
mockCacheGetErrored.mockReset();
|
||
mockCacheGetErrored.mockReturnValue(false);
|
||
mockCacheMarkError.mockReset();
|
||
mockCachePut.mockReset();
|
||
mockCachePut.mockImplementation(async (placeId: string, _bytes: Buffer, attribution: string | null) => ({
|
||
photoUrl: `/api/maps/place-photo/${encodeURIComponent(placeId)}/bytes`,
|
||
filePath: `/tmp/${placeId}.jpg`,
|
||
attribution,
|
||
}));
|
||
mockCacheGetInFlight.mockReset();
|
||
mockCacheGetInFlight.mockReturnValue(undefined);
|
||
mockCacheSetInFlight.mockReset();
|
||
mockServeFilePath.mockReset();
|
||
mockServeFilePath.mockReturnValue(null);
|
||
});
|
||
|
||
// ── parseOpeningHours ─────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* CPU milliseconds burned by `fn`.
|
||
*
|
||
* The ReDoS budgets below used to measure wall-clock time, which on a loaded
|
||
* machine reports how long the test waited for a core rather than how long the
|
||
* regex ran — MAPS-024 has gone red at 523 ms on an unchanged file for exactly
|
||
* that reason. Catastrophic backtracking burns CPU, so CPU time is both the
|
||
* honest signal and a STRICTER one: cpu <= wall always, so no budget here got
|
||
* looser. vitest.config.ts uses pool:'forks', so this counts only this file.
|
||
*/
|
||
function cpuMillis(fn: () => void): number {
|
||
const before = process.cpuUsage();
|
||
fn();
|
||
const d = process.cpuUsage(before);
|
||
return (d.user + d.system) / 1000;
|
||
}
|
||
|
||
describe('parseOpeningHours', () => {
|
||
it('MAPS-001: returns 7 weekday descriptions and openNow', () => {
|
||
const result = parseOpeningHours('Mo-Fr 09:00-18:00');
|
||
expect(result.weekdayDescriptions).toHaveLength(7);
|
||
expect(result.weekdayDescriptions[0]).toContain('Monday: 09:00-18:00');
|
||
expect(typeof result.openNow === 'boolean' || result.openNow === null).toBe(true);
|
||
});
|
||
|
||
it('MAPS-002: marks unknown days with ?', () => {
|
||
const result = parseOpeningHours('Mo 10:00-12:00');
|
||
expect(result.weekdayDescriptions[1]).toContain('?');
|
||
});
|
||
|
||
it('MAPS-003: handles multiple segments separated by semicolons', () => {
|
||
const result = parseOpeningHours('Mo-Fr 09:00-18:00; Sa 10:00-14:00');
|
||
expect(result.weekdayDescriptions[5]).toContain('Saturday: 10:00-14:00');
|
||
expect(result.weekdayDescriptions[0]).toContain('Monday: 09:00-18:00');
|
||
});
|
||
|
||
it('MAPS-004: reads 24/7 as open around the clock, every day', () => {
|
||
// This case used to assert only that nothing crashed, and nothing did: all
|
||
// seven lines came back as "?", buildOsmDetails then dropped the hours
|
||
// entirely, and airports, main stations and petrol stations — the places
|
||
// most likely to be tagged this way — showed no opening hours at all.
|
||
const result = parseOpeningHours('24/7');
|
||
|
||
expect(result.weekdayDescriptions).toHaveLength(7);
|
||
expect(result.weekdayDescriptions[0]).toBe('Monday: 00:00-24:00');
|
||
expect(result.weekdayDescriptions[6]).toBe('Sunday: 00:00-24:00');
|
||
expect(result.openNow).toBe(true);
|
||
// A single period with no close is how "never closes" is spelled.
|
||
expect(result.periods).toEqual([{ open: { day: 0, hour: 0, minute: 0 }, close: null }]);
|
||
});
|
||
|
||
it('MAPS-004b: reads a bare "open" the same way, and leaves other strings alone', () => {
|
||
expect(parseOpeningHours('open').openNow).toBe(true);
|
||
// Only a bare "24/7" short-circuits. Anything with weekdays in front of it
|
||
// is an ordinary segment and keeps going through the normal parser.
|
||
expect(parseOpeningHours('Mo-Fr 24/7').weekdayDescriptions[0]).toBe('Monday: 24/7');
|
||
expect(parseOpeningHours('Mo-Fr 24/7').weekdayDescriptions[6]).toBe('Sunday: ?');
|
||
});
|
||
|
||
it('MAPS-005: returns openNow null for unparseable format', () => {
|
||
const result = parseOpeningHours('invalid-hours-string');
|
||
expect(result.openNow).toBeNull();
|
||
});
|
||
|
||
it('MAPS-005b: a weekday range that wraps the whole week covers all seven days', () => {
|
||
// "Mo-Su" is how a place open daily is usually tagged, and it produced
|
||
// exactly ONE day: the loop's exit condition was already true on entry, so
|
||
// the body never ran and only the closing day was added.
|
||
const daily = parseOpeningHours('Mo-Su 11:30-23:00');
|
||
expect(daily.weekdayDescriptions.filter((l) => !l.endsWith('?'))).toHaveLength(7);
|
||
expect(daily.weekdayDescriptions[0]).toBe('Monday: 11:30-23:00');
|
||
expect(daily.weekdayDescriptions[6]).toBe('Sunday: 11:30-23:00');
|
||
|
||
// Same shape starting anywhere else in the week.
|
||
expect(parseOpeningHours('Tu-Mo 08:00-20:00').weekdayDescriptions.filter((l) => !l.endsWith('?'))).toHaveLength(7);
|
||
|
||
// Ranges that do not wrap are unchanged.
|
||
expect(parseOpeningHours('Mo-Fr 09:00-18:00').weekdayDescriptions.filter((l) => !l.endsWith('?'))).toHaveLength(5);
|
||
expect(parseOpeningHours('Sa-Su 10:00-14:00').weekdayDescriptions.filter((l) => !l.endsWith('?'))).toHaveLength(2);
|
||
expect(parseOpeningHours('Mo 09:00-12:00').weekdayDescriptions.filter((l) => !l.endsWith('?'))).toHaveLength(1);
|
||
});
|
||
|
||
it('MAPS-006: handles comma-separated days', () => {
|
||
const result = parseOpeningHours('Mo,We,Fr 08:00-17:00');
|
||
expect(result.weekdayDescriptions[0]).toContain('Monday: 08:00-17:00');
|
||
expect(result.weekdayDescriptions[2]).toContain('Wednesday: 08:00-17:00');
|
||
expect(result.weekdayDescriptions[4]).toContain('Friday: 08:00-17:00');
|
||
expect(result.weekdayDescriptions[1]).toContain('?');
|
||
});
|
||
|
||
it('MAPS-007 (ReDoS): opening hours regex on adversarial input < 100ms of CPU', () => {
|
||
const adversarial = 'Mo' + ',Mo'.repeat(500) + ' closed';
|
||
expect(cpuMillis(() => { parseOpeningHours(adversarial); })).toBeLessThan(100);
|
||
});
|
||
|
||
it('MAPS-007b: emits machine-readable periods in Google day numbering (Sunday = 0)', () => {
|
||
const result = parseOpeningHours('Mo 09:00-18:00; Su 10:00-14:00');
|
||
expect(result.periods).toEqual([
|
||
{ open: { day: 1, hour: 9, minute: 0 }, close: { day: 1, hour: 18, minute: 0 } },
|
||
{ open: { day: 0, hour: 10, minute: 0 }, close: { day: 0, hour: 14, minute: 0 } },
|
||
]);
|
||
});
|
||
|
||
it('MAPS-007c: a period past midnight closes on the following day', () => {
|
||
const result = parseOpeningHours('Sa 20:00-02:00');
|
||
expect(result.periods).toEqual([
|
||
{ open: { day: 6, hour: 20, minute: 0 }, close: { day: 0, hour: 2, minute: 0 } },
|
||
]);
|
||
});
|
||
|
||
it('MAPS-007d: the OSM 24:00 spelling becomes midnight of the next day', () => {
|
||
// Google's clock has no hour 24, so "00:00-24:00" is a full day, not a rejected range.
|
||
const result = parseOpeningHours('Mo 00:00-24:00');
|
||
expect(result.periods).toEqual([
|
||
{ open: { day: 1, hour: 0, minute: 0 }, close: { day: 2, hour: 0, minute: 0 } },
|
||
]);
|
||
});
|
||
|
||
it('MAPS-007e: unusable clock values produce no period', () => {
|
||
expect(parseOpeningHours('Mo 09:75-18:00').periods).toEqual([]);
|
||
expect(parseOpeningHours('Mo 09:00-24:30').periods).toEqual([]);
|
||
expect(parseOpeningHours('Mo closed').periods).toEqual([]);
|
||
});
|
||
});
|
||
|
||
// ── normalizeOpeningPeriods / normalizeSpecialDays ────────────────────────────
|
||
|
||
describe('normalizeOpeningPeriods', () => {
|
||
it('MAPS-007f: keeps well-formed periods and fills the zeroes proto3 JSON omits', () => {
|
||
expect(
|
||
normalizeOpeningPeriods([{ open: { day: 3, hour: 9, minute: 30 }, close: { day: 3, hour: 17 } }]),
|
||
).toEqual([{ open: { day: 3, hour: 9, minute: 30 }, close: { day: 3, hour: 17, minute: 0 } }]);
|
||
// Sunday midnight arrives as an empty object.
|
||
expect(normalizeOpeningPeriods([{ open: {} }])).toEqual([{ open: { day: 0, hour: 0, minute: 0 }, close: null }]);
|
||
});
|
||
|
||
it('MAPS-007g: a period without a close survives as the round-the-clock marker', () => {
|
||
expect(normalizeOpeningPeriods([{ open: { day: 0, hour: 0, minute: 0 } }])).toEqual([
|
||
{ open: { day: 0, hour: 0, minute: 0 }, close: null },
|
||
]);
|
||
});
|
||
|
||
it('MAPS-007h: drops out-of-range points and periods with an unusable close', () => {
|
||
expect(normalizeOpeningPeriods([{ open: { day: 7, hour: 9, minute: 0 } }])).toBeNull();
|
||
expect(normalizeOpeningPeriods([{ open: { day: 1, hour: 24, minute: 0 } }])).toBeNull();
|
||
// A broken close must not be mistaken for "never closes".
|
||
expect(
|
||
normalizeOpeningPeriods([{ open: { day: 1, hour: 9, minute: 0 }, close: { day: 1, hour: 9, minute: 90 } }]),
|
||
).toBeNull();
|
||
});
|
||
|
||
it('MAPS-007i: returns null when there is nothing to normalise', () => {
|
||
expect(normalizeOpeningPeriods(undefined)).toBeNull();
|
||
expect(normalizeOpeningPeriods([])).toBeNull();
|
||
});
|
||
});
|
||
|
||
describe('normalizeSpecialDays', () => {
|
||
it('MAPS-007j: turns Google date parts into ISO dates without duplicates', () => {
|
||
expect(
|
||
normalizeSpecialDays([
|
||
{ date: { year: 2026, month: 12, day: 25 } },
|
||
{ date: { year: 2026, month: 1, day: 1 } },
|
||
{ date: { year: 2026, month: 12, day: 25 } },
|
||
]),
|
||
).toEqual(['2026-12-25', '2026-01-01']);
|
||
});
|
||
|
||
it('MAPS-007k: skips incomplete or impossible dates and returns null when none are left', () => {
|
||
expect(normalizeSpecialDays([{}, { date: { year: 2026, month: 13, day: 1 } }, { date: { year: 2026, day: 5 } }])).toBeNull();
|
||
expect(normalizeSpecialDays(undefined)).toBeNull();
|
||
});
|
||
});
|
||
|
||
// ── buildOsmDetails ───────────────────────────────────────────────────────────
|
||
|
||
describe('buildOsmDetails', () => {
|
||
it('MAPS-008: returns website from tags', () => {
|
||
const result = buildOsmDetails({ website: 'https://example.com' }, 'way', '123');
|
||
expect(result.website).toBe('https://example.com');
|
||
});
|
||
|
||
it('MAPS-009: prefers contact:website over website', () => {
|
||
const result = buildOsmDetails(
|
||
{ 'contact:website': 'https://contact.example.com', website: 'https://other.com' },
|
||
'node',
|
||
'1',
|
||
);
|
||
expect(result.website).toBe('https://contact.example.com');
|
||
});
|
||
|
||
it('MAPS-010: returns null website when no tag', () => {
|
||
const result = buildOsmDetails({}, 'node', '1');
|
||
expect(result.website).toBeNull();
|
||
});
|
||
|
||
it('MAPS-011: builds correct osm_url', () => {
|
||
const result = buildOsmDetails({}, 'way', '99999');
|
||
expect(result.osm_url).toBe('https://www.openstreetmap.org/way/99999');
|
||
});
|
||
|
||
it('MAPS-012: includes parsed opening_hours when valid', () => {
|
||
const result = buildOsmDetails({ opening_hours: 'Mo-Fr 09:00-18:00' }, 'node', '1');
|
||
expect(result.opening_hours).not.toBeNull();
|
||
expect(Array.isArray(result.opening_hours)).toBe(true);
|
||
});
|
||
|
||
it('MAPS-013: opening_hours is null when tag is missing', () => {
|
||
const result = buildOsmDetails({}, 'node', '1');
|
||
expect(result.opening_hours).toBeNull();
|
||
expect(result.open_now).toBeNull();
|
||
});
|
||
|
||
it('MAPS-014: source is always openstreetmap', () => {
|
||
expect(buildOsmDetails({}, 'node', '1').source).toBe('openstreetmap');
|
||
});
|
||
|
||
it('MAPS-012b: carries the structured periods next to the weekday lines', () => {
|
||
const result = buildOsmDetails({ opening_hours: 'Mo-Tu 09:00-18:00' }, 'node', '1');
|
||
expect(result.opening_periods).toEqual([
|
||
{ open: { day: 1, hour: 9, minute: 0 }, close: { day: 1, hour: 18, minute: 0 } },
|
||
{ open: { day: 2, hour: 9, minute: 0 }, close: { day: 2, hour: 18, minute: 0 } },
|
||
]);
|
||
});
|
||
|
||
it('MAPS-012c: opening_periods is null when the day lines carry no usable times', () => {
|
||
expect(buildOsmDetails({}, 'node', '1').opening_periods).toBeNull();
|
||
expect(buildOsmDetails({ opening_hours: 'Mo closed' }, 'node', '1').opening_periods).toBeNull();
|
||
});
|
||
|
||
it('MAPS-014b: opening_hours is null when all days have unknown times (all "?")', () => {
|
||
// "closed" does not match the day+time pattern so all days remain "?"
|
||
const result = buildOsmDetails({ opening_hours: 'closed' }, 'node', '1');
|
||
expect(result.opening_hours).toBeNull();
|
||
expect(result.open_now).toBeNull();
|
||
});
|
||
});
|
||
|
||
// ── resolveMapsKey / getMapsKey ───────────────────────────────────────────────
|
||
|
||
describe('resolveMapsKey', () => {
|
||
const ORIGINAL_PLACES_KEY = process.env.PLACES_API_KEY;
|
||
afterEach(() => {
|
||
if (ORIGINAL_PLACES_KEY === undefined) delete process.env.PLACES_API_KEY;
|
||
else process.env.PLACES_API_KEY = ORIGINAL_PLACES_KEY;
|
||
});
|
||
|
||
it('MAPS-015: returns the caller own row key when nothing above it is set', async () => {
|
||
mockDbGet.mockReturnValue({ maps_api_key: 'user-api-key' });
|
||
expect(await svc.resolveMapsKey(1)).toEqual({ key: 'user-api-key', source: 'user-row' });
|
||
expect(await svc.getMapsKey(1)).toBe('user-api-key'); // the wrapper reads the same chain
|
||
});
|
||
|
||
it('MAPS-016: the instance-wide key wins over the caller own row (#1939)', async () => {
|
||
mockInstanceGet.mockReturnValueOnce({ value: 'instance-api-key' });
|
||
mockDbGet.mockReturnValueOnce({ maps_api_key: 'user-api-key' });
|
||
expect(await svc.resolveMapsKey(1)).toEqual({ key: 'instance-api-key', source: 'instance' });
|
||
});
|
||
|
||
it('MAPS-017: returns null with no source when nothing is set anywhere', async () => {
|
||
expect(await svc.resolveMapsKey(1)).toEqual({ key: null, source: null });
|
||
expect(await svc.getMapsKey(1)).toBeNull();
|
||
});
|
||
|
||
it('MAPS-017b: the operator env key wins and the database is never asked', async () => {
|
||
process.env.PLACES_API_KEY = 'operator-key';
|
||
expect(await svc.resolveMapsKey(1)).toEqual({ key: 'operator-key', source: 'operator-env' });
|
||
expect(mockInstanceGet).not.toHaveBeenCalled();
|
||
expect(mockDbGet).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("MAPS-017c: never reads another user's row — the admin fallback is gone (#1939)", async () => {
|
||
await svc.resolveMapsKey(1);
|
||
// Two reads, both scoped: the instance row and this caller's own row. The
|
||
// old chain ended in "WHERE role = 'admin' ... LIMIT 1", which handed a
|
||
// stranger's credential to every non-admin — UsersRepository.getApiKeyColumn
|
||
// (instance-api-keys.ts's resolveApiKey, Plan 3a Task 5) is scoped to
|
||
// exactly the userId given, never a role-based lookup.
|
||
expect(mockInstanceGet).toHaveBeenCalledTimes(1);
|
||
expect(mockDbGet).toHaveBeenCalledTimes(1);
|
||
expect(mockDbGet).toHaveBeenCalledWith(1);
|
||
});
|
||
});
|
||
|
||
// ── reverseGeocode ────────────────────────────────────────────────────────────
|
||
|
||
describe('reverseGeocode (fetch stubbed)', () => {
|
||
it('MAPS-018: returns name and address from nominatim response', async () => {
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
json: async () => ({
|
||
name: 'Eiffel Tower',
|
||
display_name: 'Eiffel Tower, Paris, France',
|
||
address: {},
|
||
}),
|
||
}),
|
||
);
|
||
const result = await svc.reverseGeocode('48.8584', '2.2945');
|
||
expect(result.name).toBe('Eiffel Tower');
|
||
expect(result.address).toBe('Eiffel Tower, Paris, France');
|
||
});
|
||
|
||
it('MAPS-019: returns nulls when fetch fails', async () => {
|
||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false }));
|
||
const result = await svc.reverseGeocode('0', '0');
|
||
expect(result.name).toBeNull();
|
||
expect(result.address).toBeNull();
|
||
});
|
||
|
||
it('MAPS-019b: falls back to address.tourism when name is absent', async () => {
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
json: async () => ({
|
||
display_name: 'Some Museum, Paris',
|
||
address: { tourism: 'Some Museum' },
|
||
}),
|
||
}),
|
||
);
|
||
const result = await svc.reverseGeocode('48.85', '2.35');
|
||
expect(result.name).toBe('Some Museum');
|
||
});
|
||
|
||
it('MAPS-019c: falls back to address.amenity when name and tourism are absent', async () => {
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
json: async () => ({
|
||
display_name: 'A Cafe, Paris',
|
||
address: { amenity: 'A Cafe' },
|
||
}),
|
||
}),
|
||
);
|
||
const result = await svc.reverseGeocode('48.85', '2.35');
|
||
expect(result.name).toBe('A Cafe');
|
||
});
|
||
|
||
it('MAPS-019d: falls back to address.road when no higher-priority field exists', async () => {
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
json: async () => ({
|
||
display_name: 'Rue de Rivoli, Paris',
|
||
address: { road: 'Rue de Rivoli' },
|
||
}),
|
||
}),
|
||
);
|
||
const result = await svc.reverseGeocode('48.85', '2.35');
|
||
expect(result.name).toBe('Rue de Rivoli');
|
||
});
|
||
|
||
it('MAPS-019e: returns null name when address has no recognized fields', async () => {
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
json: async () => ({
|
||
display_name: 'Somewhere',
|
||
address: {},
|
||
}),
|
||
}),
|
||
);
|
||
const result = await svc.reverseGeocode('0', '0');
|
||
expect(result.name).toBeNull();
|
||
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 result = await svc.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 result = await svc.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 result = await svc.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 result = await svc.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 result = await svc.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 result = await svc.resolveGoogleMapsUrl('https://www.google.com/maps?cid=999');
|
||
expect(result.lat).toBeCloseTo(40.6892, 3);
|
||
expect(result.lng).toBeCloseTo(-74.0445, 3);
|
||
});
|
||
|
||
it('MAPS-CID-003: does not read the page body of a non-Google resolved URL', async () => {
|
||
// The body branch used to run for any host, turning resolve-url into an
|
||
// authenticated outbound GET primitive.
|
||
const fetchMock = vi.fn(async (u: string) => {
|
||
if (u.includes('nominatim')) {
|
||
return { ok: true, json: async () => ({ display_name: 'x', name: null, address: {} }) };
|
||
}
|
||
if (u.includes('goo.gl')) return { url: 'https://internal.example.com/status' };
|
||
return { url: 'https://internal.example.com/status', text: async () => 'x!3d40.6892!4d-74.0445y' };
|
||
});
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
|
||
await expect(svc.resolveGoogleMapsUrl('https://goo.gl/maps/abc123')).rejects.toMatchObject({ status: 400 });
|
||
// Only the redirect-following call went out; the page body was never fetched.
|
||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
it.each([
|
||
'https://www.google.de/maps?cid=999',
|
||
'https://maps.google.co.uk/?cid=999',
|
||
'https://google.com.au/maps?cid=999',
|
||
])('MAPS-CID-003b: still reads the page body of a country domain (%s)', async (link) => {
|
||
// Google Maps answers on every ccTLD, and those links carry no coordinates
|
||
// to fall back on — a host allow-list of .com spellings drops them.
|
||
const fetchMock = vi.fn(async (u: string) => {
|
||
if (u.includes('nominatim')) {
|
||
return { ok: true, json: async () => ({ display_name: 'Berlin, DE', name: null, address: {} }) };
|
||
}
|
||
return { url: link, text: async () => 'x!3d52.5163!4d13.3777y' };
|
||
});
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
|
||
const result = await svc.resolveGoogleMapsUrl(link);
|
||
expect(result.lat).toBeCloseTo(52.5163, 3);
|
||
expect(result.lng).toBeCloseTo(13.3777, 3);
|
||
});
|
||
|
||
it('MAPS-CID-003c: a host that only looks like Google is still not read', async () => {
|
||
const fetchMock = vi.fn(async (u: string) => {
|
||
if (u.includes('nominatim')) {
|
||
return { ok: true, json: async () => ({ display_name: 'x', name: null, address: {} }) };
|
||
}
|
||
return { url: 'https://google.evil.com/maps', text: async () => 'x!3d40.6892!4d-74.0445y' };
|
||
});
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
|
||
await expect(svc.resolveGoogleMapsUrl('https://google.evil.com/maps?cid=999')).rejects.toMatchObject({ status: 400 });
|
||
expect(fetchMock).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('MAPS-CID-004: skips a page body that declares more than the size cap', async () => {
|
||
const fetchMock = vi.fn(async (u: string) => {
|
||
if (u.includes('cid=')) return { url: 'https://www.google.com/maps/place/Somewhere' };
|
||
return {
|
||
url: 'https://www.google.com/maps/place/Somewhere',
|
||
headers: { get: (h: string) => (h === 'content-length' ? String(50_000_000) : null) },
|
||
text: async () => 'x!3d40.6892!4d-74.0445y',
|
||
};
|
||
});
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
await expect(svc.resolveGoogleMapsUrl('https://www.google.com/maps?cid=999')).rejects.toMatchObject({ status: 400 });
|
||
});
|
||
|
||
it('MAPS-024 (ReDoS): /@(-?\\d+\\.?\\d*),(-?\\d+\\.?\\d*)/ on adversarial input < 500ms of CPU', () => {
|
||
const adversarial = '/@' + '1'.repeat(10000) + '.';
|
||
expect(cpuMillis(() => { adversarial.match(/@(-?\d+\.?\d*),(-?\d+\.?\d*)/); })).toBeLessThan(500);
|
||
});
|
||
|
||
it('MAPS-025 (ReDoS): /!3d(-?\\d+\\.?\\d*)!4d/ on adversarial input < 500ms of CPU', () => {
|
||
const adversarial = '!3d' + '1'.repeat(10000) + '.';
|
||
expect(cpuMillis(() => { adversarial.match(/!3d(-?\d+\.?\d*)!4d(-?\d+\.?\d*)/); })).toBeLessThan(500);
|
||
});
|
||
|
||
it('MAPS-026 (ReDoS): /[?&]q=(-?\\d+\\.?\\d*)/ on adversarial input < 500ms of CPU', () => {
|
||
const adversarial = '?q=' + '1'.repeat(10000) + '.';
|
||
expect(cpuMillis(() => { adversarial.match(/[?&]q=(-?\d+\.?\d*),(-?\d+\.?\d*)/); })).toBeLessThan(500);
|
||
});
|
||
|
||
it('MAPS-027 (ReDoS): /<[^>]+>/ HTML strip on adversarial input < 100ms of CPU', () => {
|
||
const adversarial = '<' + 'a'.repeat(10000);
|
||
expect(cpuMillis(() => { adversarial.replace(/<[^>]+>/g, ''); })).toBeLessThan(100);
|
||
});
|
||
|
||
it('MAPS-028: throws when no coordinates found in URL', async () => {
|
||
vi.stubGlobal('fetch', nominatimStub);
|
||
await expect(svc.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 });
|
||
await expect(svc.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 result = await svc.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);
|
||
// URL with coordinates but no /place/ path segment
|
||
const result = await svc.resolveGoogleMapsUrl('https://www.google.com/maps/@48.8606,2.3376,15z');
|
||
expect(result.name).toBe('Louvre Museum');
|
||
});
|
||
|
||
it('MAPS-110: a non-ok reverse-geocode answer degrades to URL-derived values instead of failing', async () => {
|
||
// The coordinates are already extracted from the URL — a Nominatim 5xx must
|
||
// not turn the resolution into a 400.
|
||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 503 }));
|
||
const result = await svc.resolveGoogleMapsUrl(
|
||
'https://www.google.com/maps/place/Eiffel+Tower/@48.8584,2.2945,15z',
|
||
);
|
||
expect(result.lat).toBeCloseTo(48.8584, 3);
|
||
expect(result.lng).toBeCloseTo(2.2945, 3);
|
||
expect(result.name).toBe('Eiffel Tower');
|
||
expect(result.address).toBeNull();
|
||
});
|
||
});
|
||
|
||
// A Google place URL carries two points: @lat,lng is where the camera looks,
|
||
// !3d!4d is the place itself. Pinning the camera lands next to the place (#2635).
|
||
describe('resolveGoogleMapsUrl place pin before camera (#2635)', () => {
|
||
// What https://maps.app.goo.gl/CdynKSHT16bufnAp9 redirects to.
|
||
const EIFFEL_SHARE_TARGET =
|
||
'https://www.google.com/maps/place/Tour+Eiffel/@48.8579431,2.292255,812m/data=!3m1!1e3!4m7!3m6!1s0x47e66e2964e34e2d:0x8ddca9ee380ef7e0!8m2!3d48.8583701!4d2.2944813!15sChFtYXBzIGVpZmZlbCB0b3dlcloTIhFtYXBzIGVpZmZlbCB0b3dlcpIBE2hpc3RvcmljYWxfbGFuZG1hcmuaAURDaTlEUVVsUlFVTnZaRU5vZEhsalJqbHZUMnR3VGxkWGRFaFBWM2gyVFd3NWVtTnVTalJNVlU1RVUxWm9ORTlWUlJBQuABAPoBBAgAEDg!16zL20vMDJqODE?entry=tts&g_ep=EgoyMDI2MTAwNy4wIPu8ASoASAFQAw%3D%3D&skid=20b521f5-0f45-4a02-8475-b2a1c2bdcb0b';
|
||
|
||
const nominatimAt = (calls: string[]) =>
|
||
vi.fn(async (u: string) => {
|
||
calls.push(u);
|
||
if (u.includes('goo.gl')) return { url: EIFFEL_SHARE_TARGET };
|
||
return { ok: true, json: async () => ({ display_name: 'Avenue Gustave Eiffel, Paris', name: null, address: {} }) };
|
||
});
|
||
|
||
it('MAPS-2635-001: a place URL with both @ and !3d!4d resolves to the place, not the camera', async () => {
|
||
const calls: string[] = [];
|
||
vi.stubGlobal('fetch', nominatimAt(calls));
|
||
const result = await svc.resolveGoogleMapsUrl(EIFFEL_SHARE_TARGET);
|
||
expect(result.lat).toBe(48.8583701);
|
||
expect(result.lng).toBe(2.2944813);
|
||
expect(result.name).toBe('Tour Eiffel');
|
||
expect(result.google_ftid).toBe('0x47e66e2964e34e2d:0x8ddca9ee380ef7e0');
|
||
// The address is looked up at the place too.
|
||
const reverse = calls.find((u) => u.includes('nominatim'));
|
||
expect(reverse).toContain('lat=48.8583701');
|
||
expect(reverse).toContain('lon=2.2944813');
|
||
});
|
||
|
||
it('MAPS-2635-002: the share short link resolves to the place pin', async () => {
|
||
const calls: string[] = [];
|
||
vi.stubGlobal('fetch', nominatimAt(calls));
|
||
const result = await svc.resolveGoogleMapsUrl('https://maps.app.goo.gl/CdynKSHT16bufnAp9');
|
||
expect(result.lat).toBe(48.8583701);
|
||
expect(result.lng).toBe(2.2944813);
|
||
});
|
||
|
||
it('MAPS-2635-003: only !3d!4d still resolves', async () => {
|
||
vi.stubGlobal('fetch', nominatimStub);
|
||
const result = await svc.resolveGoogleMapsUrl(
|
||
'https://www.google.com/maps/place/Tour+Eiffel/data=!4m7!3m6!8m2!3d48.8583701!4d2.2944813',
|
||
);
|
||
expect(result.lat).toBe(48.8583701);
|
||
expect(result.lng).toBe(2.2944813);
|
||
});
|
||
|
||
it('MAPS-2635-004: only @ still resolves to the camera point', async () => {
|
||
vi.stubGlobal('fetch', nominatimStub);
|
||
const result = await svc.resolveGoogleMapsUrl('https://www.google.com/maps/@48.8579431,2.292255,812m');
|
||
expect(result.lat).toBe(48.8579431);
|
||
expect(result.lng).toBe(2.292255);
|
||
});
|
||
|
||
it('MAPS-2635-005: only ?q= still resolves', async () => {
|
||
vi.stubGlobal('fetch', nominatimStub);
|
||
const result = await svc.resolveGoogleMapsUrl('https://www.google.com/maps?q=48.8583701,2.2944813');
|
||
expect(result.lat).toBe(48.8583701);
|
||
expect(result.lng).toBe(2.2944813);
|
||
});
|
||
|
||
it('MAPS-2635-006: @ keeps its precedence over ?q= when no place pin is present', async () => {
|
||
vi.stubGlobal('fetch', nominatimStub);
|
||
const result = await svc.resolveGoogleMapsUrl('https://www.google.com/maps/@48.85,2.29,15z?q=40.6892,-74.0445');
|
||
expect(result.lat).toBe(48.85);
|
||
expect(result.lng).toBe(2.29);
|
||
});
|
||
|
||
it('MAPS-2635-007: a page body with both @ and !3d!4d resolves to the place', async () => {
|
||
const fetchMock = vi.fn(async (u: string) => {
|
||
if (u.includes('nominatim')) {
|
||
return { ok: true, json: async () => ({ display_name: 'Paris', name: null, address: {} }) };
|
||
}
|
||
return {
|
||
url: 'https://www.google.com/maps/place/Somewhere',
|
||
text: async () => 'x/@48.8579431,2.292255,812m/y!3d48.8583701!4d2.2944813z',
|
||
};
|
||
});
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
const result = await svc.resolveGoogleMapsUrl('https://www.google.com/maps?cid=999');
|
||
expect(result.lat).toBe(48.8583701);
|
||
expect(result.lng).toBe(2.2944813);
|
||
});
|
||
});
|
||
|
||
// ── searchNominatim (fetch-dependent) ────────────────────────────────────────
|
||
|
||
describe('searchNominatim (fetch stubbed)', () => {
|
||
it('MAPS-029: returns mapped nominatim results on success', async () => {
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
json: async () => [
|
||
{ osm_type: 'way', osm_id: '1', lat: '48.8', lon: '2.3', name: 'Paris', display_name: 'Paris, France' },
|
||
],
|
||
}),
|
||
);
|
||
const results = await osm.searchNominatim('Paris');
|
||
expect(results).toHaveLength(1);
|
||
expect((results[0] as any).address).toBe('Paris, France');
|
||
expect((results[0] as any).source).toBe('openstreetmap');
|
||
});
|
||
|
||
it('MAPS-030: throws on fetch failure', async () => {
|
||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network error')));
|
||
await expect(osm.searchNominatim('fail')).rejects.toThrow();
|
||
});
|
||
|
||
it('MAPS-030b: throws when nominatim response is not ok', async () => {
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi.fn().mockResolvedValue({
|
||
ok: false,
|
||
status: 500,
|
||
statusText: 'Internal Server Error',
|
||
text: async () => '',
|
||
}),
|
||
);
|
||
await expect(osm.searchNominatim('fail')).rejects.toThrow('Nominatim API error');
|
||
});
|
||
|
||
it('MAPS-030c: falls back to display_name split when name is absent', async () => {
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
json: async () => [{ osm_type: 'node', osm_id: '2', lat: '51.5', lon: '-0.1', display_name: 'London, UK' }],
|
||
}),
|
||
);
|
||
const results = await osm.searchNominatim('London');
|
||
expect((results[0] as any).name).toBe('London');
|
||
});
|
||
|
||
it('MAPS-108: keeps "0" coordinates and nulls only unparseable ones', async () => {
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
json: async () => [
|
||
{ osm_type: 'node', osm_id: '3', lat: '0', lon: '0', name: 'Null Island' },
|
||
{ osm_type: 'node', osm_id: '4', lat: 'not-a-number', lon: '', name: 'Broken' },
|
||
],
|
||
}),
|
||
);
|
||
const results = await osm.searchNominatim('null island');
|
||
expect((results[0] as any).lat).toBe(0);
|
||
expect((results[0] as any).lng).toBe(0);
|
||
expect((results[1] as any).lat).toBeNull();
|
||
expect((results[1] as any).lng).toBeNull();
|
||
});
|
||
|
||
it('MAPS-108b: carries what the place is, with a shop named as one (#2282)', async () => {
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
json: async () => [
|
||
{ osm_type: 'node', osm_id: '5', lat: '52.5', lon: '13.4', name: 'Adlon', class: 'tourism', type: 'hotel' },
|
||
{ osm_type: 'node', osm_id: '6', lat: '52.5', lon: '13.4', name: 'Bäcker', class: 'shop', type: 'bakery' },
|
||
{ osm_type: 'node', osm_id: '7', lat: '52.5', lon: '13.4', name: 'Hut', class: 'building', type: 'yes' },
|
||
{ osm_type: 'node', osm_id: '8', lat: '52.5', lon: '13.4', name: 'Bare' },
|
||
],
|
||
}),
|
||
);
|
||
const results = await osm.searchNominatim('x');
|
||
expect(results.map((r: any) => r.category)).toEqual(['hotel', 'shop_bakery', null, null]);
|
||
});
|
||
});
|
||
|
||
// ── fetchOverpassDetails (fetch stubbed) ─────────────────────────────────────
|
||
|
||
describe('fetchOverpassDetails (fetch stubbed)', () => {
|
||
it('MAPS-031: returns element tags on success', async () => {
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
json: async () => ({ elements: [{ tags: { name: 'Eiffel Tower', website: 'https://eiffel.com' } }] }),
|
||
}),
|
||
);
|
||
const result = await osm.fetchOverpassDetails('way', '12345');
|
||
expect(result).toBeDefined();
|
||
expect((result as any).tags.name).toBe('Eiffel Tower');
|
||
});
|
||
|
||
it('MAPS-032: returns null for unknown osmType', async () => {
|
||
const result = await osm.fetchOverpassDetails('unknown', '12345');
|
||
expect(result).toBeNull();
|
||
});
|
||
|
||
it('MAPS-032b: refuses an id that is not a number, without sending anything', async () => {
|
||
// The id is interpolated into Overpass QL. Anything past the digits is a
|
||
// statement of its own, run on the mirror under TREK's shared user agent:
|
||
// this one turns a millisecond lookup into a global scan capped only by the
|
||
// query's own five second timeout.
|
||
const fetchMock = vi.fn();
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
for (const bad of ['1);nwr["amenity"](-90,-180,90,180', '12345;out geom', '', ' 1', '1e3', '-5']) {
|
||
expect(await osm.fetchOverpassDetails('node', bad), bad).toBeNull();
|
||
}
|
||
expect(fetchMock).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('MAPS-033: returns null when fetch throws', async () => {
|
||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network error')));
|
||
const result = await osm.fetchOverpassDetails('node', '99999');
|
||
expect(result).toBeNull();
|
||
});
|
||
|
||
it('MAPS-034: returns null when response is not ok', async () => {
|
||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false }));
|
||
const result = await osm.fetchOverpassDetails('node', '99999');
|
||
expect(result).toBeNull();
|
||
});
|
||
|
||
it('MAPS-034b: returns null when elements array is empty', async () => {
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
json: async () => ({ elements: [] }),
|
||
}),
|
||
);
|
||
const result = await osm.fetchOverpassDetails('node', '1');
|
||
expect(result).toBeNull();
|
||
});
|
||
});
|
||
|
||
// ── searchOverpassPois localized names (#1655) ───────────────────────────────
|
||
|
||
describe('searchOverpassPois localized names (#1655)', () => {
|
||
// Elephant and Obelisk in Rome: OSM ships the native name plus localized tags.
|
||
const tags = {
|
||
name: 'Obelisco della Minerva',
|
||
'name:en': 'Elephant and Obelisk',
|
||
'name:de': 'Minerva-Obelisk',
|
||
int_name: 'Elephant Obelisk',
|
||
tourism: 'attraction',
|
||
};
|
||
const stubOverpass = (elementTags: Record<string, string>) =>
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
json: async () => ({ elements: [{ type: 'node', id: 1, lat: 41.9, lon: 12.48, tags: elementTags }] }),
|
||
}),
|
||
);
|
||
// Distinct bboxes per case so the module-level POI_CACHE (keyed by lang+bbox)
|
||
// never serves one case's names to another.
|
||
const bbox = (n: number) => ({ south: 41 + n / 100, west: 12, north: 42 + n / 100, east: 13 });
|
||
|
||
it('prefers name:<lang> for the user language over the native name', async () => {
|
||
stubOverpass(tags);
|
||
const { pois } = await osm.searchOverpassPois('sights', bbox(1), 'en-US');
|
||
expect(pois[0].name).toBe('Elephant and Obelisk');
|
||
});
|
||
|
||
it('localizes to a non-English language too', async () => {
|
||
stubOverpass(tags);
|
||
const { pois } = await osm.searchOverpassPois('sights', bbox(2), 'de-DE');
|
||
expect(pois[0].name).toBe('Minerva-Obelisk');
|
||
});
|
||
|
||
it('falls back to int_name when the language tag is absent', async () => {
|
||
stubOverpass({ name: tags.name, int_name: tags.int_name, tourism: 'attraction' });
|
||
const { pois } = await osm.searchOverpassPois('sights', bbox(3), 'fr-FR');
|
||
expect(pois[0].name).toBe('Elephant Obelisk');
|
||
});
|
||
|
||
it('skips POIs tagged as closed for good (#1341)', async () => {
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
json: async () => ({
|
||
elements: [
|
||
{ type: 'node', id: 1, lat: 41.9, lon: 12.48, tags: { name: 'Gone Forever', tourism: 'attraction', disused: 'yes' } },
|
||
{ type: 'node', id: 2, lat: 41.9, lon: 12.49, tags: { name: 'Ruin', tourism: 'attraction', abandoned: 'yes' } },
|
||
{ type: 'node', id: 3, lat: 41.9, lon: 12.5, tags: { name: 'Shut', tourism: 'attraction', opening_hours: 'closed' } },
|
||
{ type: 'node', id: 4, lat: 41.9, lon: 12.51, tags: { name: 'Open For Business', tourism: 'attraction' } },
|
||
],
|
||
}),
|
||
}),
|
||
);
|
||
const { pois } = await osm.searchOverpassPois('sights', bbox(5), 'en-US');
|
||
expect(pois.map((p: any) => p.name)).toEqual(['Open For Business']);
|
||
});
|
||
|
||
it('falls back to the native name when no localized tag exists', async () => {
|
||
stubOverpass({ name: tags.name, tourism: 'attraction' });
|
||
const { pois } = await osm.searchOverpassPois('sights', bbox(4), 'fr-FR');
|
||
expect(pois[0].name).toBe('Obelisco della Minerva');
|
||
});
|
||
});
|
||
|
||
// ── fetchWikimediaPhoto (fetch stubbed) ───────────────────────────────────────
|
||
|
||
describe('fetchWikimediaPhoto (fetch stubbed)', () => {
|
||
it('MAPS-035: returns photo from Wikipedia article image (strategy 1)', async () => {
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
json: async () => ({
|
||
query: { pages: { '1': { thumbnail: { source: 'https://example.com/thumb.jpg' } } } },
|
||
}),
|
||
}),
|
||
);
|
||
const result = await wiki.fetchWikimediaPhoto(48.8, 2.3, 'Eiffel Tower');
|
||
expect(result).toBeDefined();
|
||
expect(result!.photoUrl).toBe('https://example.com/thumb.jpg');
|
||
expect(result!.attribution).toBe('Wikipedia');
|
||
});
|
||
|
||
it('MAPS-036: falls through to geosearch when Wikipedia has no thumbnail', async () => {
|
||
const wikiResponse = { ok: true, json: async () => ({ query: { pages: { '-1': {} } } }) };
|
||
const commonsResponse = {
|
||
ok: true,
|
||
json: async () => ({
|
||
query: {
|
||
pages: {
|
||
'1': {
|
||
imageinfo: [
|
||
{ url: 'https://commons.org/img.jpg', mime: 'image/jpeg', extmetadata: { Artist: { value: 'Alice' } } },
|
||
],
|
||
},
|
||
},
|
||
},
|
||
}),
|
||
};
|
||
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(wikiResponse).mockResolvedValueOnce(commonsResponse));
|
||
const result = await wiki.fetchWikimediaPhoto(48.8, 2.3, 'Some Place');
|
||
expect(result).toBeDefined();
|
||
expect(result!.photoUrl).toBe('https://commons.org/img.jpg');
|
||
expect(result!.attribution).toBe('Alice');
|
||
});
|
||
|
||
it('MAPS-036b: geosearch prefers the scaled thumburl over the full-res original', async () => {
|
||
const wikiResponse = { ok: true, json: async () => ({ query: { pages: { '-1': {} } } }) };
|
||
const commonsResponse = {
|
||
ok: true,
|
||
json: async () => ({
|
||
query: {
|
||
pages: {
|
||
'1': {
|
||
imageinfo: [
|
||
{
|
||
url: 'https://commons.org/original-16mb.jpg',
|
||
thumburl: 'https://commons.org/thumb-400.jpg',
|
||
mime: 'image/jpeg',
|
||
extmetadata: { Artist: { value: 'Alice' } },
|
||
},
|
||
],
|
||
},
|
||
},
|
||
},
|
||
}),
|
||
};
|
||
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(wikiResponse).mockResolvedValueOnce(commonsResponse));
|
||
const result = await wiki.fetchWikimediaPhoto(48.8, 2.3, 'Some Place');
|
||
expect(result).toBeDefined();
|
||
expect(result!.photoUrl).toBe('https://commons.org/thumb-400.jpg');
|
||
expect(result!.attribution).toBe('Alice');
|
||
});
|
||
|
||
it('MAPS-037: returns null when both strategies find nothing', async () => {
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
json: async () => ({ query: { pages: {} } }),
|
||
}),
|
||
);
|
||
const result = await wiki.fetchWikimediaPhoto(48.8, 2.3);
|
||
expect(result).toBeNull();
|
||
});
|
||
|
||
it('MAPS-037b: skips strategy 1 entirely when name is undefined', async () => {
|
||
// Only one fetch call is made (the Commons geosearch), not two
|
||
const fetchMock = vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
json: async () => ({ query: { pages: {} } }),
|
||
});
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
await wiki.fetchWikimediaPhoto(48.8, 2.3);
|
||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
it('MAPS-037c: falls through to geosearch when Wikipedia fetch throws', async () => {
|
||
const commonsResponse = {
|
||
ok: true,
|
||
json: async () => ({
|
||
query: {
|
||
pages: {
|
||
'1': {
|
||
imageinfo: [{ url: 'https://commons.org/fallback.jpg', mime: 'image/png', extmetadata: {} }],
|
||
},
|
||
},
|
||
},
|
||
}),
|
||
};
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi.fn().mockRejectedValueOnce(new Error('Wikipedia network error')).mockResolvedValueOnce(commonsResponse),
|
||
);
|
||
const result = await wiki.fetchWikimediaPhoto(48.8, 2.3, 'Some Place');
|
||
expect(result).toBeDefined();
|
||
expect(result!.photoUrl).toBe('https://commons.org/fallback.jpg');
|
||
// no Artist in extmetadata -> attribution null
|
||
expect(result!.attribution).toBeNull();
|
||
});
|
||
|
||
it('MAPS-037d: falls through to geosearch when Wikipedia response is not ok', async () => {
|
||
const wikiNotOk = { ok: false };
|
||
const commonsResponse = {
|
||
ok: true,
|
||
json: async () => ({
|
||
query: {
|
||
pages: {
|
||
'1': {
|
||
imageinfo: [
|
||
{
|
||
url: 'https://commons.org/photo.jpg',
|
||
mime: 'image/jpeg',
|
||
extmetadata: { Artist: { value: '<b>Bob</b>' } },
|
||
},
|
||
],
|
||
},
|
||
},
|
||
},
|
||
}),
|
||
};
|
||
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(wikiNotOk).mockResolvedValueOnce(commonsResponse));
|
||
const result = await wiki.fetchWikimediaPhoto(48.8, 2.3, 'Some Place');
|
||
expect(result).toBeDefined();
|
||
// HTML tags stripped from attribution
|
||
expect(result!.attribution).toBe('Bob');
|
||
});
|
||
|
||
it('MAPS-037e: returns null when Commons geosearch returns not ok', async () => {
|
||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false }));
|
||
const result = await wiki.fetchWikimediaPhoto(48.8, 2.3);
|
||
expect(result).toBeNull();
|
||
});
|
||
|
||
it('MAPS-037f: returns null when Commons geosearch returns no query.pages', async () => {
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
json: async () => ({ query: {} }),
|
||
}),
|
||
);
|
||
const result = await wiki.fetchWikimediaPhoto(48.8, 2.3);
|
||
expect(result).toBeNull();
|
||
});
|
||
|
||
it('MAPS-037g: returns null when Commons fetch throws', async () => {
|
||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Commons network error')));
|
||
const result = await wiki.fetchWikimediaPhoto(48.8, 2.3);
|
||
expect(result).toBeNull();
|
||
});
|
||
|
||
it('MAPS-037h: skips Commons page entries with non-photo MIME type (SVG)', async () => {
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
json: async () => ({
|
||
query: {
|
||
pages: {
|
||
'1': {
|
||
imageinfo: [{ url: 'https://commons.org/diagram.svg', mime: 'image/svg+xml' }],
|
||
},
|
||
},
|
||
},
|
||
}),
|
||
}),
|
||
);
|
||
const result = await wiki.fetchWikimediaPhoto(48.8, 2.3);
|
||
expect(result).toBeNull();
|
||
});
|
||
|
||
it('MAPS-037i: accepts PNG mime type as valid photo', async () => {
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
json: async () => ({
|
||
query: {
|
||
pages: {
|
||
'1': {
|
||
imageinfo: [
|
||
{
|
||
url: 'https://commons.org/photo.png',
|
||
mime: 'image/png',
|
||
extmetadata: { Artist: { value: 'Carol' } },
|
||
},
|
||
],
|
||
},
|
||
},
|
||
},
|
||
}),
|
||
}),
|
||
);
|
||
const result = await wiki.fetchWikimediaPhoto(48.8, 2.3);
|
||
expect(result!.photoUrl).toBe('https://commons.org/photo.png');
|
||
expect(result!.attribution).toBe('Carol');
|
||
});
|
||
|
||
it('MAPS-037j: returns null attribution when Artist extmetadata is absent', async () => {
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
json: async () => ({
|
||
query: {
|
||
pages: {
|
||
'1': {
|
||
imageinfo: [{ url: 'https://commons.org/noattr.jpg', mime: 'image/jpeg', extmetadata: {} }],
|
||
},
|
||
},
|
||
},
|
||
}),
|
||
}),
|
||
);
|
||
const result = await wiki.fetchWikimediaPhoto(48.8, 2.3);
|
||
expect(result!.attribution).toBeNull();
|
||
});
|
||
});
|
||
|
||
// ── searchPlaces (fetch stubbed) ─────────────────────────────────────────────
|
||
|
||
describe('searchPlaces (fetch stubbed)', () => {
|
||
it('MAPS-038: uses Nominatim when user has no API key', async () => {
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
json: async () => [
|
||
{ osm_type: 'node', osm_id: '1', lat: '48.8', lon: '2.3', display_name: 'Paris, France', name: 'Paris' },
|
||
],
|
||
}),
|
||
);
|
||
const result = await svc.searchPlaces(999, 'Paris');
|
||
expect(result.source).toBe('openstreetmap');
|
||
expect(Array.isArray(result.places)).toBe(true);
|
||
});
|
||
|
||
// Session tokens: the keystrokes of one search and the details lookup that
|
||
// ends it must reach Google under the same token, or every request is billed
|
||
// on its own. The body field and the query parameter are what Google's
|
||
// reference specifies for each half.
|
||
it('MAPS-039f: sends the session token in the autocomplete body', async () => {
|
||
indexOff();
|
||
mockDbGet.mockReturnValueOnce({ maps_api_key: 'ENCRYPTED' }).mockReturnValueOnce(null);
|
||
const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ suggestions: [] }) });
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
|
||
await svc.autocompletePlaces(1, 'eiff', 'en', undefined, 'sess-123');
|
||
|
||
const [url, init] = fetchMock.mock.calls[0];
|
||
expect(String(url)).toContain('places:autocomplete');
|
||
expect(JSON.parse((init as { body: string }).body).sessionToken).toBe('sess-123');
|
||
});
|
||
|
||
it('MAPS-039g: omits the field entirely when there is no session token', async () => {
|
||
indexOff();
|
||
mockDbGet.mockReturnValueOnce({ maps_api_key: 'ENCRYPTED' }).mockReturnValueOnce(null);
|
||
const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ suggestions: [] }) });
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
|
||
await svc.autocompletePlaces(1, 'eiff', 'en');
|
||
|
||
const body = JSON.parse((fetchMock.mock.calls[0][1] as { body: string }).body);
|
||
expect(body).not.toHaveProperty('sessionToken');
|
||
});
|
||
|
||
it('MAPS-039: uses Google when user has an API key', async () => {
|
||
mockDbGet.mockReturnValueOnce({ maps_api_key: 'ENCRYPTED' }).mockReturnValueOnce(null);
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
json: async () => ({
|
||
places: [
|
||
{
|
||
id: 'gid1',
|
||
displayName: { text: 'Eiffel Tower' },
|
||
formattedAddress: 'Paris',
|
||
location: { latitude: 48.8, longitude: 2.3 },
|
||
// Real search API returns a cid-style URL with no ftid → google_ftid stays null.
|
||
googleMapsUri: 'https://maps.google.com/?cid=10403719659250533155',
|
||
},
|
||
],
|
||
}),
|
||
}),
|
||
);
|
||
const result = await svc.searchPlaces(1, 'Eiffel Tower');
|
||
expect(result.source).toBe('google');
|
||
expect((result.places[0] as any).google_place_id).toBe('gid1');
|
||
expect((result.places[0] as any).google_ftid).toBeNull();
|
||
});
|
||
|
||
it('MAPS-039b: throws with Google error status when Google API returns non-ok', async () => {
|
||
mockDbGet.mockReturnValueOnce({ maps_api_key: 'some-key' });
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi.fn().mockResolvedValue({
|
||
ok: false,
|
||
status: 403,
|
||
json: async () => ({ error: { message: 'API key invalid' } }),
|
||
}),
|
||
);
|
||
await expect(svc.searchPlaces(1, 'anything')).rejects.toMatchObject({
|
||
message: 'API key invalid',
|
||
status: 403,
|
||
});
|
||
});
|
||
|
||
it('MAPS-039h: a Google rejection logs which credential was used, never the credential (#1939)', async () => {
|
||
mockInstanceGet.mockReturnValueOnce({ value: 'instance-secret-key' });
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi.fn().mockResolvedValue({
|
||
ok: false,
|
||
status: 403,
|
||
json: async () => ({ error: { message: 'The caller does not have permission' } }),
|
||
}),
|
||
);
|
||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||
await expect(svc.searchPlaces(7, 'anything')).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=7');
|
||
expect(logged).not.toContain('instance-secret-key');
|
||
errorSpy.mockRestore();
|
||
});
|
||
|
||
it('MAPS-039c: throws with generic message when Google error has no message', async () => {
|
||
mockDbGet.mockReturnValueOnce({ maps_api_key: 'some-key' });
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi.fn().mockResolvedValue({
|
||
ok: false,
|
||
status: 500,
|
||
json: async () => ({ error: {} }),
|
||
}),
|
||
);
|
||
await expect(svc.searchPlaces(1, 'anything')).rejects.toMatchObject({
|
||
message: 'Google Places API error',
|
||
status: 500,
|
||
});
|
||
});
|
||
|
||
it('MAPS-039d: returns empty places array when Google returns no results', async () => {
|
||
mockDbGet.mockReturnValueOnce({ maps_api_key: 'some-key' });
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
json: async () => ({ places: [] }),
|
||
}),
|
||
);
|
||
const result = await svc.searchPlaces(1, 'very obscure place');
|
||
expect(result.source).toBe('google');
|
||
expect(result.places).toHaveLength(0);
|
||
});
|
||
|
||
it('MAPS-039e: handles Google result with optional fields absent', async () => {
|
||
mockDbGet.mockReturnValueOnce({ maps_api_key: 'some-key' });
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
json: async () => ({
|
||
// id only, no displayName, formattedAddress, location, etc.
|
||
places: [{ id: 'gid-sparse' }],
|
||
}),
|
||
}),
|
||
);
|
||
const result = await svc.searchPlaces(1, 'sparse');
|
||
const place = result.places[0] as any;
|
||
expect(place.google_place_id).toBe('gid-sparse');
|
||
expect(place.google_ftid).toBeNull();
|
||
expect(place.name).toBe('');
|
||
expect(place.address).toBe('');
|
||
expect(place.lat).toBeNull();
|
||
expect(place.lng).toBeNull();
|
||
expect(place.rating).toBeNull();
|
||
expect(place.website).toBeNull();
|
||
expect(place.phone).toBeNull();
|
||
});
|
||
|
||
it('MAPS-183: drops permanently closed Google results and keeps the rest (#1341)', async () => {
|
||
indexOff();
|
||
mockDbGet.mockReturnValueOnce({ maps_api_key: 'some-key' });
|
||
const fetchMock = vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
json: async () => ({
|
||
places: [
|
||
{ id: 'gone', displayName: { text: 'Shuttered Bistro' }, businessStatus: 'CLOSED_PERMANENTLY' },
|
||
{ id: 'holiday', displayName: { text: 'Winter Break Cafe' }, businessStatus: 'CLOSED_TEMPORARILY' },
|
||
{ id: 'open', displayName: { text: 'Still Trading' }, businessStatus: 'OPERATIONAL' },
|
||
{ id: 'park', displayName: { text: 'City Park' } },
|
||
],
|
||
}),
|
||
});
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
const result = await svc.searchPlaces(1, 'bistro');
|
||
expect(result.places.map((p: any) => p.google_place_id)).toEqual(['holiday', 'open', 'park']);
|
||
// The field has to be requested or it never arrives to filter on.
|
||
const mask = (fetchMock.mock.calls[0][1].headers as Record<string, string>)['X-Goog-FieldMask'];
|
||
expect(mask).toContain('places.businessStatus');
|
||
});
|
||
|
||
it('MAPS-107: keeps 0 coordinates (equator / prime meridian) instead of nulling them', async () => {
|
||
mockDbGet.mockReturnValueOnce({ maps_api_key: 'some-key' });
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
json: async () => ({
|
||
places: [{ id: 'gid-null-island', displayName: { text: 'Null Island' }, location: { latitude: 0, longitude: 0 } }],
|
||
}),
|
||
}),
|
||
);
|
||
const result = await svc.searchPlaces(1, 'null island');
|
||
const place = result.places[0] as any;
|
||
expect(place.lat).toBe(0);
|
||
expect(place.lng).toBe(0);
|
||
});
|
||
});
|
||
|
||
// ── autocompletePlaces (fetch stubbed) ──────────────────────────────────────
|
||
|
||
describe('autocompletePlaces (fetch stubbed)', () => {
|
||
it('MAPS-081: uses Nominatim when user has no API key', async () => {
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
json: async () => [
|
||
{
|
||
osm_type: 'node',
|
||
osm_id: '1',
|
||
lat: '48.8',
|
||
lon: '2.3',
|
||
display_name: 'Paris, Île-de-France, France',
|
||
name: 'Paris',
|
||
},
|
||
],
|
||
}),
|
||
);
|
||
const result = await svc.autocompletePlaces(999, 'Paris');
|
||
expect(result.source).toBe('nominatim');
|
||
expect(result.suggestions).toHaveLength(1);
|
||
expect(result.suggestions[0].mainText).toBe('Paris');
|
||
expect(result.suggestions[0].placeId).toBe('node:1');
|
||
});
|
||
|
||
it('MAPS-082: uses Google when user has an API key', async () => {
|
||
mockDbGet.mockReturnValueOnce({ maps_api_key: 'ENCRYPTED' }).mockReturnValueOnce(null);
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
json: async () => ({
|
||
suggestions: [
|
||
{
|
||
placePrediction: {
|
||
placeId: 'ChIJ1234',
|
||
structuredFormat: {
|
||
mainText: { text: 'Eiffel Tower' },
|
||
secondaryText: { text: 'Paris, France' },
|
||
},
|
||
},
|
||
},
|
||
],
|
||
}),
|
||
}),
|
||
);
|
||
const result = await svc.autocompletePlaces(1, 'Eiffel');
|
||
expect(result.source).toBe('google');
|
||
expect(result.suggestions).toHaveLength(1);
|
||
expect(result.suggestions[0].placeId).toBe('ChIJ1234');
|
||
expect(result.suggestions[0].mainText).toBe('Eiffel Tower');
|
||
expect(result.suggestions[0].secondaryText).toBe('Paris, France');
|
||
});
|
||
|
||
it('MAPS-083: throws with Google error status when API returns non-ok', async () => {
|
||
mockDbGet.mockReturnValueOnce({ maps_api_key: 'some-key' });
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi.fn().mockResolvedValue({
|
||
ok: false,
|
||
status: 403,
|
||
json: async () => ({ error: { message: 'API key invalid' } }),
|
||
}),
|
||
);
|
||
await expect(svc.autocompletePlaces(1, 'anything')).rejects.toMatchObject({
|
||
message: 'API key invalid',
|
||
status: 403,
|
||
});
|
||
});
|
||
|
||
it('MAPS-083b: the autocomplete rejection logs the key source too (#1939)', async () => {
|
||
mockDbGet.mockReturnValueOnce({ maps_api_key: 'own-row-secret' });
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi.fn().mockResolvedValue({ ok: false, status: 403, json: async () => ({ error: { message: 'nope' } }) }),
|
||
);
|
||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||
await expect(svc.autocompletePlaces(4, 'anything')).rejects.toMatchObject({ status: 403 });
|
||
const logged = errorSpy.mock.calls.map((c) => String(c[0])).join('\n');
|
||
expect(logged).toContain('keySource=user-row');
|
||
expect(logged).not.toContain('own-row-secret');
|
||
errorSpy.mockRestore();
|
||
});
|
||
|
||
it('MAPS-084: throws generic message when Google error has no message', async () => {
|
||
mockDbGet.mockReturnValueOnce({ maps_api_key: 'some-key' });
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi.fn().mockResolvedValue({
|
||
ok: false,
|
||
status: 500,
|
||
json: async () => ({ error: {} }),
|
||
}),
|
||
);
|
||
await expect(svc.autocompletePlaces(1, 'anything')).rejects.toMatchObject({
|
||
message: 'Google Places Autocomplete error',
|
||
status: 500,
|
||
});
|
||
});
|
||
|
||
it('MAPS-085: returns empty suggestions when Google returns no results', async () => {
|
||
mockDbGet.mockReturnValueOnce({ maps_api_key: 'some-key' });
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
json: async () => ({ suggestions: [] }),
|
||
}),
|
||
);
|
||
const result = await svc.autocompletePlaces(1, 'very obscure place');
|
||
expect(result.source).toBe('google');
|
||
expect(result.suggestions).toHaveLength(0);
|
||
});
|
||
|
||
it('MAPS-086: filters out suggestions without placePrediction', async () => {
|
||
mockDbGet.mockReturnValueOnce({ maps_api_key: 'some-key' });
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
json: async () => ({
|
||
suggestions: [
|
||
{ placePrediction: { placeId: 'A', structuredFormat: { mainText: { text: 'Good' } } } },
|
||
{ queryPrediction: { text: 'some query' } },
|
||
{ placePrediction: { placeId: 'B', structuredFormat: { mainText: { text: 'Also Good' } } } },
|
||
],
|
||
}),
|
||
}),
|
||
);
|
||
const result = await svc.autocompletePlaces(1, 'test');
|
||
expect(result.suggestions).toHaveLength(2);
|
||
expect(result.suggestions[0].placeId).toBe('A');
|
||
expect(result.suggestions[1].placeId).toBe('B');
|
||
});
|
||
|
||
it('MAPS-087: limits results to 5 suggestions', async () => {
|
||
mockDbGet.mockReturnValueOnce({ maps_api_key: 'some-key' });
|
||
const manySuggestions = Array.from({ length: 10 }, (_, i) => ({
|
||
placePrediction: {
|
||
placeId: `id-${i}`,
|
||
structuredFormat: { mainText: { text: `Place ${i}` } },
|
||
},
|
||
}));
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
json: async () => ({ suggestions: manySuggestions }),
|
||
}),
|
||
);
|
||
const result = await svc.autocompletePlaces(1, 'test');
|
||
expect(result.suggestions).toHaveLength(5);
|
||
});
|
||
|
||
it('MAPS-088: includes locationBias in Google request when provided', async () => {
|
||
indexOff();
|
||
mockDbGet.mockReturnValueOnce({ maps_api_key: 'test-key' });
|
||
const fetchMock = vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
json: async () => ({ suggestions: [] }),
|
||
});
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
await svc.autocompletePlaces(1, 'test', 'en', { low: { lat: 48.5, lng: 2.0 }, high: { lat: 49.0, lng: 2.8 } });
|
||
|
||
expect(fetchMock).toHaveBeenCalledOnce();
|
||
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
||
expect(body.locationBias).toEqual({
|
||
rectangle: {
|
||
low: { latitude: 48.5, longitude: 2.0 },
|
||
high: { latitude: 49.0, longitude: 2.8 },
|
||
},
|
||
});
|
||
});
|
||
|
||
it('MAPS-089: omits locationBias from Google request when not provided', async () => {
|
||
indexOff();
|
||
mockDbGet.mockReturnValueOnce({ maps_api_key: 'test-key' });
|
||
const fetchMock = vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
json: async () => ({ suggestions: [] }),
|
||
});
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
await svc.autocompletePlaces(1, 'test', 'en');
|
||
|
||
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
||
expect(body.locationBias).toBeUndefined();
|
||
});
|
||
|
||
it('MAPS-090: handles missing structuredFormat fields gracefully', async () => {
|
||
mockDbGet.mockReturnValueOnce({ maps_api_key: 'some-key' });
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
json: async () => ({
|
||
suggestions: [{ placePrediction: { placeId: 'sparse-id' } }],
|
||
}),
|
||
}),
|
||
);
|
||
const result = await svc.autocompletePlaces(1, 'sparse');
|
||
expect(result.suggestions[0].placeId).toBe('sparse-id');
|
||
expect(result.suggestions[0].mainText).toBe('');
|
||
expect(result.suggestions[0].secondaryText).toBe('');
|
||
});
|
||
|
||
it('MAPS-091: Nominatim fallback returns empty suggestions on searchNominatim error', async () => {
|
||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network error')));
|
||
const result = await svc.autocompletePlaces(999, 'fail');
|
||
expect(result.source).toBe('nominatim');
|
||
expect(result.suggestions).toHaveLength(0);
|
||
});
|
||
|
||
it('MAPS-092: Nominatim fallback splits address into mainText and secondaryText', async () => {
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
json: async () => [
|
||
{
|
||
osm_type: 'way',
|
||
osm_id: '42',
|
||
lat: '51.5',
|
||
lon: '-0.1',
|
||
display_name: 'Big Ben, Westminster, London, UK',
|
||
name: 'Big Ben',
|
||
},
|
||
],
|
||
}),
|
||
);
|
||
const result = await svc.autocompletePlaces(999, 'Big Ben');
|
||
expect(result.suggestions[0].mainText).toBe('Big Ben');
|
||
expect(result.suggestions[0].secondaryText).toBe('Westminster, London, UK');
|
||
});
|
||
|
||
it('MAPS-093: Nominatim fallback filters out results with empty osm_id', async () => {
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
json: async () => [
|
||
{ osm_type: 'node', osm_id: '1', lat: '48.8', lon: '2.3', display_name: 'Paris, France', name: 'Paris' },
|
||
{ osm_type: 'node', osm_id: '', lat: '51.5', lon: '-0.1', display_name: 'London, UK', name: 'London' },
|
||
{ osm_type: 'way', osm_id: '3', lat: '52.5', lon: '13.4', display_name: 'Berlin, Germany', name: 'Berlin' },
|
||
],
|
||
}),
|
||
);
|
||
const result = await svc.autocompletePlaces(999, 'test');
|
||
expect(result.suggestions).toHaveLength(2);
|
||
expect(result.suggestions.map((s) => s.placeId)).toEqual(['node:1', 'way:3']);
|
||
});
|
||
});
|
||
|
||
// ── getPlaceDetails (fetch stubbed) ─────────────────────────────────────────
|
||
|
||
describe('getPlaceDetails (fetch stubbed)', () => {
|
||
it('MAPS-040: handles OSM placeId (way:id) via Overpass', async () => {
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
json: async () => ({ elements: [{ tags: { website: 'https://eiffel.com' } }] }),
|
||
}),
|
||
);
|
||
const result = await svc.getPlaceDetails(1, 'way:12345');
|
||
expect(result.place).toBeDefined();
|
||
expect((result.place as any).source).toBe('openstreetmap');
|
||
expect((result.place as any).website).toBe('https://eiffel.com');
|
||
});
|
||
|
||
it('MAPS-040b: handles OSM placeId when Overpass returns no tags (element missing)', async () => {
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
json: async () => ({ elements: [] }),
|
||
}),
|
||
);
|
||
const result = await svc.getPlaceDetails(1, 'node:99999');
|
||
expect((result.place as any).source).toBe('openstreetmap');
|
||
expect((result.place as any).website).toBeNull();
|
||
});
|
||
|
||
it('MAPS-040c: an OSM id with anything but digits after the colon is no place, and asks nobody', async () => {
|
||
// The route takes the id as a path segment and nothing validates it before
|
||
// it reaches here. Split at the colon, the rest went into an Overpass query
|
||
// as it was and to Nominatim as an osm_ids value, so a doctored id ran a
|
||
// query of its own on the mirror. A coordinate pseudo-id and a legacy image
|
||
// URL carry a colon too and have no details source either way.
|
||
const fetchMock = vi.fn();
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
for (const id of [
|
||
'node:1);nwr["amenity"](-90,-180,90,180',
|
||
'way:12345;out geom',
|
||
'relation:',
|
||
'node:abc',
|
||
'coords:48.8,2.3',
|
||
'https://example.test/pic.jpg',
|
||
]) {
|
||
await expect(svc.getPlaceDetails(1, id), id).resolves.toEqual({ place: null });
|
||
}
|
||
expect(fetchMock).not.toHaveBeenCalled();
|
||
});
|
||
|
||
// A Google id has no OpenStreetMap equivalent, so without a key there is
|
||
// nothing to look up. That is an empty result, not a client error: search and
|
||
// autocomplete already answer their keyless case with the OSM stack, and this
|
||
// used to be the one path that threw, so an instance without a key produced a
|
||
// 400 every time an older Google place was opened.
|
||
it('MAPS-041: answers with an empty place when a Google id has no API key', async () => {
|
||
mockDbGet.mockReturnValue(undefined);
|
||
await expect(svc.getPlaceDetails(999, 'ChIJNotAnOsmId')).resolves.toEqual({ place: null });
|
||
});
|
||
|
||
it('MAPS-041g: the expanded lookup degrades the same way', async () => {
|
||
mockDbGet.mockReturnValue(undefined);
|
||
await expect(svc.getPlaceDetailsExpanded(999, 'ChIJNotAnOsmId', 'en', false)).resolves.toEqual({ place: null });
|
||
});
|
||
|
||
it('MAPS-041b: returns full Google place details on happy path', async () => {
|
||
mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' });
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
json: async () => ({
|
||
id: 'ChIJ123',
|
||
displayName: { text: 'Eiffel Tower' },
|
||
formattedAddress: 'Champ de Mars, 5 Av. Anatole France, 75007 Paris',
|
||
location: { latitude: 48.8584, longitude: 2.2945 },
|
||
rating: 4.7,
|
||
userRatingCount: 200000,
|
||
websiteUri: 'https://www.toureiffel.paris',
|
||
nationalPhoneNumber: '+33 892 70 12 39',
|
||
regularOpeningHours: {
|
||
weekdayDescriptions: ['Monday: 9:00 AM – 12:00 AM'],
|
||
openNow: true,
|
||
},
|
||
// The Places API returns a cid-style URL with no ftid, so google_ftid stays null
|
||
// and the precise query_place_id link is used on the client instead.
|
||
googleMapsUri: 'https://maps.google.com/?cid=10403719659250533155',
|
||
editorialSummary: { text: 'Iconic iron tower.' },
|
||
reviews: [
|
||
{
|
||
authorAttribution: { displayName: 'John', photoUri: 'https://photo.url' },
|
||
rating: 5,
|
||
text: { text: 'Amazing!' },
|
||
relativePublishTimeDescription: '2 weeks ago',
|
||
},
|
||
],
|
||
photos: [{ name: 'places/ChIJ123/photos/photo1', authorAttributions: [{ displayName: 'Jane' }] }],
|
||
}),
|
||
}),
|
||
);
|
||
const result = await svc.getPlaceDetails(1, 'ChIJ123');
|
||
const place = result.place as any;
|
||
expect(place.google_place_id).toBe('ChIJ123');
|
||
expect(place.google_ftid).toBeNull();
|
||
expect(place.name).toBe('Eiffel Tower');
|
||
expect(place.rating).toBe(4.7);
|
||
expect(place.rating_count).toBe(200000);
|
||
expect(place.open_now).toBe(true);
|
||
expect(place.source).toBe('google');
|
||
// Lean mask — reviews/summary not fetched in getPlaceDetails; use getPlaceDetailsExpanded for those
|
||
expect(place.reviews).toHaveLength(0);
|
||
expect(place.summary).toBeNull();
|
||
});
|
||
|
||
it('MAPS-041b2: normalises non-standard TREK language codes for Google (br→pt-BR, gr→el)', async () => {
|
||
const fetchMock = vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
json: async () => ({ id: 'ChIJ1', displayName: { text: 'X' }, location: { latitude: 0, longitude: 0 } }),
|
||
});
|
||
mockDbGet.mockReturnValue({ maps_api_key: 'gkey' });
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
|
||
await svc.getPlaceDetails(1, 'ChIJ-br', 'br');
|
||
expect(String(fetchMock.mock.calls[0][0])).toContain('languageCode=pt-BR');
|
||
|
||
await svc.getPlaceDetails(1, 'ChIJ-gr', 'gr');
|
||
expect(String(fetchMock.mock.calls[1][0])).toContain('languageCode=el');
|
||
|
||
// A code that is already valid passes through unchanged.
|
||
await svc.getPlaceDetails(1, 'ChIJ-de', 'de');
|
||
expect(String(fetchMock.mock.calls[2][0])).toContain('languageCode=de');
|
||
});
|
||
|
||
it('MAPS-109: defaults to English when no language is given (the legacy de default was a leftover)', async () => {
|
||
const fetchMock = vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
json: async () => ({ id: 'ChIJ1', displayName: { text: 'X' } }),
|
||
});
|
||
mockDbGet.mockReturnValue({ maps_api_key: 'gkey' });
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
|
||
await svc.getPlaceDetails(1, 'ChIJ-nolang');
|
||
expect(String(fetchMock.mock.calls[0][0])).toContain('languageCode=en');
|
||
|
||
// refresh=true skips the expanded-cache read (the blanket mockDbGet return
|
||
// would otherwise be mistaken for a cached row).
|
||
await svc.getPlaceDetailsExpanded(1, 'ChIJ-nolang-exp', undefined, true);
|
||
expect(String(fetchMock.mock.calls[1][0])).toContain('languageCode=en');
|
||
});
|
||
|
||
it('MAPS-041c: throws with status when Google API returns non-ok response', async () => {
|
||
mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' });
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi.fn().mockResolvedValue({
|
||
ok: false,
|
||
status: 404,
|
||
json: async () => ({ error: { message: 'Place not found' } }),
|
||
}),
|
||
);
|
||
await expect(svc.getPlaceDetails(1, 'ChIJMissing')).rejects.toMatchObject({
|
||
message: 'Place not found',
|
||
status: 404,
|
||
});
|
||
});
|
||
|
||
it('MAPS-041d: getPlaceDetailsExpanded maps reviews with optional fields absent to null', async () => {
|
||
mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' });
|
||
// expanded=1 cache miss → return undefined
|
||
mockDbGet.mockReturnValueOnce(undefined);
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
json: async () => ({
|
||
id: 'ChIJ456',
|
||
reviews: [
|
||
// All optional fields absent
|
||
{},
|
||
],
|
||
}),
|
||
}),
|
||
);
|
||
const result = await svc.getPlaceDetailsExpanded(1, 'ChIJ456');
|
||
const review = (result.place as any).reviews[0];
|
||
expect(review.author).toBeNull();
|
||
expect(review.rating).toBeNull();
|
||
expect(review.text).toBeNull();
|
||
expect(review.time).toBeNull();
|
||
expect(review.photo).toBeNull();
|
||
});
|
||
|
||
it('MAPS-040c: OSM path enriches name/address/coords from Nominatim (serial fetch)', async () => {
|
||
const fetchMock = vi
|
||
.fn()
|
||
// First call: Overpass (returns element with tags but no coords)
|
||
.mockResolvedValueOnce({
|
||
ok: true,
|
||
json: async () => ({ elements: [{ tags: { website: 'https://example.com' } }] }),
|
||
})
|
||
// Second call: Nominatim /lookup
|
||
.mockResolvedValueOnce({
|
||
ok: true,
|
||
json: async () => [
|
||
{
|
||
osm_type: 'way',
|
||
osm_id: '5',
|
||
lat: '48.85',
|
||
lon: '2.29',
|
||
display_name: 'Eiffel Tower, Paris, France',
|
||
name: 'Eiffel Tower',
|
||
},
|
||
],
|
||
});
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
const result = await svc.getPlaceDetails(1, 'way:5');
|
||
const place = result.place as any;
|
||
expect(place.name).toBe('Eiffel Tower');
|
||
expect(place.address).toBe('Eiffel Tower, Paris, France');
|
||
expect(place.lat).toBeCloseTo(48.85);
|
||
expect(place.lng).toBeCloseTo(2.29);
|
||
expect(place.source).toBe('openstreetmap');
|
||
// Overpass first, then Nominatim — two total fetch calls
|
||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||
const overpassUrl = fetchMock.mock.calls[0][0] as string;
|
||
const nominatimUrl = fetchMock.mock.calls[1][0] as string;
|
||
expect(overpassUrl).toContain('overpass');
|
||
expect(nominatimUrl).toContain('nominatim');
|
||
});
|
||
|
||
it('MAPS-041e: open_now is null when regularOpeningHours.openNow is undefined', async () => {
|
||
mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' });
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
json: async () => ({
|
||
id: 'ChIJ789',
|
||
regularOpeningHours: {
|
||
weekdayDescriptions: ['Monday: 9:00 AM – 5:00 PM'],
|
||
// openNow intentionally absent
|
||
},
|
||
}),
|
||
}),
|
||
);
|
||
const result = await svc.getPlaceDetails(1, 'ChIJ789');
|
||
expect((result.place as any).open_now).toBeNull();
|
||
});
|
||
|
||
it('MAPS-041f: open_now is false when regularOpeningHours.openNow is false', async () => {
|
||
mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' });
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
json: async () => ({
|
||
id: 'ChIJClosed',
|
||
regularOpeningHours: {
|
||
weekdayDescriptions: ['Monday: 9:00 AM – 5:00 PM'],
|
||
openNow: false,
|
||
},
|
||
}),
|
||
}),
|
||
);
|
||
const result = await svc.getPlaceDetails(1, 'ChIJClosed');
|
||
// false is preserved (not coerced to null) via the ?? null operator
|
||
expect((result.place as any).open_now).toBe(false);
|
||
});
|
||
|
||
it('MAPS-041f2: hands the structured opening periods and special days to the client', async () => {
|
||
mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' });
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
json: async () => ({
|
||
id: 'ChIJPeriods',
|
||
regularOpeningHours: {
|
||
// Localised display text — the client shows it but must not parse it.
|
||
weekdayDescriptions: ['月曜日: 9:00~18:00'],
|
||
openNow: true,
|
||
periods: [{ open: { day: 1, hour: 9, minute: 0 }, close: { day: 1, hour: 18, minute: 0 } }],
|
||
specialDays: [{ date: { year: 2026, month: 12, day: 25 } }],
|
||
},
|
||
}),
|
||
}),
|
||
);
|
||
const place = (await svc.getPlaceDetails(1, 'ChIJPeriods')).place as any;
|
||
expect(place.opening_periods).toEqual([
|
||
{ open: { day: 1, hour: 9, minute: 0 }, close: { day: 1, hour: 18, minute: 0 } },
|
||
]);
|
||
expect(place.opening_special_days).toEqual(['2026-12-25']);
|
||
expect(place.opening_hours).toEqual(['月曜日: 9:00~18:00']);
|
||
});
|
||
|
||
it('MAPS-041f3: opening_periods is null when Google sends no periods', async () => {
|
||
mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' });
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
json: async () => ({
|
||
id: 'ChIJNoPeriods',
|
||
regularOpeningHours: { weekdayDescriptions: ['Monday: 9:00 AM – 5:00 PM'], openNow: false },
|
||
}),
|
||
}),
|
||
);
|
||
const place = (await svc.getPlaceDetails(1, 'ChIJNoPeriods')).place as any;
|
||
expect(place.opening_periods).toBeNull();
|
||
expect(place.opening_special_days).toBeNull();
|
||
});
|
||
|
||
it('MAPS-041f4: getPlaceDetailsExpanded carries the periods too', async () => {
|
||
mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' });
|
||
// expanded=1 cache miss
|
||
mockDbGet.mockReturnValueOnce(undefined);
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
json: async () => ({
|
||
id: 'ChIJExpandedPeriods',
|
||
// A place that never closes: one period, no close point.
|
||
regularOpeningHours: { openNow: true, periods: [{ open: { day: 0, hour: 0, minute: 0 } }] },
|
||
}),
|
||
}),
|
||
);
|
||
const place = (await svc.getPlaceDetailsExpanded(1, 'ChIJExpandedPeriods')).place as any;
|
||
expect(place.opening_periods).toEqual([{ open: { day: 0, hour: 0, minute: 0 }, close: null }]);
|
||
});
|
||
|
||
it('MAPS-041g: getPlaceDetailsExpanded truncates reviews to first 5 entries', async () => {
|
||
mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' });
|
||
// expanded=1 cache miss
|
||
mockDbGet.mockReturnValueOnce(undefined);
|
||
const manyReviews = Array.from({ length: 8 }, (_, i) => ({
|
||
authorAttribution: { displayName: `User${i}` },
|
||
rating: 4,
|
||
text: { text: 'Good' },
|
||
relativePublishTimeDescription: '1 day ago',
|
||
}));
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
json: async () => ({ id: 'ChIJMany', reviews: manyReviews }),
|
||
}),
|
||
);
|
||
const result = await svc.getPlaceDetailsExpanded(1, 'ChIJMany');
|
||
expect((result.place as any).reviews).toHaveLength(5);
|
||
});
|
||
|
||
// The client sends whatever id the place carries, and the expanded lookup used to
|
||
// forward all of them — including OSM ids — to Google, which bills the 400
|
||
// INVALID_ARGUMENT it answers with (#1727).
|
||
it('MAPS-041h: getPlaceDetailsExpanded serves an OSM id from Overpass instead of Google', async () => {
|
||
mockDbGet.mockReturnValue({ maps_api_key: 'gkey' });
|
||
const fetchMock = vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
json: async () => ({ elements: [{ tags: { website: 'https://nerja.example' } }] }),
|
||
});
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
const result = await svc.getPlaceDetailsExpanded(1, 'node:5255005321');
|
||
expect((result.place as any).source).toBe('openstreetmap');
|
||
expect((result.place as any).website).toBe('https://nerja.example');
|
||
expect(fetchMock.mock.calls.map((call) => String(call[0])).some((url) => url.includes('places.googleapis.com')))
|
||
.toBe(false);
|
||
});
|
||
|
||
it('MAPS-041i: getPlaceDetailsExpanded answers a coordinate pseudo-id with no place at all', async () => {
|
||
mockDbGet.mockReturnValue({ maps_api_key: 'gkey' });
|
||
const fetchMock = vi.fn();
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
await expect(svc.getPlaceDetailsExpanded(1, 'coords:48.8,2.3')).resolves.toEqual({ place: null });
|
||
expect(fetchMock).not.toHaveBeenCalled();
|
||
});
|
||
});
|
||
|
||
// ── getPlacePhoto (fetch stubbed) ────────────────────────────────────────────
|
||
|
||
describe('getPlacePhoto (fetch stubbed)', () => {
|
||
it('MAPS-042: returns proxy URL for coordinate-based lookup via Wikimedia (no API key)', async () => {
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi
|
||
.fn()
|
||
// First call: Wikimedia Commons API
|
||
.mockResolvedValueOnce({
|
||
ok: true,
|
||
json: async () => ({
|
||
query: { pages: { '1': { thumbnail: { source: 'https://wiki.org/photo.jpg' } } } },
|
||
}),
|
||
})
|
||
// Second call: fetch Wikimedia image bytes
|
||
.mockResolvedValueOnce({
|
||
ok: true,
|
||
arrayBuffer: async () => new ArrayBuffer(100),
|
||
}),
|
||
);
|
||
const placeId = 'coords:48.8,2.3';
|
||
const result = await svc.getPlacePhoto(999, placeId, 48.8, 2.3, 'Eiffel Tower');
|
||
expect(result.photoUrl).toBe(`/api/maps/place-photo/${encodeURIComponent(placeId)}/bytes`);
|
||
expect(mockCachePut).toHaveBeenCalledOnce();
|
||
});
|
||
|
||
it('MAPS-043: resolves with photoUrl null when Wikimedia returns nothing and no API key', async () => {
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
json: async () => ({ query: { pages: {} } }),
|
||
}),
|
||
);
|
||
await expect(svc.getPlacePhoto(999, 'coords:0.0,0.0', 0, 0)).resolves.toEqual({
|
||
photoUrl: null,
|
||
attribution: null,
|
||
});
|
||
});
|
||
|
||
it('MAPS-043b: returns cached photo when disk cache returns a hit', async () => {
|
||
const placeId = `coords:cache-test-${Date.now()}`;
|
||
const cachedUrl = `/api/maps/place-photo/${encodeURIComponent(placeId)}/bytes`;
|
||
mockCacheGet.mockResolvedValue({
|
||
photoUrl: cachedUrl,
|
||
attribution: null,
|
||
});
|
||
const fetchMock = vi.fn();
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
const result = await svc.getPlacePhoto(999, placeId, 48.8, 2.3, 'Cache Test');
|
||
expect(result.photoUrl).toBe(cachedUrl);
|
||
expect(fetchMock).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('MAPS-043c: serves the negative cache with photoUrl null and no network request', async () => {
|
||
mockCacheGetErrored.mockReturnValue(true);
|
||
const fetchMock = vi.fn();
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
const errorId = `coords:error-cache-${Date.now()}`;
|
||
await expect(svc.getPlacePhoto(999, errorId, 0, 0)).resolves.toEqual({ photoUrl: null, attribution: null });
|
||
expect(fetchMock).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('MAPS-043d: resolves with photoUrl null when lat/lng are NaN and no API key', async () => {
|
||
const nanId = `coords:nan-test-${Date.now()}`;
|
||
await expect(svc.getPlacePhoto(999, nanId, NaN, NaN)).resolves.toEqual({ photoUrl: null, attribution: null });
|
||
});
|
||
|
||
it('MAPS-043e: falls through to photoUrl null when the Wikimedia fetch throws', async () => {
|
||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network fail')));
|
||
const throwId = `coords:throw-test-${Date.now()}`;
|
||
await expect(svc.getPlacePhoto(999, throwId, 48.8, 2.3, 'Place')).resolves.toEqual({
|
||
photoUrl: null,
|
||
attribution: null,
|
||
});
|
||
});
|
||
|
||
it('MAPS-043f: an in-flight lookup that found nothing resolves the waiter with photoUrl null', async () => {
|
||
mockCacheGetInFlight.mockReturnValue(Promise.resolve(null));
|
||
const fetchMock = vi.fn();
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
await expect(svc.getPlacePhoto(999, 'ChIJInFlight', 48.8, 2.3)).resolves.toEqual({
|
||
photoUrl: null,
|
||
attribution: null,
|
||
});
|
||
expect(fetchMock).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('MAPS-044: returns proxy URL via Google path when API key present and photos exist', async () => {
|
||
mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' });
|
||
const fetchMock = vi
|
||
.fn()
|
||
// First call: get place details (with photos)
|
||
.mockResolvedValueOnce({
|
||
ok: true,
|
||
text: async () =>
|
||
JSON.stringify({
|
||
photos: [{ name: 'places/ChIJABC/photos/photo1', authorAttributions: [{ displayName: 'Photographer' }] }],
|
||
}),
|
||
})
|
||
// Second call: fetch image bytes
|
||
.mockResolvedValueOnce({
|
||
ok: true,
|
||
arrayBuffer: async () => new ArrayBuffer(200),
|
||
});
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
const uniqueId = `ChIJABC-${Date.now()}`;
|
||
const result = await svc.getPlacePhoto(1, uniqueId, 48.8, 2.3, 'Place');
|
||
expect(result.photoUrl).toBe(`/api/maps/place-photo/${encodeURIComponent(uniqueId)}/bytes`);
|
||
expect(result.attribution).toBe('Photographer');
|
||
expect(mockCachePut).toHaveBeenCalledOnce();
|
||
});
|
||
|
||
it('MAPS-044b: resolves with photoUrl null when Google details fetch returns non-ok', async () => {
|
||
mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' });
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi.fn().mockResolvedValue({
|
||
ok: false,
|
||
status: 403,
|
||
text: async () => JSON.stringify({ error: { message: 'Forbidden' } }),
|
||
}),
|
||
);
|
||
const errId = `ChIJErr-${Date.now()}`;
|
||
await expect(svc.getPlacePhoto(1, errId, 48.8, 2.3)).resolves.toEqual({ photoUrl: null, attribution: null });
|
||
// A rejected provider call says nothing about the place — remember it as a
|
||
// failed attempt (minutes), not as "this place has no photo" (a day).
|
||
expect(mockCacheMarkError).toHaveBeenCalledWith(errId, 'provider-error');
|
||
});
|
||
|
||
it('MAPS-044b2: remembers a place both providers came up empty for as a lasting miss', async () => {
|
||
mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' });
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi
|
||
.fn()
|
||
// Google answers, the place simply has no photos.
|
||
.mockResolvedValueOnce({ ok: true, text: async () => JSON.stringify({ photos: [] }) })
|
||
// Wikimedia has no article near the coordinates either.
|
||
.mockResolvedValueOnce({ ok: true, json: async () => ({ query: { pages: {} } }) }),
|
||
);
|
||
const emptyId = `ChIJEmpty-${Date.now()}`;
|
||
await expect(svc.getPlacePhoto(1, emptyId, 48.8, 2.3)).resolves.toEqual({ photoUrl: null, attribution: null });
|
||
expect(mockCacheMarkError).toHaveBeenCalledWith(emptyId, 'no-photo');
|
||
});
|
||
|
||
it('MAPS-044b3: a failed Wikimedia image download counts as a failed attempt', async () => {
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi
|
||
.fn()
|
||
.mockResolvedValueOnce({
|
||
ok: true,
|
||
json: async () => ({ query: { pages: { '1': { thumbnail: { source: 'https://wiki.org/photo.jpg' } } } } }),
|
||
})
|
||
.mockResolvedValueOnce({ ok: false, status: 503, arrayBuffer: async () => new ArrayBuffer(0) }),
|
||
);
|
||
const downId = `coords:down-${Date.now()}`;
|
||
await expect(svc.getPlacePhoto(999, downId, 48.8, 2.3, 'Place')).resolves.toEqual({
|
||
photoUrl: null,
|
||
attribution: null,
|
||
});
|
||
expect(mockCacheMarkError).toHaveBeenCalledWith(downId, 'provider-error');
|
||
});
|
||
|
||
it('MAPS-044c: resolves with photoUrl null when the Google place has no photos', async () => {
|
||
mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' });
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
text: async () => JSON.stringify({ photos: [] }),
|
||
}),
|
||
);
|
||
const noPhotoId = `ChIJNone-${Date.now()}`;
|
||
await expect(svc.getPlacePhoto(1, noPhotoId, 48.8, 2.3)).resolves.toEqual({ photoUrl: null, attribution: null });
|
||
});
|
||
|
||
it('MAPS-044d: resolves with photoUrl null when the media endpoint returns non-ok status', async () => {
|
||
mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' });
|
||
const fetchMock = vi
|
||
.fn()
|
||
.mockResolvedValueOnce({
|
||
ok: true,
|
||
text: async () =>
|
||
JSON.stringify({
|
||
photos: [{ name: 'places/ChIJXYZ/photos/photo1', authorAttributions: [] }],
|
||
}),
|
||
})
|
||
.mockResolvedValueOnce({
|
||
ok: false,
|
||
status: 403,
|
||
arrayBuffer: async () => new ArrayBuffer(0),
|
||
});
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
const noUriId = `ChIJXYZ-${Date.now()}`;
|
||
await expect(svc.getPlacePhoto(1, noUriId, 48.8, 2.3)).resolves.toEqual({ photoUrl: null, attribution: null });
|
||
});
|
||
|
||
it('MAPS-044e: returns proxy URL with null attribution when authorAttributions is empty', async () => {
|
||
mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' });
|
||
const fetchMock = vi
|
||
.fn()
|
||
.mockResolvedValueOnce({
|
||
ok: true,
|
||
text: async () =>
|
||
JSON.stringify({
|
||
photos: [{ name: 'places/ChIJNoAttr/photos/photo1', authorAttributions: [] }],
|
||
}),
|
||
})
|
||
.mockResolvedValueOnce({
|
||
ok: true,
|
||
arrayBuffer: async () => new ArrayBuffer(150),
|
||
});
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
const noAttrId = `ChIJNoAttr-${Date.now()}`;
|
||
const result = await svc.getPlacePhoto(1, noAttrId, 48.8, 2.3);
|
||
expect(result.photoUrl).toBe(`/api/maps/place-photo/${encodeURIComponent(noAttrId)}/bytes`);
|
||
expect(result.attribution).toBeNull();
|
||
});
|
||
|
||
it('MAPS-044f: uses Wikimedia and returns proxy URL when API key present but placeId is coords: prefix', async () => {
|
||
mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' });
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi
|
||
.fn()
|
||
.mockResolvedValueOnce({
|
||
ok: true,
|
||
json: async () => ({
|
||
query: { pages: { '1': { thumbnail: { source: 'https://wiki.org/coords-photo.jpg' } } } },
|
||
}),
|
||
})
|
||
.mockResolvedValueOnce({
|
||
ok: true,
|
||
arrayBuffer: async () => new ArrayBuffer(120),
|
||
}),
|
||
);
|
||
const uniqueId = `coords:44f-test-${Date.now()}`;
|
||
const result = await svc.getPlacePhoto(1, uniqueId, 48.8, 2.3, 'Coords Place');
|
||
expect(result.photoUrl).toBe(`/api/maps/place-photo/${encodeURIComponent(uniqueId)}/bytes`);
|
||
expect(mockCachePut).toHaveBeenCalledOnce();
|
||
});
|
||
|
||
it('MAPS-044g: falls back to Wikipedia/OSM for a Google place_id when the Google photo call fails', async () => {
|
||
// A key is present and the placeId is a Google id, but Google rejects the
|
||
// photo request (e.g. 403). The lookup must still return an image via the
|
||
// coordinate-based Wikipedia fallback instead of giving up with a 404 —
|
||
// matching what right-click (coords:) places already do.
|
||
mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' });
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi
|
||
.fn()
|
||
// 1) Google photo details → 403
|
||
.mockResolvedValueOnce({
|
||
ok: false,
|
||
status: 403,
|
||
text: async () => JSON.stringify({ error: { message: 'PERMISSION_DENIED' } }),
|
||
})
|
||
// 2) Wikipedia pageimages → thumbnail
|
||
.mockResolvedValueOnce({
|
||
ok: true,
|
||
json: async () => ({ query: { pages: { '1': { thumbnail: { source: 'https://wiki.org/guinness.jpg' } } } } }),
|
||
})
|
||
// 3) image bytes
|
||
.mockResolvedValueOnce({
|
||
ok: true,
|
||
arrayBuffer: async () => new ArrayBuffer(200),
|
||
}),
|
||
);
|
||
const placeId = `ChIJFallback-${Date.now()}`;
|
||
const result = await svc.getPlacePhoto(1, placeId, 53.34, -6.28, 'Guinness Storehouse');
|
||
expect(result.photoUrl).toBe(`/api/maps/place-photo/${encodeURIComponent(placeId)}/bytes`);
|
||
expect(result.attribution).toBe('Wikipedia');
|
||
expect(mockCachePut).toHaveBeenCalledOnce();
|
||
});
|
||
|
||
// OSM ids are what the client sends whenever a place has no google_place_id.
|
||
// Google rejects them with a billable 400 INVALID_ARGUMENT, so they must go
|
||
// straight to the Wikimedia fallback like coords: ids already do.
|
||
it.each(['node:5255005321', 'way:84527326', 'relation:345407'])(
|
||
'MAPS-044h: skips Google for the OSM id %s even when an API key is configured',
|
||
async (osmId) => {
|
||
mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' });
|
||
const fetchMock = vi
|
||
.fn()
|
||
.mockResolvedValueOnce({
|
||
ok: true,
|
||
json: async () => ({ query: { pages: { '1': { thumbnail: { source: 'https://wiki.org/osm.jpg' } } } } }),
|
||
})
|
||
.mockResolvedValueOnce({
|
||
ok: true,
|
||
arrayBuffer: async () => new ArrayBuffer(90),
|
||
});
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
const result = await svc.getPlacePhoto(1, osmId, 36.7617, -3.8448, 'Cueva de Nerja');
|
||
expect(result.photoUrl).toBe(`/api/maps/place-photo/${encodeURIComponent(osmId)}/bytes`);
|
||
const requested = fetchMock.mock.calls.map((call) => String(call[0]));
|
||
expect(requested.some((url) => url.includes('places.googleapis.com'))).toBe(false);
|
||
},
|
||
);
|
||
|
||
it('MAPS-044i: an OSM id with no Wikimedia hit resolves with photoUrl null instead of a 404', async () => {
|
||
mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' });
|
||
const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ query: { pages: {} } }) });
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
await expect(svc.getPlacePhoto(1, 'node:2481346642', 36.76, -3.84, 'Nerja')).resolves.toEqual({
|
||
photoUrl: null,
|
||
attribution: null,
|
||
});
|
||
expect(fetchMock.mock.calls.map((call) => String(call[0])).some((url) => url.includes('places.googleapis.com')))
|
||
.toBe(false);
|
||
});
|
||
});
|
||
|
||
describe('isGooglePlaceId', () => {
|
||
it('MAPS-045: accepts Google place ids and rejects the ids Google cannot resolve', () => {
|
||
expect(isGooglePlaceId('ChIJLU7jZClu5kcR4PcOOO6p3I0')).toBe(true);
|
||
expect(isGooglePlaceId('coords:48.8,2.3')).toBe(false);
|
||
expect(isGooglePlaceId('node:5255005321')).toBe(false);
|
||
expect(isGooglePlaceId('way:84527326')).toBe(false);
|
||
expect(isGooglePlaceId('relation:345407')).toBe(false);
|
||
expect(isGooglePlaceId('https://lh3.googleusercontent.com/photo.jpg')).toBe(false);
|
||
// The collection views send the bare coordinate pair when a place has no ids.
|
||
expect(isGooglePlaceId('36.7617499,-3.8448432')).toBe(false);
|
||
});
|
||
|
||
it('MAPS-045b: rejects the TREK index ids that search and autocomplete now hand out', () => {
|
||
// The index answers before Google, so `gers:` is what an ordinary new place
|
||
// carries. Sending one to Google costs a billable 400 INVALID_ARGUMENT and can
|
||
// never return anything, and three separate call sites gate on this function:
|
||
// photo refs, the editorial summary, and the place-photo route.
|
||
expect(isGooglePlaceId('gers:08f2ab12345678901234567890abcd')).toBe(false);
|
||
expect(isGooglePlaceId('gers:abc-123')).toBe(false);
|
||
// Case-insensitive, like every other prefix in the list.
|
||
expect(isGooglePlaceId('GERS:abc-123')).toBe(false);
|
||
// The photo-picker cache key keeps its suffix meaning on top of the prefix.
|
||
expect(isGooglePlaceId('gers:abc-123~p3')).toBe(false);
|
||
// Guard against over-matching: a Google id that merely starts with those
|
||
// letters is still a Google id, because the prefix only counts before a colon.
|
||
expect(isGooglePlaceId('gersChIJLU7jZClu5kcR')).toBe(true);
|
||
});
|
||
|
||
it('MAPS-045c: rejects plugin ids, which name a plugin index and never a Google record (#2221, #1781)', () => {
|
||
// A place picked from a plugin search or a plugin POI category keeps
|
||
// plugin:<pluginId>:<id>. With a Google key configured, letting it through billed
|
||
// an invalid photo lookup, an editorial summary and the photo route per place.
|
||
expect(isGooglePlaceId('plugin:trail-finder:th-1')).toBe(false);
|
||
expect(isGooglePlaceId('PLUGIN:trail-finder:th-1')).toBe(false);
|
||
expect(isGooglePlaceId('plugin:trail-finder:th-1~p2')).toBe(false);
|
||
expect(isGooglePlaceId('pluginChIJLU7jZClu5kcR')).toBe(true);
|
||
});
|
||
});
|
||
|
||
describe('clampPoiBbox', () => {
|
||
it('MAPS-POIBOX-001: narrows each oversized side to a centred window and says so', () => {
|
||
expect(MAX_POI_BBOX_SPAN_DEG).toBe(0.5);
|
||
expect(clampPoiBbox({ south: 48, west: 11, north: 48.25, east: 11.25 })).toEqual({
|
||
bbox: { south: 48, west: 11, north: 48.25, east: 11.25 },
|
||
clamped: false,
|
||
});
|
||
expect(clampPoiBbox({ south: 40, west: 11, north: 50, east: 11.25 })).toEqual({
|
||
bbox: { south: 44.75, west: 11, north: 45.25, east: 11.25 },
|
||
clamped: true,
|
||
});
|
||
expect(clampPoiBbox({ south: 48, west: 0, north: 48.25, east: 20 })).toEqual({
|
||
bbox: { south: 48, west: 9.75, north: 48.25, east: 10.25 },
|
||
clamped: true,
|
||
});
|
||
});
|
||
});
|
||
|
||
describe('googleFtidFromMapsUrl', () => {
|
||
it('MAPS-FTID-001: extracts a valid ftid from a /place/?ftid= URL (resolved share link)', () => {
|
||
expect(
|
||
googleFtidFromMapsUrl('https://www.google.com/maps/place/?q=X&ftid=0x882bf179e806d471:0x8591dde29c821a93'),
|
||
).toBe('0x882bf179e806d471:0x8591dde29c821a93');
|
||
});
|
||
it('MAPS-FTID-002: returns null for a cid-style URL (the usual Places API shape)', () => {
|
||
expect(googleFtidFromMapsUrl('https://maps.google.com/?cid=10403719659250533155')).toBeNull();
|
||
});
|
||
it('MAPS-FTID-003: rejects malformed / hostile ftid values', () => {
|
||
expect(googleFtidFromMapsUrl('https://maps.google.com/?ftid=not-an-ftid')).toBeNull();
|
||
expect(googleFtidFromMapsUrl('https://maps.google.com/?ftid=0xAB%26q%3Devil%3Cscript%3E')).toBeNull();
|
||
expect(googleFtidFromMapsUrl('not a url')).toBeNull();
|
||
expect(googleFtidFromMapsUrl(null)).toBeNull();
|
||
});
|
||
|
||
// #1954 — a followed maps.app.goo.gl link keeps the id in the `data=` blob, not
|
||
// in a query parameter, which is why a pasted single link used to lose it.
|
||
it('MAPS-FTID-004: extracts the ftid from the data= path blob of a resolved share link', () => {
|
||
expect(
|
||
googleFtidFromMapsUrl(
|
||
'https://www.google.com/maps/place/Notre-Dame/data=!4m6!3m5!1s0x47e671e23a09b3b1:0x40b82c3688b2f60!8m2!3d48.8529!4d2.3499',
|
||
),
|
||
).toBe('0x47e671e23a09b3b1:0x40b82c3688b2f60');
|
||
});
|
||
|
||
it('MAPS-FTID-005: the query parameter still wins over the path blob', () => {
|
||
expect(
|
||
googleFtidFromMapsUrl(
|
||
'https://www.google.com/maps/place/X/data=!3m5!1s0x1:0x2!8m2?ftid=0x882bf179e806d471:0x8591dde29c821a93',
|
||
),
|
||
).toBe('0x882bf179e806d471:0x8591dde29c821a93');
|
||
});
|
||
|
||
it('MAPS-FTID-006: ignores the path blob outside a /place/ URL', () => {
|
||
// A directions link carries one !1s per waypoint and the first is the origin,
|
||
// so reading it would attach the wrong place.
|
||
expect(
|
||
googleFtidFromMapsUrl('https://www.google.com/maps/dir/A/B/data=!4m2!1s0x47e671e23a09b3b1:0x40b82c3688b2f60'),
|
||
).toBeNull();
|
||
});
|
||
|
||
it('MAPS-FTID-007: a place URL without either shape is still null, and case is normalised', () => {
|
||
expect(googleFtidFromMapsUrl('https://www.google.com/maps/place/Notre-Dame/@48.85,2.34,17z')).toBeNull();
|
||
expect(
|
||
googleFtidFromMapsUrl('https://www.google.com/maps/place/X/data=!1s0x47E671E23A09B3B1:0x40B82C3688B2F60'),
|
||
).toBe('0x47e671e23a09b3b1:0x40b82c3688b2f60');
|
||
});
|
||
|
||
it('MAPS-FTID-008: a long hostile path blob resolves in linear time (ReDoS budget)', () => {
|
||
const hostile = `https://www.google.com/maps/place/X/data=${'!1s0x'.repeat(20000)}`;
|
||
const started = Date.now();
|
||
expect(googleFtidFromMapsUrl(hostile)).toBeNull();
|
||
expect(Date.now() - started).toBeLessThan(500);
|
||
});
|
||
});
|
||
|
||
// ── buildUserAgent (instance-specific UA, #1309) ──────────────────────────────
|
||
|
||
describe('buildUserAgent', () => {
|
||
const base = 'TREK Travel Planner (https://github.com/liketrek/TREK)';
|
||
|
||
it('MAPS-094: returns the bare base UA when no instance URL is configured', () => {
|
||
expect(buildUserAgent(undefined)).toBe(base);
|
||
expect(buildUserAgent('')).toBe(base);
|
||
});
|
||
|
||
it('MAPS-095: appends a configured https instance URL so the deployment is identifiable', () => {
|
||
expect(buildUserAgent('https://trek.example.org')).toBe(`${base}; https://trek.example.org`);
|
||
});
|
||
|
||
it('MAPS-096: drops the http://localhost fallback — it is not a unique identifier', () => {
|
||
expect(buildUserAgent('http://localhost:3001')).toBe(base);
|
||
});
|
||
});
|
||
|
||
// ── resolveOverpassEndpoints (OVERPASS_URL override, #1309) ────────────────────
|
||
|
||
describe('resolveOverpassEndpoints', () => {
|
||
it('MAPS-097: falls back to the public mirrors when OVERPASS_URL is unset/empty', () => {
|
||
expect(resolveOverpassEndpoints(undefined).length).toBeGreaterThan(1);
|
||
expect(resolveOverpassEndpoints('').length).toBeGreaterThan(1);
|
||
expect(resolveOverpassEndpoints(undefined)[0]).toContain('overpass-api.de');
|
||
});
|
||
|
||
it('MAPS-098: a single custom endpoint REPLACES the public mirrors (locked-down egress)', () => {
|
||
expect(resolveOverpassEndpoints('https://overpass.internal/api/interpreter')).toEqual([
|
||
'https://overpass.internal/api/interpreter',
|
||
]);
|
||
});
|
||
|
||
it('MAPS-099: parses a comma-separated list and trims whitespace', () => {
|
||
expect(resolveOverpassEndpoints(' https://a.test/api , http://b.test/api ')).toEqual([
|
||
'https://a.test/api',
|
||
'http://b.test/api',
|
||
]);
|
||
});
|
||
|
||
it('MAPS-100: drops non-http(s) / malformed entries, keeping the valid ones', () => {
|
||
expect(resolveOverpassEndpoints('https://ok.test/api, ftp://no.test, not a url')).toEqual(['https://ok.test/api']);
|
||
});
|
||
|
||
it('MAPS-101: falls back to the defaults when every custom entry is invalid', () => {
|
||
expect(resolveOverpassEndpoints('not a url, ftp://no.test').length).toBeGreaterThan(1);
|
||
});
|
||
});
|
||
|
||
// ── resolveOverpassTimeoutMs (OVERPASS_TIMEOUT_MS override, #1309) ─────────────
|
||
|
||
describe('resolveOverpassTimeoutMs', () => {
|
||
it('MAPS-104: falls back to the default for unset / empty / non-numeric values', () => {
|
||
expect(resolveOverpassTimeoutMs(undefined)).toBe(OVERPASS_TIMEOUT_DEFAULT_MS);
|
||
expect(resolveOverpassTimeoutMs('')).toBe(OVERPASS_TIMEOUT_DEFAULT_MS);
|
||
expect(resolveOverpassTimeoutMs('abc')).toBe(OVERPASS_TIMEOUT_DEFAULT_MS);
|
||
});
|
||
|
||
it('MAPS-105: honours a positive numeric override', () => {
|
||
expect(resolveOverpassTimeoutMs('30000')).toBe(30000);
|
||
});
|
||
|
||
it('MAPS-106: rejects 0, negative and Infinity — a non-positive cap would 502 every search', () => {
|
||
expect(resolveOverpassTimeoutMs('0')).toBe(OVERPASS_TIMEOUT_DEFAULT_MS);
|
||
expect(resolveOverpassTimeoutMs('-5')).toBe(OVERPASS_TIMEOUT_DEFAULT_MS);
|
||
expect(resolveOverpassTimeoutMs('Infinity')).toBe(OVERPASS_TIMEOUT_DEFAULT_MS);
|
||
});
|
||
|
||
it('MAPS-107: we wait longer than the query we sent is allowed to take', () => {
|
||
// The query carries `[timeout:N]`, which the mirror enforces itself. Hanging up
|
||
// before that expires aborts an answer that was still legitimately being computed —
|
||
// which is how a corridor search came to report half its stretches as unsearchable
|
||
// while every mirror was in fact working on them.
|
||
expect(OVERPASS_TIMEOUT_DEFAULT_MS).toBeGreaterThan(OVERPASS_QUERY_TIMEOUT_S * 1000);
|
||
});
|
||
});
|
||
|
||
// ── searchOverpassPois with several categories at once (#1797) ────────────────
|
||
|
||
describe('searchOverpassPois multi-category', () => {
|
||
const bbox = (n: number) => ({ south: 50 + n / 100, west: 8, north: 50.2 + n / 100, east: 8.2 });
|
||
|
||
it('MAPS-155: asks Overpass once for every category and labels each hit with its own', async () => {
|
||
const fetchMock = vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
json: async () => ({
|
||
elements: [
|
||
{ type: 'node', id: 1, lat: 50.1, lon: 8.1, tags: { name: 'Aral', amenity: 'fuel' } },
|
||
{ type: 'node', id: 2, lat: 50.11, lon: 8.11, tags: { name: 'Ionity', amenity: 'charging_station' } },
|
||
{ type: 'node', id: 3, lat: 50.12, lon: 8.12, tags: { name: 'Rasthof Taunus', highway: 'services' } },
|
||
],
|
||
}),
|
||
});
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
|
||
// One category first, to learn what a single box costs (the service races mirrors).
|
||
await osm.searchOverpassPois('fuel', bbox(9), 'en-US');
|
||
const single = fetchMock.mock.calls.length;
|
||
fetchMock.mockClear();
|
||
|
||
const { pois } = await osm.searchOverpassPois('fuel,charging,rest_area', bbox(1), 'en-US');
|
||
|
||
// Three categories cost exactly what one does — the whole point of the batching.
|
||
expect(fetchMock.mock.calls.length).toBe(single);
|
||
// The query goes out form-encoded, so read it back the way Overpass will.
|
||
const body = decodeURIComponent(String(fetchMock.mock.calls[0][1]?.body ?? ''));
|
||
expect(body).toContain('amenity"="fuel');
|
||
expect(body).toContain('amenity"="charging_station');
|
||
expect(body).toContain('highway"="services');
|
||
expect(pois.map(p => [p.name, p.category])).toEqual([
|
||
['Aral', 'fuel'],
|
||
['Ionity', 'charging'],
|
||
['Rasthof Taunus', 'rest_area'],
|
||
]);
|
||
});
|
||
|
||
it('MAPS-156: refuses the whole query when one of the categories is not a category', async () => {
|
||
const fetchMock = vi.fn();
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
|
||
await expect(osm.searchOverpassPois('fuel,unicorns', bbox(2))).rejects.toMatchObject({ status: 400 });
|
||
expect(fetchMock).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('MAPS-157: a repeat of the same set is served from the cache whatever the order', async () => {
|
||
const fetchMock = vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
json: async () => ({ elements: [{ type: 'node', id: 9, lat: 50.3, lon: 8.3, tags: { name: 'Shell', amenity: 'fuel' } }] }),
|
||
});
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
|
||
await osm.searchOverpassPois('fuel,campsite', bbox(3), 'en-US');
|
||
const asked = fetchMock.mock.calls.length;
|
||
await osm.searchOverpassPois('campsite,fuel', bbox(3), 'en-US');
|
||
|
||
// Same set, written the other way round: the second ask costs nothing.
|
||
expect(fetchMock.mock.calls.length).toBe(asked);
|
||
});
|
||
});
|
||
|
||
// ── searchOverpassPois error path (all endpoints down, #1309) ──────────────────
|
||
|
||
describe('searchOverpassPois all-endpoints-down', () => {
|
||
const bbox = { south: -41.2, west: 146.31, north: -41.16, east: 146.37 };
|
||
|
||
it('MAPS-102: surfaces a 502 with a clear message when every Overpass endpoint fails', async () => {
|
||
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('connect ECONNREFUSED')));
|
||
await expect(osm.searchOverpassPois('restaurant', bbox)).rejects.toMatchObject({
|
||
status: 502,
|
||
message: 'Could not reach any Overpass endpoint',
|
||
});
|
||
errSpy.mockRestore();
|
||
});
|
||
|
||
it('MAPS-103: logs each endpoint failure so an operator can diagnose blocked egress', async () => {
|
||
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('connect ECONNREFUSED')));
|
||
await expect(osm.searchOverpassPois('bar', bbox)).rejects.toThrow();
|
||
expect(errSpy).toHaveBeenCalledWith(expect.stringContaining('[Overpass] all'));
|
||
expect(errSpy).toHaveBeenCalledWith(expect.stringContaining('ECONNREFUSED'));
|
||
errSpy.mockRestore();
|
||
});
|
||
});
|
||
|
||
// ── Wrapper surface (kept from the pre-fold wrapper suite) ────────────────────
|
||
|
||
/** An AppSettingsRepository stub whose getValue() returns the row's value the test wants. */
|
||
function makeSettingsRepo(row?: { value: string }) {
|
||
const getValue = vi.fn(async (_key: string) => row?.value ?? null);
|
||
return { repo: { getValue } as unknown as AppSettingsRepository, getValue };
|
||
}
|
||
|
||
function settingsSvc(row?: { value: string }) {
|
||
return buildMapsService(photoCacheStub, makeSettingsRepo(row).repo, usersStub, placeDetailsCacheStub, placesStub, noGoogleQuota);
|
||
}
|
||
|
||
describe('kill-switch settings reads', () => {
|
||
it('reports a switch disabled when the stored value is exactly "false"', async () => {
|
||
expect(await settingsSvc({ value: 'false' }).autocompleteDisabled()).toBe(true);
|
||
expect(await settingsSvc({ value: 'false' }).detailsDisabled()).toBe(true);
|
||
expect(await settingsSvc({ value: 'false' }).photosDisabled()).toBe(true);
|
||
});
|
||
|
||
it('reports enabled when the value is "true"', async () => {
|
||
expect(await settingsSvc({ value: 'true' }).autocompleteDisabled()).toBe(false);
|
||
expect(await settingsSvc({ value: 'true' }).detailsDisabled()).toBe(false);
|
||
expect(await settingsSvc({ value: 'true' }).photosDisabled()).toBe(false);
|
||
});
|
||
|
||
it('reports enabled when the setting row is absent', async () => {
|
||
expect(await settingsSvc(undefined).autocompleteDisabled()).toBe(false);
|
||
expect(await settingsSvc(undefined).detailsDisabled()).toBe(false);
|
||
expect(await settingsSvc(undefined).photosDisabled()).toBe(false);
|
||
});
|
||
|
||
it('queries the matching app_settings key', async () => {
|
||
const { repo: settingsRepo, getValue } = makeSettingsRepo({ value: 'true' });
|
||
const s = buildMapsService(photoCacheStub, settingsRepo, usersStub, placeDetailsCacheStub, placesStub, noGoogleQuota);
|
||
await s.autocompleteDisabled();
|
||
expect(getValue).toHaveBeenCalledWith('places_autocomplete_enabled');
|
||
await s.detailsDisabled();
|
||
expect(getValue).toHaveBeenCalledWith('places_details_enabled');
|
||
await s.photosDisabled();
|
||
expect(getValue).toHaveBeenCalledWith('places_photos_enabled');
|
||
});
|
||
});
|
||
|
||
describe('photoBytesKey', () => {
|
||
it('returns the cached storage name from placePhotoCache', async () => {
|
||
mockServeFilePath.mockReturnValue('abc.jpg');
|
||
expect(await svc.photoBytesKey('p1')).toBe('abc.jpg');
|
||
expect(mockServeFilePath).toHaveBeenCalledWith('p1');
|
||
});
|
||
|
||
it('returns null when nothing is cached', async () => {
|
||
mockServeFilePath.mockReturnValue(null);
|
||
expect(await svc.photoBytesKey('p1')).toBeNull();
|
||
});
|
||
});
|
||
|
||
describe('controller-facing wrappers delegate to the folded methods', () => {
|
||
it('search/autocomplete/details/detailsExpanded/photo/reverse/resolveUrl/pois forward their args', async () => {
|
||
const spies = {
|
||
searchPlaces: vi.spyOn(MapsService.prototype, 'searchPlaces').mockResolvedValue({ places: [], source: 'osm' }),
|
||
autocompletePlaces: vi.spyOn(MapsService.prototype, 'autocompletePlaces').mockResolvedValue({ suggestions: [], source: 'osm' }),
|
||
getPlaceDetails: vi.spyOn(MapsService.prototype, 'getPlaceDetails').mockResolvedValue({ place: {} }),
|
||
getPlaceDetailsExpanded: vi.spyOn(MapsService.prototype, 'getPlaceDetailsExpanded').mockResolvedValue({ place: {} }),
|
||
getPlacePhoto: vi.spyOn(MapsService.prototype, 'getPlacePhoto').mockResolvedValue({ photoUrl: null, attribution: null }),
|
||
reverseGeocode: vi.spyOn(MapsService.prototype, 'reverseGeocode').mockResolvedValue({ name: null, address: null }),
|
||
resolveGoogleMapsUrl: vi.spyOn(MapsService.prototype, 'resolveGoogleMapsUrl').mockResolvedValue({ lat: 1, lng: 2, name: null, address: null, google_ftid: null }),
|
||
searchOverpassPois: vi.spyOn(OsmClient.prototype, 'searchOverpassPois').mockResolvedValue({ pois: [], source: 'openstreetmap', truncated: false, clamped: false }),
|
||
};
|
||
try {
|
||
const circleBias = { lat: 1, lng: 2, radius: 5 };
|
||
await svc.search(3, 'berlin', 'de', circleBias);
|
||
expect(spies.searchPlaces).toHaveBeenCalledWith(3, 'berlin', 'de', circleBias, { googleOnly: false });
|
||
await svc.search(3, 'berlin', 'de', circleBias, 'google');
|
||
expect(spies.searchPlaces).toHaveBeenLastCalledWith(3, 'berlin', 'de', circleBias, { googleOnly: true });
|
||
|
||
const rectBias = { low: { lat: 1, lng: 2 }, high: { lat: 3, lng: 4 } };
|
||
await svc.autocomplete(3, 'be', 'en', rectBias);
|
||
expect(spies.autocompletePlaces).toHaveBeenCalledWith(3, 'be', 'en', rectBias, undefined);
|
||
|
||
await svc.details(3, 'p1', 'de');
|
||
expect(spies.getPlaceDetails).toHaveBeenCalledWith(3, 'p1', 'de', undefined);
|
||
|
||
await svc.detailsExpanded(3, 'p1', 'de', true);
|
||
expect(spies.getPlaceDetailsExpanded).toHaveBeenCalledWith(3, 'p1', 'de', true);
|
||
|
||
await svc.photo(3, 'p1', 1.5, 2.5, 'Spot');
|
||
expect(spies.getPlacePhoto).toHaveBeenCalledWith(3, 'p1', 1.5, 2.5, 'Spot');
|
||
|
||
await svc.reverse('1', '2', 'de');
|
||
expect(spies.reverseGeocode).toHaveBeenCalledWith('1', '2', 'de');
|
||
|
||
await svc.resolveUrl('https://maps.app.goo.gl/x');
|
||
expect(spies.resolveGoogleMapsUrl).toHaveBeenCalledWith('https://maps.app.goo.gl/x');
|
||
|
||
const bbox = { south: 1, west: 2, north: 3, east: 4 };
|
||
// The index answers with an empty page (see the mock above), so what this
|
||
// measures is the wrapper's own delegation.
|
||
await svc.pois('cafe', bbox, 'de');
|
||
// The caller's per-category budget rides along, so a corridor search that
|
||
// falls through to Overpass gets the allowance it asked the index for.
|
||
expect(spies.searchOverpassPois).toHaveBeenCalledWith('cafe', bbox, 'de', 60);
|
||
} finally {
|
||
Object.values(spies).forEach((s) => s.mockRestore());
|
||
}
|
||
});
|
||
});
|
||
|
||
// The maps.bridge delegation cases (MAPS-104..106) died with the bridge itself:
|
||
// its last two consumers — the legacy placeEnrichment helper and the places MCP
|
||
// registrar — both folded into the DI-native places domain, which injects
|
||
// MapsService directly.
|
||
|
||
// ── Enrichment primitives (MAPS-111..) ───────────────────────────────────────
|
||
|
||
describe('stripWikiMarkup', () => {
|
||
it('MAPS-111: reduces a Commons author fragment to plain text', () => {
|
||
expect(stripWikiMarkup('<a href="/wiki/User:Alice" title="User:Alice">Alice</a>')).toBe('Alice');
|
||
expect(stripWikiMarkup('<span class="fn">Bob</span> / <i>Studio</i>')).toBe('Bob / Studio');
|
||
});
|
||
|
||
it('MAPS-112: turns empty, absent and markup-only values into null', () => {
|
||
expect(stripWikiMarkup(undefined)).toBeNull();
|
||
expect(stripWikiMarkup(null)).toBeNull();
|
||
expect(stripWikiMarkup('')).toBeNull();
|
||
expect(stripWikiMarkup(' ')).toBeNull();
|
||
expect(stripWikiMarkup('<span></span>')).toBeNull();
|
||
});
|
||
});
|
||
|
||
describe('parseWikipediaTag', () => {
|
||
it('MAPS-113: splits the "lang:Title" spelling', () => {
|
||
expect(parseWikipediaTag('de:Museum Ludwig')).toEqual({ lang: 'de', title: 'Museum Ludwig' });
|
||
expect(parseWikipediaTag(' en:Eiffel Tower ')).toEqual({ lang: 'en', title: 'Eiffel Tower' });
|
||
// Article titles may themselves contain a colon ("de:Portal:Köln").
|
||
expect(parseWikipediaTag('de:Portal:Köln')).toEqual({ lang: 'de', title: 'Portal:Köln' });
|
||
expect(parseWikipediaTag('zh-yue:香港')).toEqual({ lang: 'zh-yue', title: '香港' });
|
||
});
|
||
|
||
it('MAPS-114: refuses spellings that would send us to the wrong wiki', () => {
|
||
// A bare title has no language, and guessing one picks an article at random.
|
||
expect(parseWikipediaTag('Museum Ludwig')).toBeNull();
|
||
expect(parseWikipediaTag('de:')).toBeNull();
|
||
expect(parseWikipediaTag('')).toBeNull();
|
||
expect(parseWikipediaTag(undefined)).toBeNull();
|
||
});
|
||
});
|
||
|
||
describe('buildOsmDetails wiki tags', () => {
|
||
it('MAPS-115: carries wikipedia/wikidata through instead of dropping them', () => {
|
||
const withTags = buildOsmDetails({ wikipedia: 'de:Museum Ludwig', wikidata: 'Q160236' }, 'way', '42');
|
||
expect(withTags.wikipedia).toBe('de:Museum Ludwig');
|
||
expect(withTags.wikidata).toBe('Q160236');
|
||
|
||
const without = buildOsmDetails({ name: 'Kiosk' }, 'node', '7');
|
||
expect(without.wikipedia).toBeNull();
|
||
expect(without.wikidata).toBeNull();
|
||
});
|
||
});
|
||
|
||
describe('isGooglePlaceId with enrichment candidate keys', () => {
|
||
it('MAPS-116: rejects "<placeId>~pN" keys so a candidate never reaches Google', () => {
|
||
// Google bills the 400 it answers these with — see NON_GOOGLE_PLACE_ID.
|
||
expect(isGooglePlaceId('ChIJN1t_tDeuEmsRUsoyG83frY4~p0')).toBe(false);
|
||
expect(isGooglePlaceId('ChIJN1t_tDeuEmsRUsoyG83frY4~p12')).toBe(false);
|
||
// The bare id still resolves, and a tilde that is not a candidate suffix does not disarm it.
|
||
expect(isGooglePlaceId('ChIJN1t_tDeuEmsRUsoyG83frY4')).toBe(true);
|
||
expect(isGooglePlaceId('ChIJabc~photo')).toBe(true);
|
||
});
|
||
});
|
||
|
||
describe('fetchCommonsCandidates (fetch stubbed)', () => {
|
||
const page = (over: Record<string, unknown> = {}) => ({
|
||
imageinfo: [
|
||
{
|
||
url: 'https://commons.org/original.jpg',
|
||
thumburl: 'https://commons.org/thumb.jpg',
|
||
mime: 'image/jpeg',
|
||
descriptionurl: 'https://commons.wikimedia.org/wiki/File:X.jpg',
|
||
extmetadata: {
|
||
Artist: { value: '<a href="/wiki/User:Alice">Alice</a>' },
|
||
LicenseShortName: { value: 'CC BY-SA 4.0' },
|
||
LicenseUrl: { value: 'https://creativecommons.org/licenses/by-sa/4.0/' },
|
||
},
|
||
...over,
|
||
},
|
||
],
|
||
});
|
||
|
||
it('MAPS-117: returns every usable image with its licence metadata', async () => {
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
json: async () => ({ query: { pages: { '1': page(), '2': page({ thumburl: 'https://commons.org/t2.jpg' }) } } }),
|
||
}),
|
||
);
|
||
const out = await wiki.fetchCommonsCandidates(48.8, 2.3, 5);
|
||
expect(out).toHaveLength(2);
|
||
expect(out[0]).toMatchObject({
|
||
photoUrl: 'https://commons.org/thumb.jpg',
|
||
attribution: 'Alice',
|
||
license: 'CC BY-SA 4.0',
|
||
licenseUrl: 'https://creativecommons.org/licenses/by-sa/4.0/',
|
||
sourceUrl: 'https://commons.wikimedia.org/wiki/File:X.jpg',
|
||
});
|
||
// The page id travels with the candidate: it is what identifies the file
|
||
// across the four routes that can reach it, and what keys its cached bytes.
|
||
expect(out[0].pageId).toBe(1);
|
||
});
|
||
|
||
it('MAPS-118: falls back to UsageTerms when there is no short licence name', async () => {
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
json: async () => ({
|
||
query: {
|
||
pages: {
|
||
'1': page({
|
||
extmetadata: { UsageTerms: { value: '<b>Public domain</b>' } },
|
||
}),
|
||
},
|
||
},
|
||
}),
|
||
}),
|
||
);
|
||
const out = await wiki.fetchCommonsCandidates(48.8, 2.3);
|
||
expect(out[0].license).toBe('Public domain');
|
||
expect(out[0].attribution).toBeNull();
|
||
expect(out[0].licenseUrl).toBeNull();
|
||
});
|
||
|
||
it('MAPS-119: skips SVGs, PDFs and entries without a URL', async () => {
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
json: async () => ({
|
||
query: {
|
||
pages: {
|
||
'1': page({ mime: 'image/svg+xml' }),
|
||
'2': page({ mime: 'application/pdf' }),
|
||
'3': { imageinfo: [{ mime: 'image/jpeg' }] },
|
||
'4': {},
|
||
'5': page({ mime: 'image/png', thumburl: undefined }),
|
||
},
|
||
},
|
||
}),
|
||
}),
|
||
);
|
||
const out = await wiki.fetchCommonsCandidates(48.8, 2.3);
|
||
expect(out).toHaveLength(1);
|
||
expect(out[0].photoUrl).toBe('https://commons.org/original.jpg');
|
||
});
|
||
|
||
it('MAPS-120: asks for more than the strip needs and clamps to what Commons accepts', async () => {
|
||
const fetchMock = vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
json: async () => ({ query: { pages: { '1': page(), '2': page(), '3': page() } } }),
|
||
});
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
|
||
// Overfetch on purpose: the first hits around anything worth visiting are
|
||
// survey tiles and the building next door, and the ranker can only reject
|
||
// from a pool. Geosearch bills the same for one result as for twenty, so
|
||
// the whole pool comes back and gets cut after ranking, not before.
|
||
expect(await wiki.fetchCommonsCandidates(48.8, 2.3, 2)).toHaveLength(3);
|
||
expect(String(fetchMock.mock.calls[0][0])).toContain('ggslimit=8');
|
||
|
||
await wiki.fetchCommonsCandidates(48.8, 2.3, 0);
|
||
expect(String(fetchMock.mock.calls[1][0])).toContain('ggslimit=8');
|
||
|
||
await wiki.fetchCommonsCandidates(48.8, 2.3, 99);
|
||
expect(String(fetchMock.mock.calls[2][0])).toContain('ggslimit=20');
|
||
});
|
||
|
||
it('MAPS-121: returns an empty list on a bad response, missing pages or a throw', async () => {
|
||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, json: async () => ({}) }));
|
||
expect(await wiki.fetchCommonsCandidates(1, 2)).toEqual([]);
|
||
|
||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => ({ query: {} }) }));
|
||
expect(await wiki.fetchCommonsCandidates(1, 2)).toEqual([]);
|
||
|
||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network')));
|
||
expect(await wiki.fetchCommonsCandidates(1, 2)).toEqual([]);
|
||
});
|
||
});
|
||
|
||
describe('fetchWikiExtract (fetch stubbed)', () => {
|
||
const page = (title: string, extract: string) => ({
|
||
ok: true,
|
||
json: async () => ({ query: { pages: { '1': { title, extract } } } }),
|
||
});
|
||
const noArticle = { ok: true, json: async () => ({ query: { pages: { '-1': { title: 'X' } } } }) };
|
||
|
||
it('MAPS-122: asks Wikivoyage first, because it writes for travellers', async () => {
|
||
const fetchMock = vi.fn().mockResolvedValue(page('Museum Ludwig', ' Ein Museum in Köln. '));
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
|
||
const out = await wiki.fetchWikiExtract('de:Museum Ludwig');
|
||
expect(out).toEqual({
|
||
text: 'Ein Museum in Köln.',
|
||
sourceUrl: 'https://de.wikivoyage.org/wiki/Museum%20Ludwig',
|
||
source: 'wikivoyage',
|
||
});
|
||
// Host follows the tag, and Wikipedia is never asked once Wikivoyage answered.
|
||
expect(String(fetchMock.mock.calls[0][0])).toContain('https://de.wikivoyage.org/w/api.php');
|
||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
it('MAPS-122b: falls through to Wikipedia when Wikivoyage has no article', async () => {
|
||
const fetchMock = vi
|
||
.fn()
|
||
.mockResolvedValueOnce(noArticle)
|
||
.mockResolvedValueOnce(page('Museum Ludwig', 'Das Museum Ludwig ist ein Museum.'));
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
|
||
const out = await wiki.fetchWikiExtract('de:Museum Ludwig');
|
||
expect(out).toMatchObject({ source: 'wikipedia' });
|
||
expect(String(fetchMock.mock.calls[1][0])).toContain('https://de.wikipedia.org/w/api.php');
|
||
});
|
||
|
||
it('MAPS-122c: asks for two sentences, not a whole lead section', async () => {
|
||
const fetchMock = vi.fn().mockResolvedValue(page('X', 'Kurz.'));
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
|
||
await wiki.fetchWikiExtract('de:X');
|
||
expect(String(fetchMock.mock.calls[0][0])).toContain('exsentences=2');
|
||
});
|
||
|
||
it('MAPS-123: prefers the resolved title so a redirect links to where it landed', async () => {
|
||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(page('Eiffel Tower', 'A tower.')));
|
||
const out = await wiki.fetchWikiExtract('en:Eiffelturm');
|
||
expect(out!.sourceUrl).toBe('https://en.wikivoyage.org/wiki/Eiffel%20Tower');
|
||
});
|
||
|
||
it('MAPS-124: returns null without calling out when the tag has no language', async () => {
|
||
const fetchMock = vi.fn();
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
expect(await wiki.fetchWikiExtract('Museum Ludwig')).toBeNull();
|
||
expect(await wiki.fetchWikiExtract(null)).toBeNull();
|
||
expect(fetchMock).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('MAPS-125: treats a miss on both wikis as no description', async () => {
|
||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(noArticle));
|
||
expect(await wiki.fetchWikiExtract('de:X')).toBeNull();
|
||
|
||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) }));
|
||
expect(await wiki.fetchWikiExtract('de:X')).toBeNull();
|
||
|
||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, json: async () => ({}) }));
|
||
expect(await wiki.fetchWikiExtract('de:X')).toBeNull();
|
||
|
||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network')));
|
||
expect(await wiki.fetchWikiExtract('de:X')).toBeNull();
|
||
});
|
||
|
||
it('MAPS-125b: still tries Wikipedia after Wikivoyage threw', async () => {
|
||
const fetchMock = vi
|
||
.fn()
|
||
.mockRejectedValueOnce(new Error('network'))
|
||
.mockResolvedValueOnce(page('X', 'Ein Ort.'));
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
|
||
expect(await wiki.fetchWikiExtract('de:X')).toMatchObject({ source: 'wikipedia' });
|
||
});
|
||
});
|
||
|
||
describe('fetchCommonsCategoryCandidates (fetch stubbed)', () => {
|
||
it('MAPS-125c: reads a category, which is pictures OF a place rather than near it', async () => {
|
||
const fetchMock = vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
json: async () => ({
|
||
query: {
|
||
pages: {
|
||
'1': {
|
||
imageinfo: [
|
||
{
|
||
url: 'https://commons.org/o.jpg',
|
||
thumburl: 'https://commons.org/t.jpg',
|
||
mime: 'image/jpeg',
|
||
descriptionurl: 'https://commons.wikimedia.org/wiki/File:T.jpg',
|
||
extmetadata: { Artist: { value: 'Alice' }, LicenseShortName: { value: 'CC BY 4.0' } },
|
||
},
|
||
],
|
||
},
|
||
},
|
||
},
|
||
}),
|
||
});
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
|
||
const out = await wiki.fetchCommonsCategoryCandidates('Category:Museum Ludwig', 3);
|
||
expect(out).toHaveLength(1);
|
||
expect(out[0]).toMatchObject({ photoUrl: 'https://commons.org/t.jpg', attribution: 'Alice', license: 'CC BY 4.0' });
|
||
// Ranked search, not the category listing: `categorymembers` orders by sort
|
||
// key, i.e. alphabetically by file name, and that put an .ogg pronunciation
|
||
// and six near-identical press shots at the head of the Brandenburg Gate
|
||
// category. The search index at least ranks by how well a file matches.
|
||
expect(String(fetchMock.mock.calls[0][0])).toContain('gsrsearch=incategory%3A%22Museum+Ludwig%22');
|
||
});
|
||
|
||
it('MAPS-125d: takes the bare category name whether or not the tag is prefixed', async () => {
|
||
const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ query: { pages: {} } }) });
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
|
||
await wiki.fetchCommonsCategoryCandidates('Museum Ludwig');
|
||
expect(String(fetchMock.mock.calls[0][0])).toContain('incategory%3A%22Museum+Ludwig%22');
|
||
// Empty search falls through to the category listing as a second chance.
|
||
expect(String(fetchMock.mock.calls[1][0])).toContain('gcmtitle=Category%3AMuseum+Ludwig');
|
||
});
|
||
|
||
it('MAPS-125f: refuses a tag that names a file rather than a category', async () => {
|
||
// Mappers do put `File:…` in wikimedia_commons. Prefixing it blindly asked
|
||
// Commons for `Category:File:X.jpg`, which matches nothing and fell through
|
||
// to the coordinate search without a word.
|
||
const fetchMock = vi.fn();
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
|
||
expect(await wiki.fetchCommonsCategoryCandidates('File:Museum Ludwig.jpg')).toEqual([]);
|
||
expect(fetchMock).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('MAPS-125g: strips a localised category prefix', async () => {
|
||
const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ query: { pages: {} } }) });
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
|
||
await wiki.fetchCommonsCategoryCandidates('Kategorie:Museum Ludwig');
|
||
expect(String(fetchMock.mock.calls[0][0])).toContain('incategory%3A%22Museum+Ludwig%22');
|
||
});
|
||
|
||
it('MAPS-125e: yields nothing on an error response or a throw', async () => {
|
||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, json: async () => ({}) }));
|
||
expect(await wiki.fetchCommonsCategoryCandidates('Category:X')).toEqual([]);
|
||
|
||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network')));
|
||
expect(await wiki.fetchCommonsCategoryCandidates('Category:X')).toEqual([]);
|
||
});
|
||
});
|
||
|
||
describe('fetchGooglePhotoRefs (fetch stubbed)', () => {
|
||
it('MAPS-126: returns capped references with their author attribution', async () => {
|
||
const fetchMock = vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
json: async () => ({
|
||
photos: [
|
||
{ name: 'places/p/photos/a', authorAttributions: [{ displayName: 'Alice' }] },
|
||
{ name: 'places/p/photos/b', authorAttributions: [] },
|
||
{ name: 'places/p/photos/c' },
|
||
],
|
||
}),
|
||
});
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
|
||
const out = await google.fetchGooglePhotoRefs('ChIJabc', 'key', 2);
|
||
expect(out).toEqual([
|
||
{ name: 'places/p/photos/a', attribution: 'Alice' },
|
||
{ name: 'places/p/photos/b', attribution: null },
|
||
]);
|
||
// One billed Details call for the whole strip, and only the photos field.
|
||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||
expect(fetchMock.mock.calls[0][1].headers['X-Goog-FieldMask']).toBe('photos');
|
||
});
|
||
|
||
it('MAPS-127: never calls Google for an id Google cannot resolve', async () => {
|
||
const fetchMock = vi.fn();
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
expect(await google.fetchGooglePhotoRefs('node:123', 'key', 3)).toEqual([]);
|
||
expect(await google.fetchGooglePhotoRefs('ChIJabc~p1', 'key', 3)).toEqual([]);
|
||
expect(await google.fetchGooglePhotoRefs('ChIJabc', 'key', 0)).toEqual([]);
|
||
expect(fetchMock).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('MAPS-128: yields nothing on an error response, a photo-less place or a throw', async () => {
|
||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, json: async () => ({}) }));
|
||
expect(await google.fetchGooglePhotoRefs('ChIJabc', 'key', 3)).toEqual([]);
|
||
|
||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) }));
|
||
expect(await google.fetchGooglePhotoRefs('ChIJabc', 'key', 3)).toEqual([]);
|
||
|
||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network')));
|
||
expect(await google.fetchGooglePhotoRefs('ChIJabc', 'key', 3)).toEqual([]);
|
||
});
|
||
});
|
||
|
||
describe('fetchGooglePhotoBytes (fetch stubbed)', () => {
|
||
it('MAPS-129: downloads the media for one reference at the requested height', async () => {
|
||
const fetchMock = vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer,
|
||
});
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
|
||
const bytes = await google.fetchGooglePhotoBytes('places/p/photos/a', 'key', 600);
|
||
expect(bytes).toBeInstanceOf(Buffer);
|
||
expect(bytes!.length).toBe(3);
|
||
expect(String(fetchMock.mock.calls[0][0])).toBe(
|
||
'https://places.googleapis.com/v1/places/p/photos/a/media?maxHeightPx=600',
|
||
);
|
||
});
|
||
|
||
it('MAPS-130: returns null for an error response, an empty body or a throw', async () => {
|
||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, arrayBuffer: async () => new ArrayBuffer(0) }));
|
||
expect(await google.fetchGooglePhotoBytes('places/p/photos/a', 'key')).toBeNull();
|
||
|
||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, arrayBuffer: async () => new ArrayBuffer(0) }));
|
||
expect(await google.fetchGooglePhotoBytes('places/p/photos/a', 'key')).toBeNull();
|
||
|
||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network')));
|
||
expect(await google.fetchGooglePhotoBytes('places/p/photos/a', 'key')).toBeNull();
|
||
});
|
||
});
|
||
|
||
describe('fetchEditorialSummary (fetch stubbed)', () => {
|
||
it('MAPS-131: asks only for the summary, not for reviews', async () => {
|
||
const fetchMock = vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
json: async () => ({ editorialSummary: { text: ' A museum in Cologne. ' } }),
|
||
});
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
|
||
expect(await google.fetchEditorialSummary('ChIJabc', 'key', 'de')).toBe('A museum in Cologne.');
|
||
// reviews would move this into the Enterprise SKU — see the method comment.
|
||
expect(fetchMock.mock.calls[0][1].headers['X-Goog-FieldMask']).toBe('editorialSummary');
|
||
expect(String(fetchMock.mock.calls[0][0])).toContain('languageCode=de');
|
||
});
|
||
|
||
it('MAPS-132: skips non-Google ids and swallows every miss', async () => {
|
||
const fetchMock = vi.fn();
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
expect(await google.fetchEditorialSummary('node:1', 'key')).toBeNull();
|
||
expect(fetchMock).not.toHaveBeenCalled();
|
||
|
||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, json: async () => ({}) }));
|
||
expect(await google.fetchEditorialSummary('ChIJabc', 'key')).toBeNull();
|
||
|
||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) }));
|
||
expect(await google.fetchEditorialSummary('ChIJabc', 'key')).toBeNull();
|
||
|
||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network')));
|
||
expect(await google.fetchEditorialSummary('ChIJabc', 'key')).toBeNull();
|
||
});
|
||
});
|
||
|
||
describe('withPhotoFetchSlot', () => {
|
||
it('MAPS-133: caps concurrent fetches at five and lets queued work through', async () => {
|
||
// Settling a release hands the slot to a queued run, which then parks on its
|
||
// own barrier — several microtasks later. Draining needs a real turn of the
|
||
// loop between waves, not a bare `await Promise.resolve()`.
|
||
const tick = () => new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||
|
||
let active = 0;
|
||
let peak = 0;
|
||
let started = 0;
|
||
const release: Array<() => void> = [];
|
||
|
||
const runs = Array.from({ length: 8 }, () =>
|
||
withPhotoFetchSlot(async () => {
|
||
started++;
|
||
active++;
|
||
peak = Math.max(peak, active);
|
||
await new Promise<void>((resolve) => release.push(resolve));
|
||
active--;
|
||
return 'ok';
|
||
}),
|
||
);
|
||
|
||
await tick();
|
||
expect(started).toBe(5);
|
||
expect(peak).toBe(5);
|
||
|
||
while (release.length) {
|
||
release.shift()!();
|
||
await tick();
|
||
}
|
||
|
||
expect(await Promise.all(runs)).toEqual(Array(8).fill('ok'));
|
||
expect(started).toBe(8);
|
||
// The cap held across the whole run, not just the first wave.
|
||
expect(peak).toBe(5);
|
||
});
|
||
|
||
it('MAPS-134: releases the slot when the work throws', async () => {
|
||
await expect(
|
||
withPhotoFetchSlot(async () => {
|
||
throw new Error('boom');
|
||
}),
|
||
).rejects.toThrow('boom');
|
||
// The slot came back, so the next caller runs immediately.
|
||
await expect(withPhotoFetchSlot(async () => 'free')).resolves.toBe('free');
|
||
});
|
||
});
|
||
|
||
/**
|
||
* MAPS-135..146 — the Commons ranker and the identity tags.
|
||
*
|
||
* Every fixture here is a real payload recorded while working out why the
|
||
* picker showed runways and train front-ends for Hamburg Airport, Berlin
|
||
* Hauptbahnhof and the Brandenburg Gate.
|
||
*/
|
||
describe('rankCommonsCandidates', () => {
|
||
const pic = (over: Partial<RankableCommonsCandidate> & { pageId: number }): RankableCommonsCandidate => ({
|
||
title: `File:pic ${over.pageId}.jpg`,
|
||
attribution: 'Someone',
|
||
width: 1600,
|
||
height: 1200,
|
||
descriptors: null,
|
||
photoUrl: `https://commons/thumb/${over.pageId}.jpg`,
|
||
...over,
|
||
});
|
||
|
||
it('MAPS-135: drops a file that reached us twice under different thumbnail urls', () => {
|
||
const first = pic({ pageId: 47529341, photoUrl: 'https://commons/x.jpg?utm_source=commons.wikimedia.org' });
|
||
const again = { ...first, photoUrl: 'https://commons/x.jpg?utm_source=de.wikipedia.org' };
|
||
expect(rankCommonsCandidates([first, again], 5)).toHaveLength(1);
|
||
});
|
||
|
||
it('MAPS-136: rejects the aerial survey tiles that fill an airport geosearch', () => {
|
||
const tiles = [2013, 2015, 2016, 2019].map((year, i) =>
|
||
pic({
|
||
pageId: 100 + i,
|
||
title: `File:Dop20rgb 32565 5943 ${year}.jpg`,
|
||
attribution: 'Landesbetrieb Geoinformation Hamburg',
|
||
width: 5000,
|
||
height: 5000,
|
||
descriptors: `Orthophoto Sommerbefliegung ${year}`,
|
||
}),
|
||
);
|
||
expect(rankCommonsCandidates(tiles, 5)).toEqual([]);
|
||
});
|
||
|
||
it('MAPS-137: rejects noise maps and terminal layouts from a category', () => {
|
||
const docs = [
|
||
pic({ pageId: 200, title: 'File:Lärmkarte Flughafen Hamburg.png' }),
|
||
pic({ pageId: 201, title: 'File:EDDH HAM Layout.png' }),
|
||
pic({ pageId: 202, title: 'File:Grundriss Terminal 1.jpg' }),
|
||
];
|
||
expect(rankCommonsCandidates(docs, 5)).toEqual([]);
|
||
});
|
||
|
||
it('MAPS-138: keeps one frame of a camera burst, not four', () => {
|
||
// The four shots that filled the Brandenburg Gate picker were one person
|
||
// walking through a station concourse.
|
||
const burst = [807, 808, 809, 810].map((n) =>
|
||
pic({ pageId: 300 + n, title: `File:Hauptbahnhof Berlin interior 0${n}.jpg`, attribution: 'Dosseman' }),
|
||
);
|
||
const kept = rankCommonsCandidates(burst, 5);
|
||
expect(kept).toHaveLength(1);
|
||
expect(kept[0].title).toBe('File:Hauptbahnhof Berlin interior 0807.jpg');
|
||
});
|
||
|
||
it('MAPS-139: collapses a press set numbered with trailing counters', () => {
|
||
const set = [16, 17, 18, 19, 20, 21].map((n) =>
|
||
pic({ pageId: 400 + n, title: `File:Alexander Schallenberg in Berlin on 7 June 2024 - ${n}.jpg` }),
|
||
);
|
||
expect(rankCommonsCandidates(set, 5)).toHaveLength(1);
|
||
});
|
||
|
||
it('MAPS-140: caps how much of the strip one author may supply', () => {
|
||
// Unrelated titles, same photographer — the series rule cannot see these,
|
||
// only the author cap can. On the coordinate rung nothing vouches that the
|
||
// pictures are even of the right subject, so variety is the only defence
|
||
// and one contributor gets one slot; a curated rung gets two.
|
||
const same = ['Rear entrance', 'Platform at dusk', 'Ticket hall'].map((subject, i) =>
|
||
pic({ pageId: 500 + i, title: `File:${subject}.jpg`, attribution: 'Bahnthaler' }),
|
||
);
|
||
expect(rankCommonsCandidates(same, 5, { perAuthor: 1 })).toHaveLength(1);
|
||
expect(rankCommonsCandidates(same, 5, { perAuthor: 2 })).toHaveLength(2);
|
||
});
|
||
|
||
it('MAPS-140b: the series rule catches a numbered pair before the author cap does', () => {
|
||
// "Berlin-Hamburg-Express 1/2.JPG", both by Bahnthaler — two files, one
|
||
// subject. This is the pair that filled the Berlin Hauptbahnhof picker.
|
||
const pair = [1, 2].map((n) =>
|
||
pic({ pageId: 510 + n, title: `File:Berlin-Hamburg-Express ${n}.JPG`, attribution: 'Bahnthaler' }),
|
||
);
|
||
expect(rankCommonsCandidates(pair, 5, { perAuthor: 2 })).toHaveLength(1);
|
||
});
|
||
|
||
it('MAPS-141: rejects panorama strips that would crop to nothing in a square tile', () => {
|
||
expect(rankCommonsCandidates([pic({ pageId: 600, width: 9000, height: 1200 })], 5)).toEqual([]);
|
||
});
|
||
|
||
it('MAPS-142: keeps a large square photo that is not a survey tile', () => {
|
||
const square = pic({ pageId: 601, width: 1200, height: 1200 });
|
||
expect(rankCommonsCandidates([square], 5)).toHaveLength(1);
|
||
});
|
||
|
||
it('MAPS-143: falls back to the url when a candidate carries no page id', () => {
|
||
const noId = { ...pic({ pageId: 0 }), pageId: null };
|
||
expect(rankCommonsCandidates([noId, { ...noId }], 5)).toHaveLength(1);
|
||
});
|
||
|
||
it('MAPS-144: stops at the limit', () => {
|
||
const many = Array.from({ length: 9 }, (_, i) =>
|
||
pic({ pageId: 700 + i, title: `File:Distinct subject ${String.fromCharCode(97 + i)}.jpg`, attribution: `Author ${i}` }),
|
||
);
|
||
expect(rankCommonsCandidates(many, 3)).toHaveLength(3);
|
||
});
|
||
});
|
||
|
||
describe('readWikiIdentity', () => {
|
||
it('MAPS-145: reads the three identity tags and nothing else', () => {
|
||
expect(
|
||
readWikiIdentity({
|
||
wikidata: 'Q82425',
|
||
wikipedia: 'de:Brandenburger Tor',
|
||
wikimedia_commons: 'Category:Brandenburg Gate',
|
||
cuisine: 'italian',
|
||
}),
|
||
).toEqual({
|
||
wikidata: 'Q82425',
|
||
wikipedia: 'de:Brandenburger Tor',
|
||
wikimedia_commons: 'Category:Brandenburg Gate',
|
||
});
|
||
});
|
||
|
||
it('MAPS-146: ignores brand tags, which describe the chain and not the branch', () => {
|
||
// Real payload for "L'Osteria Rostock": following brand:wikidata would
|
||
// describe (and illustrate) L'Osteria the company.
|
||
const identity = readWikiIdentity({
|
||
'brand:wikidata': 'Q17323478',
|
||
'brand:wikipedia': 'de:L’Osteria',
|
||
cuisine: 'pizza',
|
||
});
|
||
expect(identity).toEqual({ wikidata: null, wikipedia: null, wikimedia_commons: null });
|
||
});
|
||
});
|
||
|
||
describe('brandLogo', () => {
|
||
// A fresh service per case: the logo cache lives on the instance, and a hit from
|
||
// one case would answer the next one's question before its fetch stub ran.
|
||
const service = (): MapsService => buildMapsService(photoCacheStub, appSettingsStub, usersStub, placeDetailsCacheStub, placesStub, noGoogleQuota);
|
||
|
||
const claimResponse = (file: string | null) => ({
|
||
ok: true,
|
||
json: async () => (file === null ? { claims: {} } : { claims: { P154: [{ mainsnak: { datavalue: { value: file } } }] } }),
|
||
});
|
||
|
||
const imageResponse = (bytes: number, contentType = 'image/png') => ({
|
||
ok: true,
|
||
headers: new Headers({ 'content-type': contentType, 'content-length': String(bytes) }),
|
||
arrayBuffer: async () => new ArrayBuffer(bytes),
|
||
});
|
||
|
||
/** A real PNG — the flattening path decodes what it is given, so a fake buffer won't do. */
|
||
const pngBytes = async (width: number, height: number, color: number): Promise<ArrayBuffer> => {
|
||
const buf = await new Jimp({ width, height, color }).getBuffer('image/png');
|
||
return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) as ArrayBuffer;
|
||
};
|
||
|
||
const pngResponse = (bytes: ArrayBuffer) => ({
|
||
ok: true,
|
||
headers: new Headers({ 'content-type': 'image/png', 'content-length': String(bytes.byteLength) }),
|
||
arrayBuffer: async () => bytes,
|
||
});
|
||
|
||
it('MAPS-147: reads the logo claim and flattens the returned bytes onto a square', async () => {
|
||
// A wide, dark wordmark — the shape that used to arrive as a plain white pin.
|
||
const fetchMock = vi.fn()
|
||
.mockResolvedValueOnce(claimResponse('Aral Logo.svg'))
|
||
.mockResolvedValueOnce(pngResponse(await pngBytes(120, 40, 0x1a3d8fff)));
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
|
||
const logo = await service().brandLogo('Q565734');
|
||
|
||
expect(logo?.contentType).toBe('image/png');
|
||
const out = await Jimp.read(Buffer.from(logo!.bytes));
|
||
// Square, so the round pin can crop it without ever cutting the mark.
|
||
expect(out.bitmap.width).toBe(96);
|
||
expect(out.bitmap.height).toBe(96);
|
||
// A dark mark gets a light ground, and the padding stays clear of it: the whole
|
||
// wordmark is visible instead of the pin showing its middle.
|
||
expect(out.getPixelColor(2, 2)).toBe(0xffffffff);
|
||
expect(out.getPixelColor(4, 48)).toBe(0xffffffff);
|
||
expect(out.getPixelColor(48, 48)).toBe(0x1a3d8fff);
|
||
|
||
const [claimUrl] = fetchMock.mock.calls[0];
|
||
expect(claimUrl).toContain('wbgetclaims');
|
||
expect(claimUrl).toContain('property=P154');
|
||
const [fileUrl] = fetchMock.mock.calls[1];
|
||
// Special:FilePath renders the thumbnail and redirects, so the width rides along.
|
||
expect(fileUrl).toContain('Special:FilePath/Aral%20Logo.svg');
|
||
expect(fileUrl).toContain('width=128');
|
||
});
|
||
|
||
it('MAPS-153: a near-white logo gets a dark ground instead of vanishing', async () => {
|
||
// TotalEnergies, Esso and JET all read as white marks; on a white pin they were gone.
|
||
const fetchMock = vi.fn()
|
||
.mockResolvedValueOnce(claimResponse('JET logo.svg'))
|
||
.mockResolvedValueOnce(pngResponse(await pngBytes(64, 64, 0xfafafaff)));
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
|
||
const out = await Jimp.read(Buffer.from((await service().brandLogo('Q568940'))!.bytes));
|
||
|
||
expect(out.getPixelColor(2, 2)).toBe(0x111827ff);
|
||
expect(out.getPixelColor(48, 48)).toBe(0xfafafaff);
|
||
});
|
||
|
||
it('MAPS-154: a logo smaller than the canvas is left at its own size, not blown up', async () => {
|
||
const fetchMock = vi.fn()
|
||
.mockResolvedValueOnce(claimResponse('Tiny.png'))
|
||
.mockResolvedValueOnce(pngResponse(await pngBytes(16, 16, 0x203040ff)));
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
|
||
const out = await Jimp.read(Buffer.from((await service().brandLogo('Q1'))!.bytes));
|
||
|
||
// Centred on the 96px canvas: the 16px mark occupies 40..56, the rest is ground.
|
||
expect(out.getPixelColor(48, 48)).toBe(0x203040ff);
|
||
expect(out.getPixelColor(30, 48)).toBe(0xffffffff);
|
||
});
|
||
|
||
it('MAPS-148: asks Wikidata nothing when the id is not one', async () => {
|
||
const fetchMock = vi.fn();
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
|
||
for (const bad of ['', 'Q', 'P154', 'Q0', '../secrets', 'Q12; DROP TABLE places']) {
|
||
expect(await service().brandLogo(bad)).toBeNull();
|
||
}
|
||
expect(fetchMock).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('MAPS-149: answers a repeat from memory instead of asking again', async () => {
|
||
const fetchMock = vi.fn()
|
||
.mockResolvedValueOnce(claimResponse('Esso.png'))
|
||
.mockResolvedValueOnce(imageResponse(1024));
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
|
||
const svcOne = service();
|
||
await svcOne.brandLogo('Q867662');
|
||
await svcOne.brandLogo('Q867662');
|
||
|
||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||
});
|
||
|
||
it('MAPS-150: remembers that a brand has no logo, so a map pan does not ask again', async () => {
|
||
const fetchMock = vi.fn().mockResolvedValue(claimResponse(null));
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
|
||
const svcOne = service();
|
||
expect(await svcOne.brandLogo('Q89432390')).toBeNull();
|
||
expect(await svcOne.brandLogo('Q89432390')).toBeNull();
|
||
|
||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
it('MAPS-151: refuses an image that is too big, and one that is not an image', async () => {
|
||
vi.stubGlobal('fetch', vi.fn()
|
||
.mockResolvedValueOnce(claimResponse('Huge.png'))
|
||
.mockResolvedValueOnce(imageResponse(4 * 1024 * 1024)));
|
||
expect(await service().brandLogo('Q1')).toBeNull();
|
||
|
||
vi.stubGlobal('fetch', vi.fn()
|
||
.mockResolvedValueOnce(claimResponse('NotAnImage.svg'))
|
||
.mockResolvedValueOnce(imageResponse(500, 'text/html')));
|
||
expect(await service().brandLogo('Q2')).toBeNull();
|
||
});
|
||
|
||
it('MAPS-152: a refused hop leaves the marker without a logo rather than failing', async () => {
|
||
mockCheckSsrf.mockResolvedValue({ allowed: false, error: 'blocked' } as SsrfResult);
|
||
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(claimResponse('Blocked.png')));
|
||
|
||
expect(await service().brandLogo('Q3')).toBeNull();
|
||
});
|
||
})
|
||
|
||
// ── Websites without a scheme (#2483) ─────────────────────────────────────────
|
||
//
|
||
// Every source hands its website through normalizePlaceWebsite, so a place that
|
||
// is picked from any of them saves without a 400 over a field nobody sees.
|
||
|
||
describe('websites from the map sources (#2483)', () => {
|
||
it('MAPS-2483-01: OSM details complete a bare host and skip a contact:website that is no website', () => {
|
||
expect(buildOsmDetails({ website: 'www.example.fr/patrimoine' }, 'way', '1').website).toBe(
|
||
'https://www.example.fr/patrimoine',
|
||
);
|
||
expect(buildOsmDetails({ 'contact:website': 'mailto:mairie@example.fr', website: 'example.fr' }, 'node', '1').website).toBe(
|
||
'https://example.fr',
|
||
);
|
||
expect(buildOsmDetails({ website: 'javascript:alert(1)' }, 'node', '1').website).toBeNull();
|
||
});
|
||
|
||
it('MAPS-2483-02: an Overpass POI gets https on a bare host and falls back past a script link', async () => {
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
json: async () => ({
|
||
elements: [
|
||
{ type: 'node', id: 1, lat: 48.03, lon: -3.49, tags: { name: 'Chapelle', tourism: 'attraction', website: 'fr.wikipedia.org/wiki/Chapelle_Sainte-Barbe_du_Faouët' } },
|
||
{ type: 'node', id: 2, lat: 48.04, lon: -3.49, tags: { name: 'Mairie', tourism: 'attraction', website: 'javascript:alert(1)', 'contact:website': '//www.example.fr' } },
|
||
{ type: 'node', id: 3, lat: 48.05, lon: -3.49, tags: { name: 'Halles', tourism: 'attraction', website: 'Halles' } },
|
||
],
|
||
}),
|
||
}),
|
||
);
|
||
const { pois } = await osm.searchOverpassPois('sights', { south: 48.0, west: -3.6, north: 48.1, east: -3.4 }, 'fr-FR');
|
||
expect(pois.map((p) => p.website)).toEqual([
|
||
'https://fr.wikipedia.org/wiki/Chapelle_Sainte-Barbe_du_Faouët',
|
||
'https://www.example.fr',
|
||
null,
|
||
]);
|
||
});
|
||
|
||
it('MAPS-2483-03: a Google search result goes through the same helper', async () => {
|
||
mockDbGet.mockReturnValueOnce({ maps_api_key: 'ENCRYPTED' }).mockReturnValueOnce(null);
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
json: async () => ({
|
||
places: [{ id: 'gid-web', displayName: { text: 'Chapelle' }, location: { latitude: 48, longitude: -3 }, websiteUri: 'www.example.fr' }],
|
||
}),
|
||
}),
|
||
);
|
||
const search = await svc.searchPlaces(1, 'Chapelle');
|
||
expect((search.places[0] as { website: unknown }).website).toBe('https://www.example.fr');
|
||
});
|
||
|
||
const googleDetails = (websiteUri: string) =>
|
||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => ({ id: 'ChIJWeb', websiteUri }) }));
|
||
|
||
it('MAPS-2483-04: so does the website of a Google details lookup', async () => {
|
||
mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' });
|
||
googleDetails('example.fr/visite');
|
||
const place = (await svc.getPlaceDetails(1, 'ChIJWeb-lean')).place as { website: unknown };
|
||
expect(place.website).toBe('https://example.fr/visite');
|
||
});
|
||
|
||
it('MAPS-2483-05: and of the expanded one, where a value that is no page becomes null', async () => {
|
||
mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' });
|
||
mockDbGet.mockReturnValueOnce(undefined);
|
||
googleDetails('data:text/html,x');
|
||
const place = (await svc.getPlaceDetailsExpanded(1, 'ChIJWeb-expanded')).place as { website: unknown };
|
||
expect(place.website).toBeNull();
|
||
});
|
||
|
||
// A details row cached before the fix still holds the website as the source
|
||
// sent it, and keeps for a week; an expanded row keeps until a refresh. The
|
||
// cache hands it out through the same helper.
|
||
const cachedRow = (place: Record<string, unknown>) => ({ payload_json: JSON.stringify(place), fetched_at: Date.now() });
|
||
|
||
it('MAPS-2483-06: a cached details row with a bare website is served with https, without a Google call', async () => {
|
||
const fetchMock = vi.fn();
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
mockDbGet
|
||
.mockReturnValueOnce({ maps_api_key: 'gkey' })
|
||
.mockReturnValueOnce(cachedRow({ google_place_id: 'ChIJOld', name: 'Chapelle', website: 'example.fr/visite' }));
|
||
const { place } = await svc.getPlaceDetails(1, 'ChIJOld');
|
||
expect(place).toMatchObject({ name: 'Chapelle', website: 'https://example.fr/visite' });
|
||
expect(fetchMock).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('MAPS-2483-07: so is an expanded row, where a script link becomes null and a row without the field stays as it is', async () => {
|
||
const fetchMock = vi.fn();
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
mockDbGet
|
||
.mockReturnValueOnce({ maps_api_key: 'gkey' })
|
||
.mockReturnValueOnce(cachedRow({ google_place_id: 'ChIJOldX', website: '//www.example.fr', reviews: [] }))
|
||
.mockReturnValueOnce({ maps_api_key: 'gkey' })
|
||
.mockReturnValueOnce(cachedRow({ google_place_id: 'ChIJOldY', website: 'javascript:alert(1)' }))
|
||
.mockReturnValueOnce({ maps_api_key: 'gkey' })
|
||
.mockReturnValueOnce(cachedRow({ google_place_id: 'ChIJOldZ', name: 'No site' }));
|
||
expect((await svc.getPlaceDetailsExpanded(1, 'ChIJOldX')).place).toMatchObject({ website: 'https://www.example.fr', reviews: [] });
|
||
expect((await svc.getPlaceDetailsExpanded(1, 'ChIJOldY')).place).toMatchObject({ website: null });
|
||
expect((await svc.getPlaceDetailsExpanded(1, 'ChIJOldZ')).place).toEqual({ google_place_id: 'ChIJOldZ', name: 'No site' });
|
||
expect(fetchMock).not.toHaveBeenCalled();
|
||
});
|
||
});
|