mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
ed18c37903
Every file the release touches was read once more, and what survived an independent check against the code is fixed here, with a test for each. Data and credentials - A document sync connection edited to a new address no longer carries the stored token along: a blank form under another origin is refused, the same rule the probe route already had. Dawarich and AirTrail testConnection send the stored key only to the host it was stored for. - The key rotation script now covers document_connections.secrets and trip_document_links.webhook_secret, which it had skipped. - Turning a booked night into a pause in the road trip popup asks first and says that the booking and its expense go with it. Deleting a place says the same when a night is booked there, on the desktop and on the phone. - A moved version marker over unchanged bytes no longer replaces the TREK file and its attachments; a real revision carries the attachments over. Planner - A drop in the day plan names the row it lands ahead of in the full day, so a hidden hotel stop no longer shifts it one slot early; the store sorts before it splices. Via re-anchoring, the booked-night guard and the sidebar's day membership read the stored day, the index space the server uses. - A hotel stop placed mid-day re-pins the road trip vias of that day, and the booking session receives the new stop order too. The Public API and the iCal feed list a booked hotel once. A plugin can no longer write a stay against another trip's day. Road trip and map - The route overview paces its requests to the public routers and retries a rate limit, and it says which legs it could not route instead of quietly reporting a shorter trip. A click on a via handle no longer drops a second via; handles hide when zooming out. Read-only members cannot edit the driving limits. The phone shows a way back when no day is picked and prints the night drive departure in the chosen clock format. Integrations and search - Google transit keeps conventional trains when the train chip is on. TREK_PLACES_ENABLED=false stops the details and enrichment calls too, and accepts the whole boolean family. A caller-supplied description no longer lands in the instance-wide enrichment cache. Nominatim searches carry a timeout. An OSM place id is checked for shape before it reaches Overpass. - Dawarich puts a stay on the trip whose dates hold it, the Atlas hand-off knows what the Atlas already shows, and a host change with a new key clears the old instance's state. - Document sync: a webhook address pasted by hand works on the token alone, deleting a connection unregisters its subscriptions, Nextcloud under a sub-path connects, Synology answers a name collision before uploading and verifies a move into the bin, Paperless titles are capped at 128 characters, a DNS failure reads as unreachable rather than as a policy verdict, videos from the store are accepted like uploads, and remote_missing has a way out. Conflict buttons only for the owner, a refused choice is reported, a refused credential gets a Reconnect button, disconnecting asks first on the desktop as it does on the phone, and the folder picker no longer spins forever. Release - The server holds the release notice back from a bundle that cannot draw it, so the previous shell served from the service worker cache after an update no longer shows bare keys and no longer lets a click use the notice up. - Import into an existing list gets the same body limit as a new list. Chat images say when the limit is reached and the new controls are translated. The transit backend switch reports a failed save. The Helm chart carries the Amap credentials in its Secret; the shipped templates document the Places API and Amap variables and the Overpass default. The wiki follows.
648 lines
29 KiB
TypeScript
648 lines
29 KiB
TypeScript
/**
|
||
* ENRICH-070..082 — where a description comes from.
|
||
*
|
||
* The cases the column got wrong on dev1: L'Osteria Rostock, Hamburg Airport
|
||
* and Berlin Brandenburg Airport all showed no description at all with a Google
|
||
* key configured, and Berlin Hauptbahnhof showed none without one. Two distinct
|
||
* causes, both pinned here — Google short-circuiting the free chain, and the
|
||
* chain itself only ever starting from an OSM `wikipedia` tag.
|
||
*/
|
||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||
|
||
const { mockDbGet, mockDbRun } = vi.hoisted(() => ({
|
||
// The prepare() stub below forwards the SQL and then the bound values, so the
|
||
// spy has to declare that shape. Inferred from a zero-arg implementation it
|
||
// would reject the spread at the call site.
|
||
mockDbGet: vi.fn((_sql: string, ..._params: unknown[]): unknown => undefined),
|
||
mockDbRun: vi.fn(),
|
||
}));
|
||
|
||
vi.mock('../../../src/db/database', () => ({
|
||
db: {
|
||
prepare: (sql: string) => ({
|
||
get: (...params: unknown[]) => mockDbGet(sql, ...params),
|
||
run: (...params: unknown[]) => mockDbRun(sql, ...params),
|
||
all: () => [],
|
||
}),
|
||
},
|
||
}));
|
||
|
||
vi.mock('../../../src/utils/ssrfGuard', () => ({
|
||
safeFetchFollow: vi.fn(async () => ({ ok: false, arrayBuffer: async () => new ArrayBuffer(0) })),
|
||
checkSsrf: vi.fn(async () => ({ allowed: true })),
|
||
SsrfBlockedError: class extends Error {},
|
||
}));
|
||
|
||
vi.mock('../../../src/config', () => ({ JWT_SECRET: 'test-secret', ENCRYPTION_KEY: '0'.repeat(64) }));
|
||
|
||
const { mockTrekPlacesById } = vi.hoisted(() => ({
|
||
mockTrekPlacesById: vi.fn(async (_gers: string): Promise<unknown> => null),
|
||
}));
|
||
vi.mock('../../../src/nest/maps/trek-places.client', () => ({
|
||
trekPlacesById: mockTrekPlacesById,
|
||
}));
|
||
|
||
import { db } from '../../../src/db/database';
|
||
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||
import { PlaceEnrichmentService } from '../../../src/nest/place-enrichment/place-enrichment.service';
|
||
import type { MapsService } from '../../../src/nest/maps/maps.service';
|
||
import type { PlacePhotoCacheService } from '../../../src/nest/place-photos/place-photo-cache.service';
|
||
|
||
const GOOGLE_REQ = { lat: 53.6323, lng: 10.0067, name: 'Hamburg Airport', placeId: 'ChIJham', lang: 'de' };
|
||
const OSM_REQ = { lat: 52.525, lng: 13.3694, name: 'Berlin Hauptbahnhof', placeId: 'relation:3600565', lang: 'de' };
|
||
|
||
const EXTRACT = {
|
||
text: 'Der Berliner Hauptbahnhof ist der größte Kreuzungsbahnhof Europas.',
|
||
sourceUrl: 'https://de.wikipedia.org/wiki/Berlin_Hauptbahnhof',
|
||
source: 'wikipedia' as const,
|
||
};
|
||
|
||
function mapsStub(over: Partial<Record<keyof MapsService, unknown>> = {}) {
|
||
return {
|
||
getMapsKey: vi.fn(() => null as string | null),
|
||
photosDisabled: vi.fn(() => false),
|
||
detailsDisabled: vi.fn(() => false),
|
||
fetchGooglePhotoRefs: vi.fn(async () => []),
|
||
fetchGooglePhotoBytes: vi.fn(async () => null),
|
||
fetchCommonsCandidates: vi.fn(async () => []),
|
||
fetchCommonsCategoryCandidates: vi.fn(async () => []),
|
||
fetchWikidataCandidates: vi.fn(async () => ({ candidates: [], commonsCategory: null })),
|
||
fetchCommonsFilesByName: vi.fn(async () => new Map()),
|
||
fetchWikiLeadImageName: vi.fn(async () => null),
|
||
fetchEditorialSummary: vi.fn(async () => null as string | null),
|
||
fetchWikiExtract: vi.fn(async () => null as typeof EXTRACT | null),
|
||
fetchWikiExtractFor: vi.fn(async () => null as typeof EXTRACT | null),
|
||
fetchWikidataSitelinks: vi.fn(async () => ({}) as Record<string, string>),
|
||
resolveOsmIdentity: vi.fn(async () => null as { tags: Record<string, string>; osmUrl: string | null; matchedName: string } | null),
|
||
details: vi.fn(async () => ({ place: null })),
|
||
trekPlacesEnabled: vi.fn(() => true),
|
||
...over,
|
||
} as unknown as MapsService;
|
||
}
|
||
|
||
const cacheStub = () =>
|
||
({ get: vi.fn(() => null), put: vi.fn(async () => ({ photoUrl: '/x', filePath: '/x', attribution: null })) }) as unknown as PlacePhotoCacheService;
|
||
|
||
const make = (maps: MapsService) => new PlaceEnrichmentService(new DatabaseService(db as never), maps, cacheStub());
|
||
|
||
beforeEach(() => {
|
||
mockDbGet.mockReset();
|
||
mockDbGet.mockReturnValue(undefined);
|
||
mockDbRun.mockReset();
|
||
mockTrekPlacesById.mockReset();
|
||
mockTrekPlacesById.mockResolvedValue(null);
|
||
});
|
||
|
||
describe('identity resolution', () => {
|
||
it('ENRICH-070: looks up a Google place in OpenStreetMap, which is where its wiki tags live', async () => {
|
||
// Google's payload has no wikidata, no wikipedia and no wikimedia_commons —
|
||
// it never had. Before this, configuring an API key silently switched off
|
||
// the free half of the column.
|
||
const maps = mapsStub({
|
||
getMapsKey: vi.fn(() => 'key'),
|
||
details: vi.fn(async () => ({ place: { source: 'google', name: 'Hamburg Airport' } })),
|
||
resolveOsmIdentity: vi.fn(async () => ({
|
||
tags: { wikidata: 'Q27706', wikipedia: 'de:Flughafen Hamburg' },
|
||
osmUrl: 'https://www.openstreetmap.org/way/147122729',
|
||
matchedName: 'Hamburg Airport',
|
||
})),
|
||
fetchWikidataSitelinks: vi.fn(async () => ({ dewikivoyage: 'Hamburg-Fuhlsbüttel' })),
|
||
fetchWikiExtractFor: vi.fn(async () => ({ ...EXTRACT, source: 'wikivoyage' as const })),
|
||
});
|
||
|
||
const out = await make(maps).enrich(1, GOOGLE_REQ);
|
||
|
||
expect(maps.resolveOsmIdentity).toHaveBeenCalledWith('Hamburg Airport', 53.6323, 10.0067, { lang: 'de' });
|
||
expect(out.description).toMatchObject({ source: 'wikivoyage', license: 'CC BY-SA 4.0' });
|
||
});
|
||
|
||
it('ENRICH-071: does not spend a lookup when the payload already carries tags', async () => {
|
||
const maps = mapsStub({
|
||
details: vi.fn(async () => ({ place: { source: 'openstreetmap', wikidata: 'Q1097' } })),
|
||
fetchWikidataSitelinks: vi.fn(async () => ({ dewiki: 'Berlin Hauptbahnhof' })),
|
||
fetchWikiExtractFor: vi.fn(async () => EXTRACT),
|
||
});
|
||
|
||
await make(maps).enrich(1, OSM_REQ);
|
||
|
||
expect(maps.resolveOsmIdentity).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('ENRICH-072: never writes OSM tags into a Google payload, but still shows them', async () => {
|
||
// collectFacts and the OSM branch of collectDescription both gate on
|
||
// details.source. Merging the looked-up tags in would either be dead weight
|
||
// or would make a Google place claim to be an OpenStreetMap one.
|
||
const details = { source: 'google', name: 'Hamburg Airport', summary: null };
|
||
const maps = mapsStub({
|
||
getMapsKey: vi.fn(() => 'key'),
|
||
details: vi.fn(async () => ({ place: details })),
|
||
resolveOsmIdentity: vi.fn(async () => ({
|
||
tags: { wikidata: 'Q27706', cuisine: 'german' },
|
||
osmUrl: null,
|
||
matchedName: 'Hamburg Airport',
|
||
})),
|
||
});
|
||
|
||
const out = await make(maps).enrich(1, GOOGLE_REQ);
|
||
|
||
// The point is that the provider's own record is never written into. Its
|
||
// `source` is what collectFacts and the OSM branch of collectDescription
|
||
// gate on, so a Google payload carrying OSM tags would either ignore them
|
||
// or start claiming to be an OpenStreetMap place.
|
||
expect(details).toEqual({ source: 'google', name: 'Hamburg Airport', summary: null });
|
||
// The tags still reach the reader — through the second object, not by
|
||
// mutating the first. Google has no concept of cuisine, so without this a
|
||
// Google place showed a rating and nothing else.
|
||
expect(out.facts.some((f) => f.kind === 'cuisine')).toBe(true);
|
||
});
|
||
});
|
||
|
||
describe('description source order', () => {
|
||
it('ENRICH-073: prefers a free article over Google, even with a key configured', async () => {
|
||
// This is the whole point of the reordering: Google used to be asked first
|
||
// and answered for almost nothing, so the column stayed empty while a
|
||
// perfectly good article sat one call away.
|
||
const maps = mapsStub({
|
||
getMapsKey: vi.fn(() => 'key'),
|
||
details: vi.fn(async () => ({ place: { source: 'google', wikidata: 'Q1097' } })),
|
||
fetchWikidataSitelinks: vi.fn(async () => ({ dewiki: 'Berlin Hauptbahnhof' })),
|
||
fetchWikiExtractFor: vi.fn(async () => EXTRACT),
|
||
fetchEditorialSummary: vi.fn(async () => 'A large railway station.'),
|
||
});
|
||
|
||
const out = await make(maps).enrich(1, { ...OSM_REQ, placeId: 'ChIJhbf' });
|
||
|
||
expect(out.description).toMatchObject({ source: 'wikipedia' });
|
||
// Not merely lower priority — the billed call is never made.
|
||
expect(maps.fetchEditorialSummary).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('ENRICH-074: still falls back to Google when nothing free describes the place', async () => {
|
||
const maps = mapsStub({
|
||
getMapsKey: vi.fn(() => 'key'),
|
||
details: vi.fn(async () => ({ place: { source: 'google', google_maps_url: 'https://maps.google.com/x' } })),
|
||
fetchEditorialSummary: vi.fn(async () => 'Casual chain for wood-fired pizza.'),
|
||
});
|
||
|
||
const out = await make(maps).enrich(1, { ...GOOGLE_REQ, name: "L'Osteria Rostock" });
|
||
|
||
expect(out.description).toMatchObject({
|
||
source: 'google',
|
||
text: 'Casual chain for wood-fired pizza.',
|
||
sourceUrl: 'https://maps.google.com/x',
|
||
license: null,
|
||
});
|
||
});
|
||
|
||
it('ENRICH-120: links Google\'s summary only to a Google Maps address', async () => {
|
||
// The record and its link come with the request, and the answer is cached
|
||
// for every user of the instance under the label "Google". A link that is
|
||
// not a Google Maps address by shape is dropped; the text stays.
|
||
for (const bad of ['https://phish.example/maps', 'https://google.evil.example/x', 'javascript:alert(1)', 'maps.google.com/x']) {
|
||
const maps = mapsStub({
|
||
getMapsKey: vi.fn(() => 'key'),
|
||
fetchEditorialSummary: vi.fn(async () => 'Casual chain for wood-fired pizza.'),
|
||
});
|
||
const out = await make(maps).enrich(1, {
|
||
...GOOGLE_REQ,
|
||
details: { source: 'google', google_maps_url: bad },
|
||
});
|
||
expect(out.description, bad).toMatchObject({ source: 'google', text: 'Casual chain for wood-fired pizza.', sourceUrl: null });
|
||
}
|
||
|
||
for (const ok of ['https://maps.google.com/?cid=1', 'https://www.google.de/maps/place/x', 'https://maps.app.goo.gl/abc']) {
|
||
const maps = mapsStub({
|
||
getMapsKey: vi.fn(() => 'key'),
|
||
fetchEditorialSummary: vi.fn(async () => 'Casual chain for wood-fired pizza.'),
|
||
});
|
||
const out = await make(maps).enrich(1, {
|
||
...GOOGLE_REQ,
|
||
details: { source: 'google', google_maps_url: ok },
|
||
});
|
||
expect(out.description, ok).toMatchObject({ source: 'google', sourceUrl: ok });
|
||
}
|
||
});
|
||
|
||
it('ENRICH-075: keeps the OpenStreetMap description ahead of everything', async () => {
|
||
const maps = mapsStub({
|
||
details: vi.fn(async () => ({
|
||
place: { source: 'openstreetmap', summary: ' Ein Bahnhof. ', osm_url: 'https://osm.org/r/1', wikidata: 'Q1097' },
|
||
})),
|
||
fetchWikiExtractFor: vi.fn(async () => EXTRACT),
|
||
});
|
||
|
||
const out = await make(maps).enrich(1, OSM_REQ);
|
||
|
||
expect(out.description).toMatchObject({ text: 'Ein Bahnhof.', source: 'osm', license: 'ODbL 1.0' });
|
||
expect(maps.fetchWikidataSitelinks).not.toHaveBeenCalled();
|
||
});
|
||
});
|
||
|
||
describe('article language choice', () => {
|
||
it('ENRICH-076: asks for the reader\'s language before English', async () => {
|
||
const maps = mapsStub({
|
||
details: vi.fn(async () => ({ place: { source: 'openstreetmap', wikidata: 'Q1097' } })),
|
||
fetchWikidataSitelinks: vi.fn(async () => ({ dewiki: 'Berlin Hauptbahnhof', enwiki: 'Berlin Hauptbahnhof' })),
|
||
fetchWikiExtractFor: vi.fn(async () => EXTRACT),
|
||
});
|
||
|
||
await make(maps).enrich(1, OSM_REQ);
|
||
|
||
const sites = (maps.fetchWikidataSitelinks as unknown as { mock: { calls: [string, string[]][] } }).mock.calls[0][1];
|
||
expect(sites.slice(0, 2)).toEqual(['dewikivoyage', 'dewiki']);
|
||
expect(sites).toContain('enwiki');
|
||
expect(maps.fetchWikiExtractFor).toHaveBeenCalledWith('wikipedia', 'de', 'Berlin Hauptbahnhof');
|
||
});
|
||
|
||
it('ENRICH-077: puts Wikivoyage ahead of Wikipedia in the same language', async () => {
|
||
// Wikivoyage describes a place for somebody about to go there; Wikipedia
|
||
// opens with area in square kilometres.
|
||
const maps = mapsStub({
|
||
details: vi.fn(async () => ({ place: { source: 'openstreetmap', wikidata: 'Q1097' } })),
|
||
fetchWikidataSitelinks: vi.fn(async () => ({ dewikivoyage: 'Berlin', dewiki: 'Berlin Hauptbahnhof' })),
|
||
fetchWikiExtractFor: vi.fn(async () => ({ ...EXTRACT, source: 'wikivoyage' as const })),
|
||
});
|
||
|
||
await make(maps).enrich(1, OSM_REQ);
|
||
|
||
expect(maps.fetchWikiExtractFor).toHaveBeenNthCalledWith(1, 'wikivoyage', 'de', 'Berlin');
|
||
});
|
||
|
||
it('ENRICH-078: keeps the language the place itself named, ahead of English', async () => {
|
||
// A Korean reader looking at the Brandenburg Gate: no kowiki sitelink, but
|
||
// the OSM tag names a German article somebody curated for this place.
|
||
const maps = mapsStub({
|
||
details: vi.fn(async () => ({
|
||
place: { source: 'openstreetmap', wikidata: 'Q82425', wikipedia: 'de:Brandenburger Tor' },
|
||
})),
|
||
fetchWikidataSitelinks: vi.fn(async () => ({ dewiki: 'Brandenburger Tor', enwiki: 'Brandenburg Gate' })),
|
||
fetchWikiExtractFor: vi.fn(async () => EXTRACT),
|
||
});
|
||
|
||
await make(maps).enrich(1, { ...OSM_REQ, lang: 'ko' });
|
||
|
||
const sites = (maps.fetchWikidataSitelinks as unknown as { mock: { calls: [string, string[]][] } }).mock.calls[0][1];
|
||
expect(sites.indexOf('dewiki')).toBeLessThan(sites.indexOf('enwiki'));
|
||
});
|
||
|
||
it('ENRICH-079: falls back to the tag when the item has no usable sitelink', async () => {
|
||
const maps = mapsStub({
|
||
details: vi.fn(async () => ({
|
||
place: { source: 'openstreetmap', wikidata: 'Q1', wikipedia: 'de:Museum Ludwig' },
|
||
})),
|
||
fetchWikidataSitelinks: vi.fn(async () => ({})),
|
||
fetchWikiExtract: vi.fn(async () => EXTRACT),
|
||
});
|
||
|
||
const out = await make(maps).enrich(1, OSM_REQ);
|
||
|
||
expect(maps.fetchWikiExtract).toHaveBeenCalledWith('de:Museum Ludwig');
|
||
expect(out.description).toMatchObject({ source: 'wikipedia' });
|
||
});
|
||
|
||
it('ENRICH-080: tries the next site when one has no article', async () => {
|
||
const maps = mapsStub({
|
||
details: vi.fn(async () => ({ place: { source: 'openstreetmap', wikidata: 'Q1097' } })),
|
||
fetchWikidataSitelinks: vi.fn(async () => ({ dewikivoyage: 'Nowhere', dewiki: 'Berlin Hauptbahnhof' })),
|
||
fetchWikiExtractFor: vi
|
||
.fn()
|
||
.mockResolvedValueOnce(null)
|
||
.mockResolvedValueOnce(EXTRACT),
|
||
});
|
||
|
||
const out = await make(maps).enrich(1, OSM_REQ);
|
||
|
||
expect(maps.fetchWikiExtractFor).toHaveBeenCalledTimes(2);
|
||
expect(out.description).toMatchObject({ source: 'wikipedia' });
|
||
});
|
||
});
|
||
|
||
/**
|
||
* ENRICH-097..100 — topping a Google place up from OpenStreetMap.
|
||
*
|
||
* Google's payload has no cuisine, no wheelchair access, no outdoor seating and
|
||
* no menu link. It never had. So a Google place showed a rating and nothing
|
||
* else, while the same building in OSM carried all of it — and the lookup that
|
||
* finds its wiki tags brings those along at no extra cost.
|
||
*/
|
||
describe('filling Google gaps from the free sources', () => {
|
||
const OSM_TAGS = {
|
||
wikidata: 'Q1',
|
||
cuisine: 'pizza;italian',
|
||
wheelchair: 'limited',
|
||
outdoor_seating: 'yes',
|
||
'opening_hours': 'Mo-Su 11:30-23:00',
|
||
};
|
||
|
||
const googlePlaceAlsoInOsm = (over: Partial<Record<keyof MapsService, unknown>> = {}) =>
|
||
mapsStub({
|
||
getMapsKey: vi.fn(() => 'key'),
|
||
details: vi.fn(async () => ({ place: { source: 'google', rating: 4.6, rating_count: 4495 } })),
|
||
resolveOsmIdentity: vi.fn(async () => ({ tags: OSM_TAGS, osmUrl: null, matchedName: "L'Osteria" })),
|
||
...over,
|
||
});
|
||
|
||
it('ENRICH-097: shows the OSM facts a Google place has no concept of', async () => {
|
||
const out = await make(googlePlaceAlsoInOsm()).enrich(1, GOOGLE_REQ);
|
||
|
||
const kinds = out.facts.map((f) => f.kind);
|
||
expect(kinds).toContain('cuisine');
|
||
expect(kinds).toContain('wheelchair');
|
||
expect(kinds).toContain('outdoorSeating');
|
||
// Semicolons and underscores are OSM syntax, not something to show a reader.
|
||
expect(out.facts.find((f) => f.kind === 'cuisine')?.value).toBe('pizza, italian');
|
||
});
|
||
|
||
it('ENRICH-098: keeps the rating Google reported', async () => {
|
||
const out = await make(googlePlaceAlsoInOsm()).enrich(1, GOOGLE_REQ);
|
||
expect(out.rating).toEqual({ value: 4.6, count: 4495 });
|
||
});
|
||
|
||
it('ENRICH-099: takes opening hours from OSM when Google reported none', async () => {
|
||
const out = await make(googlePlaceAlsoInOsm()).enrich(1, GOOGLE_REQ);
|
||
expect(out.hours?.weekdayDescriptions[0]).toContain('11:30-23:00');
|
||
});
|
||
|
||
it('ENRICH-100: the provider wins where both know the same thing', async () => {
|
||
// Google's hours are the ones the user searched against and the ones more
|
||
// likely to be current for a business.
|
||
const maps = googlePlaceAlsoInOsm({
|
||
details: vi.fn(async () => ({
|
||
place: { source: 'google', opening_hours: ['Monday: 09:00-17:00'], rating: 4.6, rating_count: 10 },
|
||
})),
|
||
});
|
||
|
||
const out = await make(maps).enrich(1, GOOGLE_REQ);
|
||
|
||
expect(out.hours?.weekdayDescriptions).toEqual(['Monday: 09:00-17:00']);
|
||
});
|
||
})
|
||
|
||
|
||
describe("the description on the place's own website", () => {
|
||
// The ordinary case, and the one nothing covered before: a restaurant has no
|
||
// encyclopaedia article and never will, but it does publish a summary of
|
||
// itself in JSON-LD for machines to read. 43 percent of Overture places carry
|
||
// a website; a fraction of a percent carry a wiki tag.
|
||
const GERS_REQ = {
|
||
lat: 54.0879,
|
||
lng: 12.1408,
|
||
name: "L'Osteria",
|
||
placeId: 'gers:abc-123',
|
||
lang: 'de',
|
||
};
|
||
|
||
it('ENRICH-100: asks the index for the description, not the request that carries one', async () => {
|
||
// The dialog's own lookup fetched this place a moment ago and the request
|
||
// carries its answer, description included. It is still not read from
|
||
// there: the result lands in a cache the whole instance reads from for a
|
||
// week, and the text and the link in it have to be the index's, not
|
||
// whatever the sender put in the body. The cached row is the proof.
|
||
mockTrekPlacesById.mockClear();
|
||
mockTrekPlacesById.mockResolvedValue({
|
||
description: { text: 'Pizza in Rostock, seit 2015.', sourceUrl: 'https://losteria.net/rostock' },
|
||
});
|
||
|
||
const out = await make(mapsStub()).enrich(1, {
|
||
...GERS_REQ,
|
||
details: {
|
||
source: 'trek-places',
|
||
description: { text: 'Buchung nur noch über https://phish.example', sourceUrl: 'https://phish.example' },
|
||
},
|
||
});
|
||
|
||
expect(mockTrekPlacesById).toHaveBeenCalledWith('abc-123');
|
||
expect(out.description).toMatchObject({
|
||
text: 'Pizza in Rostock, seit 2015.',
|
||
source: 'website',
|
||
sourceUrl: 'https://losteria.net/rostock',
|
||
});
|
||
const written = mockDbRun.mock.calls.find(([sql]) => String(sql).includes('INSERT OR REPLACE INTO place_details_cache'));
|
||
expect(written).toBeTruthy();
|
||
expect(String(written![4])).not.toContain('phish.example');
|
||
});
|
||
|
||
it('ENRICH-101: asks when the caller passed no details, all the same', async () => {
|
||
mockTrekPlacesById.mockClear();
|
||
mockTrekPlacesById.mockResolvedValue({
|
||
description: { text: 'Pizza in Rostock, seit 2015.', sourceUrl: 'https://losteria.net/rostock' },
|
||
});
|
||
|
||
const out = await make(mapsStub()).enrich(1, GERS_REQ);
|
||
|
||
expect(out.description).toMatchObject({ source: 'website' });
|
||
expect(mockTrekPlacesById).toHaveBeenCalledWith('abc-123');
|
||
});
|
||
|
||
it('ENRICH-102: a source URL that is not http(s) loses the link, not the text', async () => {
|
||
// It becomes an href on the client, and the value comes from whatever index
|
||
// the instance is pointed at. The same allow-list a place's website goes
|
||
// through, for the same reason.
|
||
for (const bad of ['javascript:alert(1)', 'data:text/html,<script>', 'file:///etc/passwd', 'not a url']) {
|
||
mockTrekPlacesById.mockResolvedValue({ description: { text: 'Pizza in Rostock.', sourceUrl: bad } });
|
||
const out = await make(mapsStub()).enrich(1, GERS_REQ);
|
||
expect(out.description, bad).toMatchObject({ text: 'Pizza in Rostock.', sourceUrl: null });
|
||
}
|
||
|
||
// The ordinary case still keeps its link.
|
||
mockTrekPlacesById.mockResolvedValue({
|
||
description: { text: 'Pizza in Rostock.', sourceUrl: 'https://losteria.net/rostock' },
|
||
});
|
||
const ok = await make(mapsStub()).enrich(1, GERS_REQ);
|
||
expect(ok.description).toMatchObject({ sourceUrl: 'https://losteria.net/rostock' });
|
||
});
|
||
|
||
it('ENRICH-117: a description the request made up never reaches the shared cache', async () => {
|
||
// The index knows no description for this place. The request claims one,
|
||
// with a link. Nobody gets to see it, the sender included: the website
|
||
// rung reads the index and nothing else.
|
||
mockTrekPlacesById.mockClear();
|
||
mockTrekPlacesById.mockResolvedValue({ description: null });
|
||
|
||
const out = await make(mapsStub()).enrich(1, {
|
||
...GERS_REQ,
|
||
details: {
|
||
source: 'trek-places',
|
||
description: { text: 'Buchung nur noch über https://phish.example', sourceUrl: 'https://phish.example' },
|
||
},
|
||
});
|
||
|
||
expect(out.description).toBeNull();
|
||
for (const [sql, ...params] of mockDbRun.mock.calls) {
|
||
expect(JSON.stringify(params), String(sql)).not.toContain('phish.example');
|
||
}
|
||
});
|
||
|
||
it('ENRICH-118: an OpenStreetMap summary the request carried is answered, but not cached for everyone', async () => {
|
||
// The OSM rung reads `summary` and `osm_url` straight off the details, and
|
||
// the details came with the request. Answering the sender with their own
|
||
// payload is harmless; writing it into the row every other user of the
|
||
// instance reads for a week is not. The row simply is not written.
|
||
const carried = {
|
||
...OSM_REQ,
|
||
details: {
|
||
source: 'openstreetmap',
|
||
summary: 'Buchung nur noch über https://phish.example',
|
||
osm_url: 'https://phish.example',
|
||
},
|
||
};
|
||
|
||
const out = await make(mapsStub()).enrich(1, carried);
|
||
|
||
expect(out.description).toMatchObject({ source: 'osm', text: 'Buchung nur noch über https://phish.example' });
|
||
expect(mockDbRun.mock.calls.some(([sql]) => String(sql).includes('place_details_cache'))).toBe(false);
|
||
|
||
// The same summary from the service's own lookup is the map's, and keeps.
|
||
const maps = mapsStub({
|
||
details: vi.fn(async () => ({
|
||
place: { source: 'openstreetmap', summary: 'Größter Kreuzungsbahnhof Europas.', osm_url: 'https://www.openstreetmap.org/relation/3600565' },
|
||
})),
|
||
});
|
||
mockDbRun.mockClear();
|
||
const own = await make(maps).enrich(1, OSM_REQ);
|
||
expect(own.description).toMatchObject({ source: 'osm', text: 'Größter Kreuzungsbahnhof Europas.' });
|
||
expect(mockDbRun.mock.calls.some(([sql]) => String(sql).includes('INSERT OR REPLACE INTO place_details_cache'))).toBe(true);
|
||
});
|
||
|
||
it('ENRICH-121: a menu link the request carried is answered, but not cached for everyone', async () => {
|
||
// The menu chip is the other link read straight off the details: it goes
|
||
// out as an href under the label "Menu", and a request can put any http(s)
|
||
// address there. Same treatment as the summary: the sender sees it, the
|
||
// row is not written.
|
||
const out = await make(mapsStub()).enrich(1, {
|
||
...OSM_REQ,
|
||
details: { source: 'openstreetmap', menu_url: 'https://phish.example/menu' },
|
||
});
|
||
|
||
expect(out.facts).toContainEqual({ kind: 'menu', value: null, url: 'https://phish.example/menu' });
|
||
expect(mockDbRun.mock.calls.some(([sql]) => String(sql).includes('place_details_cache'))).toBe(false);
|
||
|
||
// The same link from the service's own lookup is the map's, and keeps.
|
||
const maps = mapsStub({
|
||
details: vi.fn(async () => ({ place: { source: 'openstreetmap', menu_url: 'https://example.org/karte' } })),
|
||
});
|
||
mockDbRun.mockClear();
|
||
const own = await make(maps).enrich(1, OSM_REQ);
|
||
expect(own.facts).toContainEqual({ kind: 'menu', value: null, url: 'https://example.org/karte' });
|
||
expect(mockDbRun.mock.calls.some(([sql]) => String(sql).includes('INSERT OR REPLACE INTO place_details_cache'))).toBe(true);
|
||
});
|
||
|
||
it('ENRICH-119: with the index switched off, no lookup leaves for the description', async () => {
|
||
// TREK_PLACES_ENABLED=false is a promise about egress, and a saved index
|
||
// place opened later is still an index place.
|
||
mockTrekPlacesById.mockClear();
|
||
mockTrekPlacesById.mockResolvedValue({
|
||
description: { text: 'Pizza in Rostock, seit 2015.', sourceUrl: 'https://losteria.net/rostock' },
|
||
});
|
||
|
||
const out = await make(mapsStub({ trekPlacesEnabled: vi.fn(() => false) })).enrich(1, GERS_REQ);
|
||
|
||
expect(mockTrekPlacesById).not.toHaveBeenCalled();
|
||
expect(out.description).toBeNull();
|
||
});
|
||
|
||
it('ENRICH-090: quotes the site and credits it by URL', async () => {
|
||
mockTrekPlacesById.mockResolvedValue({
|
||
description: { text: 'Pizza in Rostock, seit 2015.', sourceUrl: 'https://losteria.net/rostock' },
|
||
});
|
||
|
||
const out = await make(mapsStub()).enrich(1, GERS_REQ);
|
||
|
||
expect(mockTrekPlacesById).toHaveBeenCalledWith('abc-123');
|
||
expect(out.description).toEqual({
|
||
text: 'Pizza in Rostock, seit 2015.',
|
||
source: 'website',
|
||
sourceUrl: 'https://losteria.net/rostock',
|
||
// No licence claim: the operator published a summary for machines, they
|
||
// did not grant terms. Naming and linking the source is what is owed.
|
||
license: null,
|
||
});
|
||
});
|
||
|
||
it('ENRICH-091: leaves the encyclopaedias ahead of it', async () => {
|
||
// An article about this exact place, written by someone with no stake in
|
||
// it, beats the operator's own copy. The website only fills the gap the
|
||
// encyclopaedias leave, which is almost every business.
|
||
mockTrekPlacesById.mockResolvedValue({ description: { text: 'Unser Restaurant.', sourceUrl: 'https://x.de' } });
|
||
const maps = mapsStub({
|
||
details: vi.fn(async () => ({ place: { source: 'openstreetmap', wikipedia: 'de:Irgendwas' } })),
|
||
fetchWikiExtract: vi.fn(async () => EXTRACT),
|
||
});
|
||
|
||
const out = await make(maps).enrich(1, GERS_REQ);
|
||
|
||
expect(out.description?.source).toBe('wikipedia');
|
||
});
|
||
|
||
it('ENRICH-092: asks only for places that came from the API', async () => {
|
||
await make(mapsStub()).enrich(1, { ...GERS_REQ, placeId: 'ChIJsomethinggoogle' });
|
||
expect(mockTrekPlacesById).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('ENRICH-093: a failing API costs the place nothing else', async () => {
|
||
mockTrekPlacesById.mockRejectedValue(new Error('places api down'));
|
||
const maps = mapsStub({
|
||
details: vi.fn(async () => ({ place: { source: 'openstreetmap', name: "L'Osteria" } })),
|
||
});
|
||
|
||
await expect(make(maps).enrich(1, GERS_REQ)).resolves.toMatchObject({ description: null });
|
||
});
|
||
|
||
it('ENRICH-094: ignores an empty or whitespace-only summary', async () => {
|
||
mockTrekPlacesById.mockResolvedValue({ description: { text: ' ', sourceUrl: 'https://x.de' } });
|
||
const out = await make(mapsStub()).enrich(1, GERS_REQ);
|
||
expect(out.description).toBeNull();
|
||
});
|
||
|
||
it('ENRICH-101: a place the index knows, whose site publishes no summary', async () => {
|
||
// The common answer, not an error: the GERS id resolves, and the place has
|
||
// no description field at all because its page carried nothing readable.
|
||
mockTrekPlacesById.mockResolvedValue({ gers: 'abc-123', name: "L'Osteria" });
|
||
|
||
const out = await make(mapsStub()).enrich(1, GERS_REQ);
|
||
|
||
expect(mockTrekPlacesById).toHaveBeenCalledWith('abc-123');
|
||
expect(out.description).toBeNull();
|
||
});
|
||
|
||
it('ENRICH-102: still quotes a summary the API could not name a page for', async () => {
|
||
// The link is the credit, not the content. Dropping the text for want of a
|
||
// footnote would lose the only description the place has. The field is
|
||
// absent rather than null, so `sourceUrl` below is the normalisation and
|
||
// not the fixture read back.
|
||
mockTrekPlacesById.mockResolvedValue({ description: { text: 'Pizza in Rostock.' } });
|
||
|
||
const out = await make(mapsStub()).enrich(1, GERS_REQ);
|
||
|
||
expect(out.description).toEqual({
|
||
text: 'Pizza in Rostock.',
|
||
source: 'website',
|
||
sourceUrl: null,
|
||
license: null,
|
||
});
|
||
});
|
||
|
||
it("ENRICH-103: the branch's own words come before the chain's article", async () => {
|
||
// L'Osteria Rostock carries brand:wikidata and has no article of its own.
|
||
// The chain article is about the company; the site is about this branch,
|
||
// and the branch is what was asked about.
|
||
mockTrekPlacesById.mockResolvedValue({
|
||
description: { text: 'Unsere Filiale in der Steinstraße.', sourceUrl: 'https://losteria.net/rostock' },
|
||
});
|
||
const maps = mapsStub({
|
||
resolveOsmIdentity: vi.fn(async () => ({
|
||
tags: { 'brand:wikidata': 'Q17323478', 'brand:wikipedia': 'de:L’Osteria' },
|
||
osmUrl: null,
|
||
matchedName: "L'Osteria",
|
||
})),
|
||
fetchWikiExtractFor: vi.fn(async () => EXTRACT),
|
||
});
|
||
|
||
const out = await make(maps).enrich(1, GERS_REQ);
|
||
|
||
expect(out.description).toMatchObject({ source: 'website', text: 'Unsere Filiale in der Steinstraße.' });
|
||
// The chain lookup starts at the brand's Wikidata item — it never ran.
|
||
expect(maps.fetchWikidataSitelinks).not.toHaveBeenCalled();
|
||
});
|
||
});
|