Files
TREK/server/tests/unit/nest/maps.gers-details.test.ts
T
Maurice 2350c3ae14 fix(places): a website without a scheme no longer blocks saving a place
A place from the TREK index can carry its website the way Overture has it,
such as fr.wikipedia.org/wiki/Chapelle_Sainte-Barbe_du_Faouët, and saving
it to a collection or a trip failed with "website must be an http or https
URL" over a field the dialog does not show (#2483). OpenStreetMap, Amap and
booking mails hand over websites the same way.

Every place a website comes in from outside now goes through one shared
helper, normalizePlaceWebsite: the index client and its nearby search, the
Overpass and OSM detail mappers, Google search and details, Amap, plugin
search and the booking import. It completes a bare or protocol-relative
host to https, keeps http and https as they are, and drops every other
scheme and anything that is no address. Only the scheme is added, so an
accented path is stored as written.

placeWebsiteSchema completes a bare host the same way before its check, so
an older client, a cached offline result, an MCP agent or a plugin is no
longer refused, while javascript:, data: and mailto: still are. The REST
controller, the plugin RPC and the enrichment now store the parsed value
instead of the raw one, and create_place_accommodation takes the website
through the same schema as create_place. safeHttpUrl still links only a
value already stored in its final form, so nothing changes on screen.

Tests: SHARED-WEBSITE-001 to 012, SHARED-PLACE-030 to 034,
SHARED-COLLFILE-2483-01, COLLECTIONS-E2E-089 and 090,
PLACES-E2E-2483-01 and 02, PLACES-CTRL-2483-01 (the louvre.fr case
adjusted), MCP-PLACES-2483-01 to 03, MCP-ACCOM-2483-01, MCP-COLL-2483-01,
PLACES-RPC-2483-01, MAPS-2483-01 to 05, MAPS-POIS-015, MAPS-GERS-010,
AMAP-012b, TREK-PLACES-2483-01, PLUGIN-SEARCH-2483-01,
BOOKING-IMPORT-2483-01, ENRICH-052e, ENRICH-102 extended, ENRICH-120 adjusted,
FE-UTIL-SAFEURL-007.
2026-09-23 22:35:01 +02:00

150 lines
7.5 KiB
TypeScript

