feat(places): send a search to Google, once or always

Since 4.3.0 the TREK index and OpenStreetMap answer every search first and
Google is only asked when they find nothing, so a list with the wrong place
on it never reached Google, whatever key the instance had, and the provider
choice only decided who fills the empty case.

Two ways to change that, both only where Google holds the key slot. Under a
result list that did not come from Google, one quiet line sends the same
query to Google Places alone (`provider: 'google'` on the search contract,
the MCP search tool takes it too). And beside the key, an admin switch
"Search with Google only" hands every search and every suggestion to Google
and asks nothing of the index or OpenStreetMap; off by default, read by the
search after the keyed provider so an install without a key, or one on Amap
or OpenStreetMap, is untouched. The panel says so beside the switch.

Both shells share the one rule for when the line is offered (placeSource),
and the wiki names both switches.
This commit is contained in:
Maurice
2026-09-21 22:14:44 +02:00
parent 11b1399b5c
commit e12ae950df
70 changed files with 525 additions and 39 deletions
@@ -495,6 +495,36 @@ describe('AddonsService transit provider', () => {
* PlaceShadowService.enabled(), which reads the same key itself — if the two
* diverge the admin panel shows "off" while the log keeps collecting picks.
*/
/**
* The Google-only switch reads like the shadow log: nobody had it before it
* existed, so an absent row is off, and MapsService.googleOnly() compares the
* same key against the same literal.
*/
describe('AddonsService places Google-only flag', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('ADDONS-SVC-095 an unset flag reads as OFF, only the literal "true" switches it on', () => {
dbMock._stmt.get.mockReturnValueOnce(undefined);
expect(svc().getPlacesGoogleOnly()).toEqual({ enabled: false });
expect(dbMock._stmt.get).toHaveBeenLastCalledWith('places_google_only');
for (const value of ['false', 'TRUE', '1', '']) {
dbMock._stmt.get.mockReturnValueOnce({ value });
expect(svc().getPlacesGoogleOnly()).toEqual({ enabled: false });
}
dbMock._stmt.get.mockReturnValueOnce({ value: 'true' });
expect(svc().getPlacesGoogleOnly()).toEqual({ enabled: true });
});
it('ADDONS-SVC-096 the setter persists the literal string under its own key', () => {
expect(svc().updatePlacesGoogleOnly(true)).toEqual({ enabled: true });
expect(dbMock._stmt.run).toHaveBeenLastCalledWith('places_google_only', 'true');
expect(svc().updatePlacesGoogleOnly(false)).toEqual({ enabled: false });
expect(dbMock._stmt.run).toHaveBeenLastCalledWith('places_google_only', 'false');
});
});
describe('AddonsService place shadow flag', () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -48,6 +48,9 @@ const addonsStub = () => ({
// Fail-open, unlike its three neighbours — see AddonsService.getPlacesEnrich.
getPlacesEnrich: vi.fn(() => ({ enabled: true })),
updatePlacesEnrich: vi.fn((enabled: boolean) => ({ enabled })),
// Fail-closed with nothing to backfill, like the shadow log.
getPlacesGoogleOnly: vi.fn(() => ({ enabled: false })),
updatePlacesGoogleOnly: vi.fn((enabled: boolean) => ({ enabled })),
// Fail-open too, and the only switch here about egress rather than spending.
getCollabFeatures: vi.fn(() => ({ chat: false })),
updateCollabFeatures: vi.fn(() => ({ features: { chat: true }, changed: true })),
@@ -277,9 +280,16 @@ describe('AdminController feature toggles', () => {
expect(c.getPlacesDetails()).toEqual({ enabled: false });
expect(c.getPlaceShadow()).toEqual({ enabled: false });
expect(c.getPlacesEnrich()).toEqual({ enabled: true });
expect(c.getPlacesGoogleOnly()).toEqual({ enabled: false });
expect(c.getCollabFeatures()).toEqual({ chat: false });
});
it('ADMIN-TOGGLE-002d places-google-only updates through the addons domain and is audited', () => {
const c = adminCtl(svc());
expect(c.updatePlacesGoogleOnly(user, { enabled: true }, req)).toEqual({ enabled: true });
expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'admin.places_google_only', details: { enabled: true } }));
});
it('ADMIN-TOGGLE-002b places-enrich updates through the addons domain and is audited', () => {
const c = adminCtl(svc());
expect(c.updatePlacesEnrich(user, { enabled: false }, req)).toEqual({ enabled: false });
@@ -49,14 +49,20 @@ describe('MapsController (parity with the legacy /api/maps route)', () => {
const search = vi.fn().mockResolvedValue({ places: [], source: 'osm' });
const res = await makeController({ search }).search(user, { query: 'berlin' }, 'de');
expect(res).toEqual({ places: [], source: 'osm' });
expect(search).toHaveBeenCalledWith(3, 'berlin', 'de', undefined);
expect(search).toHaveBeenCalledWith(3, 'berlin', 'de', undefined, undefined);
});
it('forwards the provider a search is sent to alone', async () => {
const search = vi.fn().mockResolvedValue({ places: [], source: 'google' });
await makeController({ search }).search(user, { query: 'x', provider: 'google' }, 'de');
expect(search).toHaveBeenCalledWith(3, 'x', 'de', undefined, 'google');
});
it('forwards a valid locationBias to the service', async () => {
const search = vi.fn().mockResolvedValue({ places: [], source: 'osm' });
const bias = { lat: 1, lng: 2, radius: 5000 };
await makeController({ search }).search(user, { query: 'x', locationBias: bias }, 'de');
expect(search).toHaveBeenCalledWith(3, 'x', 'de', bias);
expect(search).toHaveBeenCalledWith(3, 'x', 'de', bias, undefined);
});
it('maps a service error to its status + message', async () => {
@@ -76,13 +76,27 @@ const osmAnswer = (rows: unknown[]) => ({ ok: true, json: async () => rows });
/**
* Keyed on the statement so the kill switch can be driven without also handing
* `'false'` to the API-key resolver, which would send the fallback at Google.
*
* `rows` are the app_settings the instance holds, by key: a Google key, the
* provider choice, the Google-only switch. Anything else reads as absent.
*/
function make(enabled = true) {
function make(enabled = true, rows: Record<string, string> = {}) {
trekPlaces.on = enabled;
const database = { get: vi.fn(() => undefined) } as unknown as DatabaseService;
const database = {
get: vi.fn((sql: string, key?: unknown) =>
typeof key === 'string' && sql.includes('app_settings') && rows[key] !== undefined ? { value: rows[key] } : undefined,
),
} as unknown as DatabaseService;
return new MapsService(database, {} as PlacePhotoCacheService);
}
const googleAnswer = (name: string) => ({
ok: true,
json: async () => ({
places: [{ id: 'g1', displayName: { text: name }, formattedAddress: 'Tokyo', location: { latitude: 35.68, longitude: 139.77 } }],
}),
});
beforeEach(() => {
mockSearch.mockReset();
mockSearch.mockResolvedValue([]);
@@ -195,6 +209,64 @@ describe('MapsService.searchPlaces — the merged path', () => {
expect(out.places[2].name).toBe('Index 1');
});
it('MAPS-SEARCH-011: the Google-only switch sends the search to Google and asks neither the index nor OpenStreetMap', async () => {
mockSearch.mockResolvedValue([indexHit('Weigh station, Ritzville')]);
mockNominatim.mockResolvedValue(osmAnswer([osmRow('Some station')]));
const fetchMock = vi.fn().mockResolvedValue(googleAnswer('Tokyo Station'));
vi.stubGlobal('fetch', fetchMock);
const out = await make(true, { maps_api_key: 'key', places_google_only: 'true' }).searchPlaces(1, 'Tokyo Station');
expect(mockSearch).not.toHaveBeenCalled();
expect(mockNominatim).not.toHaveBeenCalled();
expect(out.source).toBe('google');
expect(out.places[0].name).toBe('Tokyo Station');
expect(String(fetchMock.mock.calls[0][0])).toContain('places:searchText');
vi.unstubAllGlobals();
});
it('MAPS-SEARCH-012: the switch changes nothing without a Google key, or when Google does not hold the slot', async () => {
mockSearch.mockResolvedValue([indexHit("L'Osteria")]);
mockNominatim.mockResolvedValue(osmAnswer([osmRow('Steinstrasse')]));
// No key at all: the index and OpenStreetMap answer, as before.
const keyless = await make(true, { places_google_only: 'true' }).searchPlaces(1, "L'Osteria");
expect(keyless.source).toBe('trek-places+openstreetmap');
// A key, but the admin picked OpenStreetMap as the provider: Google holds
// no slot, so there is nothing for the switch to hand the search to.
mockSearch.mockClear();
const osmOnly = await make(true, { maps_api_key: 'key', places_google_only: 'true', places_provider: 'openstreetmap' })
.searchPlaces(1, "L'Osteria");
expect(mockSearch).toHaveBeenCalled();
expect(osmOnly.source).toBe('trek-places+openstreetmap');
// A key and the switch off: the index still answers first.
mockSearch.mockClear();
const off = await make(true, { maps_api_key: 'key', places_google_only: 'false' }).searchPlaces(1, "L'Osteria");
expect(mockSearch).toHaveBeenCalled();
expect(off.source).toBe('trek-places+openstreetmap');
});
it('MAPS-SEARCH-013: one search sent to Google on purpose goes there alone, and only where Google holds the slot', async () => {
mockSearch.mockResolvedValue([indexHit('Weigh station, Ritzville')]);
mockNominatim.mockResolvedValue(osmAnswer([osmRow('Some station')]));
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(googleAnswer('Tokyo Station')));
const asked = await make(true, { maps_api_key: 'key' }).searchPlaces(1, 'Tokyo Station', undefined, undefined, { googleOnly: true });
expect(mockSearch).not.toHaveBeenCalled();
expect(asked.source).toBe('google');
// The same request on an install without a key is an ordinary search: the
// link that sends it is never shown there, and a client that sends it
// anyway gets what everybody else gets.
mockSearch.mockClear();
const keyless = await make(true, {}).searchPlaces(1, 'Tokyo Station', undefined, undefined, { googleOnly: true });
expect(mockSearch).toHaveBeenCalled();
expect(keyless.source).toBe('trek-places+openstreetmap');
vi.unstubAllGlobals();
});
it('MAPS-SEARCH-007: with the index switched off the query never leaves for it', async () => {
mockSearch.mockResolvedValue([indexHit("L'Osteria")]);
mockNominatim.mockResolvedValue(osmAnswer([osmRow('Steinstrasse')]));
+3 -1
View File
@@ -2721,7 +2721,9 @@ describe('controller-facing wrappers delegate to the folded methods', () => {
try {
const circleBias = { lat: 1, lng: 2, radius: 5 };
await svc.search(3, 'berlin', 'de', circleBias);
expect(spies.searchPlaces).toHaveBeenCalledWith(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);