/**
* MAPS-GERS-001..007 — the details of a place that came from the index.
*
* The case that started this: L'Osteria Steinstrasse showed opening hours while
* it was being added and none on its card afterwards. The index is not the only
* source for a place it holds, and this is where the rest gets filled in.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
const { mockById } = vi.hoisted(() => ({
mockById: vi.fn(async (_gers: string): Promise<unknown> => null),
}));
vi.mock('../../../src/nest/maps/trek-places.client', async (importOriginal) => ({
...(await importOriginal<typeof import('../../../src/nest/maps/trek-places.client')>()),
trekPlacesById: mockById,
}));
vi.mock('../../../src/config', () => ({ JWT_SECRET: 'test-secret', ENCRYPTION_KEY: '0'.repeat(64) }));
import { MapsService } from '../../../src/nest/maps/maps.service';
import type { DatabaseService } from '../../../src/nest/database/database.service';
import type { PlacePhotoCacheService } from '../../../src/nest/place-photos/place-photo-cache.service';
const PLACE = {
gers: 'abc-123',
name: "L'Osteria",
lat: 54.0879,
lng: 12.1408,
category: 'restaurant',
categoryPath: 'eat_and_drink>restaurant',
confidence: 1,
address: { freeform: 'Steinstrasse 9', locality: 'Rostock', postcode: '18055', region: null, country: 'DE' },
contact: { website: 'https://losteria.net/', phone: null, email: null, socials: null },
brand: null,
source: 'overture',
hours: null as { osm: string } | null,
};
function make(osmTags: Record<string, string> | null) {
const database = { get: vi.fn(() => undefined) } as unknown as DatabaseService;
const svc = new MapsService(database, {} as PlacePhotoCacheService);
vi.spyOn(svc, 'resolveOsmIdentity').mockResolvedValue(
osmTags ? { tags: osmTags, osmUrl: 'https://www.openstreetmap.org/node/1', matchedName: "L'Osteria" } : null,
);
return svc;
}
beforeEach(() => {
mockById.mockReset();
mockById.mockResolvedValue({ ...PLACE });
vi.spyOn(console, 'warn').mockImplementation(() => {});
});
describe('MapsService.getPlaceDetails for a gers: id', () => {
it('MAPS-GERS-001: prefers the hours OpenStreetMap has for the same building', async () => {
// OSM describes this exact object and gets corrected by people who walked
// past. A chain's website often carries one set of hours for every branch.
mockById.mockResolvedValue({ ...PLACE, hours: { osm: 'Mo-Su 12:00-22:00' } });
const out = await make({ opening_hours: 'Mo-Su 11:30-23:00' }).getPlaceDetails(1, 'gers:abc-123');
// Expanded to weekday lines, which is the shape the card reads.
expect(out.place?.opening_hours).toEqual(expect.arrayContaining(['Monday: 11:30-23:00']));
});
it('MAPS-GERS-002: falls back to the hours the operator publishes', async () => {
// OpenStreetMap has hours for 27.5 percent of gastronomy, measured across
// seven countries. This is what covers part of the rest.
mockById.mockResolvedValue({ ...PLACE, hours: { osm: 'Mo-Su 12:00-22:00' } });
const out = await make({ cuisine: 'italian' }).getPlaceDetails(1, 'gers:abc-123');
expect(out.place?.opening_hours).toEqual(expect.arrayContaining(['Monday: 12:00-22:00']));
});
it('MAPS-GERS-003: still answers when OpenStreetMap knows nothing about it', async () => {
mockById.mockResolvedValue({ ...PLACE, hours: { osm: 'Mo-Su 12:00-22:00' } });
const out = await make(null).getPlaceDetails(1, 'gers:abc-123');
expect(out.place?.name).toBe("L'Osteria");
// Same shape whether the hours came from OSM or from the operator's site.
expect(out.place?.opening_hours).toEqual(expect.arrayContaining(['Monday: 12:00-22:00']));
});
it('MAPS-GERS-004: a place nobody has hours for shows none, not an empty string', async () => {
const out = await make(null).getPlaceDetails(1, 'gers:abc-123');
expect(out.place?.opening_hours ?? null).toBeNull();
});
it('MAPS-GERS-005: the index wins on the fields the user actually picked', async () => {
const out = await make({ name: 'Etwas anderes', opening_hours: 'Mo-Fr 09:00-17:00' })
.getPlaceDetails(1, 'gers:abc-123');
expect(out.place).toMatchObject({ name: "L'Osteria", osm_id: 'gers:abc-123', source: 'trek-places' });
});
it('MAPS-GERS-006: an unknown id is null rather than a thrown request', async () => {
mockById.mockResolvedValue(null);
expect(await make(null).getPlaceDetails(1, 'gers:nope')).toEqual({ place: null });
});
it('MAPS-GERS-007: a failing lookup is a null, not a 500', async () => {
mockById.mockRejectedValue(new Error('places api down'));
expect(await make(null).getPlaceDetails(1, 'gers:abc-123')).toEqual({ place: null });
});
it('MAPS-GERS-008: Overpass being down costs the OSM half, not the whole place', async () => {
// The index has already answered by the time OSM is asked; the second lookup
// only adds what OSM knows about the same building. Letting its failure
// through would turn a working answer into an error for the one user whose
// details request happened to land while Overpass was unreachable.
const database = { get: vi.fn(() => undefined) } as unknown as DatabaseService;
const svc = new MapsService(database, {} as PlacePhotoCacheService);
vi.spyOn(svc, 'resolveOsmIdentity').mockRejectedValue(new Error('overpass down'));
mockById.mockResolvedValue({ ...PLACE, hours: { osm: 'Mo-Su 12:00-22:00' } });
const out = await svc.getPlaceDetails(1, 'gers:abc-123');
expect(out.place).toMatchObject({
name: "L'Osteria",
osm_id: 'gers:abc-123',
source: 'trek-places',
website: 'https://losteria.net/',
});
// The index's own hours survive: they came with the record, not from OSM.
expect(out.place?.opening_hours).toBeTruthy();
});
// #2483: the place from the issue, whose index record carries its website
// without a scheme. Both halves of the merge hand it over completed.
it('MAPS-GERS-010: the merged website has its scheme, from the index or else from OSM', async () => {
const site = 'fr.wikipedia.org/wiki/Chapelle_Sainte-Barbe_du_Faouët';
mockById.mockResolvedValue({ ...PLACE, gers: 'ceba0e62-172b-4343-bb3b-78b915a18383', contact: { ...PLACE.contact, website: site } });
expect((await make(null).getPlaceDetails(1, 'gers:ceba0e62-172b-4343-bb3b-78b915a18383')).place?.website).toBe(`https://${site}`);
expect((await make({ website: 'www.example.fr' }).getPlaceDetails(1, 'gers:ceba0e62-172b-4343-bb3b-78b915a18383')).place?.website).toBe(`https://${site}`);
mockById.mockResolvedValue({ ...PLACE, contact: { ...PLACE.contact, website: 'javascript:alert(1)' } });
expect((await make({ website: 'www.example.fr' }).getPlaceDetails(1, 'gers:abc-123')).place?.website).toBe('https://www.example.fr');
expect((await make(null).getPlaceDetails(1, 'gers:abc-123')).place?.website).toBeNull();
});
it('MAPS-GERS-009: with the index switched off, opening a saved place asks nobody', async () => {
// TREK_PLACES_ENABLED=false is the operator's word that nothing leaves for
// the index. Search, autocomplete and the area download honoured it; the
// lookup by id did not, so every saved place that came from the index was
// still sent out, with the instance token, on every open.
const svc = make({ opening_hours: 'Mo-Su 11:30-23:00' });
vi.spyOn(svc, 'trekPlacesEnabled').mockReturnValue(false);
await expect(svc.getPlaceDetails(1, 'gers:abc-123')).resolves.toEqual({ place: null });
expect(mockById).not.toHaveBeenCalled();
expect(svc.resolveOsmIdentity).not.toHaveBeenCalled();
});
});