mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
3e0bfbbb29
Several of the fixes in this branch landed on a mobile screen while the desktop component with the identical bug sat in another package and was left alone, which leaves the two disagreeing about the same handler. So: the appearance tab flushes its debounced write on unmount, the journey share copy guards navigator.clipboard (undefined on a plain-HTTP self-hosted install), the admin collab toggle rolls back from a scoped snapshot instead of a stale one, the reservation modal parses metadata through the guarded helper rather than a bare JSON.parse that takes the route down, and the notification toggle surfaces its failures and stops racing concurrent toggles. The Journey components reached the memories provider-status endpoint with a raw fetch(), past every apiClient interceptor; they go through a small memoriesApi now. And the server half of the OSM menu_url finding: the client renders it through safeHttpUrl, so place-enrichment should not be handing a javascript: value out in the first place. The share section hides itself when the server says the link is not this user's to manage, rather than offering a create button that 403s.
999 lines
42 KiB
TypeScript
999 lines
42 KiB
TypeScript
/**
|
|
* Unit tests for PlaceEnrichmentService — ENRICH-001 through ENRICH-030.
|
|
*
|
|
* The service is a fan-out over MapsService primitives plus the shared photo
|
|
* cache, so both are injected as stubs and every provider branch is driven from
|
|
* the test. The SSRF guard is mocked the same way maps.service.test.ts mocks it.
|
|
*/
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
|
|
/** Whatever the statement under test selects: a settings row, a cache row, or nothing. */
|
|
type DbRow = Record<string, unknown> | undefined;
|
|
|
|
const { mockDbGet, mockDbRun, mockSafeFetchFollow } = vi.hoisted(() => ({
|
|
// Signature taken from the seam below rather than from this default: the
|
|
// statement text is folded in ahead of the params, so a no-argument spy
|
|
// would reject both the call site and every mockImplementation here.
|
|
mockDbGet: vi.fn((_sql: string, ..._params: unknown[]): DbRow => undefined),
|
|
mockDbRun: vi.fn((_sql: string, ..._params: unknown[]) => {}),
|
|
mockSafeFetchFollow: vi.fn(async () => ({ ok: true, arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer })),
|
|
}));
|
|
|
|
// prepare() takes the SQL and the statement takes the params, so the spies get
|
|
// the statement text folded back in as their first argument — the assertions
|
|
// below distinguish the settings read from the cache read by exactly that.
|
|
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: mockSafeFetchFollow,
|
|
checkSsrf: vi.fn(async () => ({ allowed: true })),
|
|
SsrfBlockedError: class extends Error {},
|
|
}));
|
|
|
|
vi.mock('../../../src/config', () => ({ JWT_SECRET: 'test-secret', ENCRYPTION_KEY: '0'.repeat(64) }));
|
|
|
|
import { db } from '../../../src/db/database';
|
|
import { DatabaseService } from '../../../src/nest/database/database.service';
|
|
import {
|
|
candidateKey,
|
|
PlaceEnrichmentService,
|
|
creditLine,
|
|
collectFacts,
|
|
collectHours,
|
|
collectRating,
|
|
} 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 REQ = { lat: 50.9, lng: 6.96, name: 'Museum Ludwig', placeId: 'ChIJmuseum' };
|
|
const OSM_REQ = { ...REQ, placeId: 'way:12345' };
|
|
|
|
/** Every MapsService seam the service touches, all inert by default. */
|
|
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 () => [] as { name: string; attribution: string | null }[]),
|
|
fetchGooglePhotoBytes: vi.fn(async () => null as Buffer | null),
|
|
fetchCommonsCandidates: vi.fn(async () => [] as any[]),
|
|
fetchEditorialSummary: vi.fn(async () => null as string | null),
|
|
fetchWikiExtract: vi.fn(async () => null as { text: string; sourceUrl: string; source: 'wikivoyage' | 'wikipedia' } | null),
|
|
fetchCommonsCategoryCandidates: vi.fn(async () => [] as any[]),
|
|
fetchWikidataCandidates: vi.fn(async () => ({ candidates: [] as any[], commonsCategory: null as string | null })),
|
|
fetchCommonsFilesByName: vi.fn(async () => new Map<string, any>()),
|
|
fetchWikiLeadImageName: vi.fn(async () => null as string | null),
|
|
resolveOsmIdentity: vi.fn(async () => null as { tags: Record<string, string>; osmUrl: string | null; matchedName: string } | null),
|
|
fetchWikidataSitelinks: vi.fn(async () => ({}) as Record<string, string>),
|
|
fetchWikiExtractFor: vi.fn(async () => null as { text: string; sourceUrl: string; source: 'wikivoyage' | 'wikipedia' } | null),
|
|
details: vi.fn(async () => ({ place: null })),
|
|
...over,
|
|
} as unknown as MapsService;
|
|
}
|
|
|
|
function cacheStub(over: Record<string, unknown> = {}) {
|
|
return {
|
|
get: vi.fn(() => null as any),
|
|
put: vi.fn(async (key: string, _bytes: Buffer, attribution: string | null) => ({
|
|
photoUrl: `/api/maps/place-photo/${encodeURIComponent(key)}/bytes`,
|
|
filePath: `/tmp/${key}.jpg`,
|
|
attribution,
|
|
})),
|
|
...over,
|
|
} as unknown as PlacePhotoCacheService;
|
|
}
|
|
|
|
function make(maps: MapsService, cache: PlacePhotoCacheService) {
|
|
return new PlaceEnrichmentService(new DatabaseService(db as never), maps, cache);
|
|
}
|
|
|
|
let candidateSeq = 0;
|
|
const commonsCandidate = (over: Record<string, unknown> = {}) => {
|
|
// Distinct by default: the ranker deduplicates by page id and collapses
|
|
// same-stem titles, so fixtures that share either would silently collapse
|
|
// into one and make a test about ordering pass for the wrong reason.
|
|
const n = ++candidateSeq;
|
|
return {
|
|
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',
|
|
pageId: 1000 + n,
|
|
title: `File:Subject ${String.fromCharCode(96 + n)}.jpg`,
|
|
width: 1600,
|
|
height: 1200,
|
|
descriptors: null,
|
|
...over,
|
|
};
|
|
};
|
|
|
|
beforeEach(() => {
|
|
// Per test, so a fixture's page id is a property of the case rather than of
|
|
// where the case sits in the file.
|
|
candidateSeq = 0;
|
|
mockDbGet.mockReset();
|
|
mockDbGet.mockReturnValue(undefined);
|
|
mockDbRun.mockReset();
|
|
mockSafeFetchFollow.mockReset();
|
|
mockSafeFetchFollow.mockResolvedValue({ ok: true, arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer });
|
|
});
|
|
|
|
// ── Kill switch ──────────────────────────────────────────────────────────────
|
|
|
|
describe('enrichDisabled', () => {
|
|
it('ENRICH-001: is off only when the setting is literally "false"', () => {
|
|
const svc = make(mapsStub(), cacheStub());
|
|
|
|
mockDbGet.mockReturnValue(undefined);
|
|
expect(svc.enrichDisabled()).toBe(false); // never configured — fail open
|
|
|
|
mockDbGet.mockReturnValue({ value: 'true' });
|
|
expect(svc.enrichDisabled()).toBe(false);
|
|
|
|
mockDbGet.mockReturnValue({ value: 'false' });
|
|
expect(svc.enrichDisabled()).toBe(true);
|
|
});
|
|
|
|
it('ENRICH-002: answers the disabled envelope without touching a provider', async () => {
|
|
const maps = mapsStub();
|
|
mockDbGet.mockReturnValue({ value: 'false' });
|
|
|
|
const out = await make(maps, cacheStub()).enrich(1, REQ);
|
|
|
|
expect(out).toEqual({ photos: [], description: null, facts: [], disabled: true });
|
|
expect(maps.fetchCommonsCandidates).not.toHaveBeenCalled();
|
|
expect(maps.details).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
// ── Photos ───────────────────────────────────────────────────────────────────
|
|
|
|
describe('collectPhotos', () => {
|
|
it('ENRICH-003: returns Commons candidates with their licence intact', async () => {
|
|
const maps = mapsStub({ fetchCommonsCandidates: vi.fn(async () => [commonsCandidate()]) });
|
|
|
|
const out = await make(maps, cacheStub()).enrich(1, REQ);
|
|
|
|
// Keyed by which file it is, not by where it landed in the strip.
|
|
const key = candidateKey('ChIJmuseum', 'commons:1001');
|
|
expect(out.photos).toEqual([
|
|
{
|
|
key,
|
|
url: `/api/maps/place-photo/${encodeURIComponent(key)}/bytes`,
|
|
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',
|
|
source: 'wikimedia',
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('ENRICH-004: works without a Google key, which is the normal instance', async () => {
|
|
const maps = mapsStub({
|
|
getMapsKey: vi.fn(() => null),
|
|
fetchCommonsCandidates: vi.fn(async () => [commonsCandidate()]),
|
|
});
|
|
|
|
const out = await make(maps, cacheStub()).enrich(1, REQ);
|
|
|
|
expect(out.photos).toHaveLength(1);
|
|
expect(maps.fetchGooglePhotoRefs).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('ENRICH-005: puts Google pictures ahead of Commons when a key is configured', async () => {
|
|
const maps = mapsStub({
|
|
getMapsKey: vi.fn(() => 'key'),
|
|
fetchGooglePhotoRefs: vi.fn(async () => [{ name: 'places/p/photos/a', attribution: 'Bob' }]),
|
|
fetchGooglePhotoBytes: vi.fn(async () => Buffer.from([1, 2, 3])),
|
|
fetchCommonsCandidates: vi.fn(async () => [commonsCandidate()]),
|
|
});
|
|
|
|
const out = await make(maps, cacheStub()).enrich(1, REQ);
|
|
|
|
expect(out.photos.map((p) => p.source)).toEqual(['google', 'wikimedia']);
|
|
expect(out.photos[0]).toMatchObject({
|
|
key: candidateKey('ChIJmuseum', 'google:places/p/photos/a'),
|
|
attribution: 'Bob',
|
|
// Google grants no reusable licence — the fields stay empty rather than invented.
|
|
license: null,
|
|
licenseUrl: null,
|
|
sourceUrl: null,
|
|
});
|
|
expect(out.photos[1].key).toBe(candidateKey('ChIJmuseum', 'commons:1001'));
|
|
});
|
|
|
|
it('ENRICH-006: respects the existing photos kill switch for the Google half', async () => {
|
|
const maps = mapsStub({
|
|
getMapsKey: vi.fn(() => 'key'),
|
|
photosDisabled: vi.fn(() => true),
|
|
fetchCommonsCandidates: vi.fn(async () => [commonsCandidate()]),
|
|
});
|
|
|
|
const out = await make(maps, cacheStub()).enrich(1, REQ);
|
|
|
|
expect(maps.fetchGooglePhotoRefs).not.toHaveBeenCalled();
|
|
expect(out.photos.map((p) => p.source)).toEqual(['wikimedia']);
|
|
});
|
|
|
|
it('ENRICH-007: never asks Google about an OSM place', async () => {
|
|
const maps = mapsStub({ getMapsKey: vi.fn(() => 'key') });
|
|
|
|
await make(maps, cacheStub()).enrich(1, OSM_REQ);
|
|
|
|
expect(maps.fetchGooglePhotoRefs).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('ENRICH-008: reuses a cached candidate instead of downloading it again', async () => {
|
|
const maps = mapsStub({ fetchCommonsCandidates: vi.fn(async () => [commonsCandidate()]) });
|
|
const cache = cacheStub({
|
|
get: vi.fn(() => ({ photoUrl: '/api/maps/place-photo/ChIJmuseum~p0/bytes', filePath: '/tmp/x', attribution: 'Alice' })),
|
|
});
|
|
|
|
const out = await make(maps, cache).enrich(1, REQ);
|
|
|
|
expect(out.photos).toHaveLength(1);
|
|
expect(mockSafeFetchFollow).not.toHaveBeenCalled();
|
|
expect(cache.put).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('ENRICH-009: skips a candidate whose bytes will not download', async () => {
|
|
const maps = mapsStub({
|
|
fetchCommonsCandidates: vi.fn(async () => [
|
|
commonsCandidate({ photoUrl: 'https://commons.org/gone.jpg', attribution: 'Alice' }),
|
|
commonsCandidate({ attribution: 'Bob' }),
|
|
]),
|
|
});
|
|
mockSafeFetchFollow
|
|
.mockResolvedValueOnce({ ok: false, arrayBuffer: async () => new ArrayBuffer(0) })
|
|
.mockResolvedValueOnce({ ok: true, arrayBuffer: async () => new Uint8Array([9]).buffer });
|
|
|
|
const out = await make(maps, cacheStub()).enrich(1, REQ);
|
|
|
|
// The survivor keeps its own key. That used to depend on the failed
|
|
// picture leaving a numbered gap behind it; now the key is derived from
|
|
// which file it is, so nothing in front of it can shift it.
|
|
expect(out.photos).toHaveLength(1);
|
|
expect(out.photos[0].key).toBe(candidateKey('ChIJmuseum', 'commons:1002'));
|
|
});
|
|
|
|
it('ENRICH-010: treats an empty body and a throwing download as a miss', async () => {
|
|
const maps = mapsStub({ fetchCommonsCandidates: vi.fn(async () => [commonsCandidate()]) });
|
|
|
|
mockSafeFetchFollow.mockResolvedValue({ ok: true, arrayBuffer: async () => new ArrayBuffer(0) });
|
|
expect((await make(maps, cacheStub()).enrich(1, REQ)).photos).toEqual([]);
|
|
|
|
mockSafeFetchFollow.mockRejectedValue(new Error('network'));
|
|
expect((await make(maps, cacheStub()).enrich(1, REQ)).photos).toEqual([]);
|
|
});
|
|
|
|
it('ENRICH-011: drops a candidate the cache refuses to store', async () => {
|
|
const maps = mapsStub({ fetchCommonsCandidates: vi.fn(async () => [commonsCandidate()]) });
|
|
const cache = cacheStub({ put: vi.fn(async () => { throw new Error('disk full'); }) });
|
|
const err = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
|
|
const out = await make(maps, cache).enrich(1, REQ);
|
|
|
|
expect(out.photos).toEqual([]);
|
|
expect(err).toHaveBeenCalled();
|
|
err.mockRestore();
|
|
});
|
|
|
|
it('ENRICH-012: asks each source only for as many pictures as it may contribute', async () => {
|
|
const maps = mapsStub({
|
|
getMapsKey: vi.fn(() => 'key'),
|
|
fetchGooglePhotoBytes: vi.fn(async () => Buffer.from([1])),
|
|
});
|
|
|
|
await make(maps, cacheStub()).enrich(1, REQ);
|
|
|
|
expect(maps.fetchGooglePhotoRefs).toHaveBeenCalledWith('ChIJmuseum', 'key', 3);
|
|
expect(maps.fetchCommonsCandidates).toHaveBeenCalledWith(REQ.lat, REQ.lng, 5);
|
|
});
|
|
|
|
it('ENRICH-012b: skips a Google reference whose media download comes back empty', async () => {
|
|
const maps = mapsStub({
|
|
getMapsKey: vi.fn(() => 'key'),
|
|
fetchGooglePhotoRefs: vi.fn(async () => [
|
|
{ name: 'places/p/photos/gone', attribution: 'Bob' },
|
|
{ name: 'places/p/photos/ok', attribution: 'Cara' },
|
|
]),
|
|
fetchGooglePhotoBytes: vi
|
|
.fn()
|
|
.mockResolvedValueOnce(null)
|
|
.mockResolvedValueOnce(Buffer.from([1, 2, 3])),
|
|
});
|
|
|
|
const out = await make(maps, cacheStub()).enrich(1, REQ);
|
|
|
|
expect(out.photos).toHaveLength(1);
|
|
expect(out.photos[0]).toMatchObject({
|
|
key: candidateKey('ChIJmuseum', 'google:places/p/photos/ok'),
|
|
attribution: 'Cara',
|
|
});
|
|
});
|
|
|
|
it('ENRICH-012c: downloads the pictures concurrently, not one after another', async () => {
|
|
// Five sequential round trips plus five decodes pushed the request past the
|
|
// client's timeout, which is what made the column show an error every time.
|
|
let active = 0;
|
|
let peak = 0;
|
|
const maps = mapsStub({
|
|
fetchCommonsCandidates: vi.fn(async () =>
|
|
['Alice', 'Bob', 'Cara'].map((attribution) => commonsCandidate({ attribution })),
|
|
),
|
|
});
|
|
mockSafeFetchFollow.mockImplementation(async () => {
|
|
active++;
|
|
peak = Math.max(peak, active);
|
|
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
active--;
|
|
return { ok: true, arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer };
|
|
});
|
|
|
|
const out = await make(maps, cacheStub()).enrich(1, REQ);
|
|
|
|
expect(out.photos).toHaveLength(3);
|
|
expect(peak).toBeGreaterThan(1);
|
|
});
|
|
|
|
it('ENRICH-012d: asks both providers at the same time', async () => {
|
|
const order: string[] = [];
|
|
const maps = mapsStub({
|
|
getMapsKey: vi.fn(() => 'key'),
|
|
fetchGooglePhotoRefs: vi.fn(async () => {
|
|
order.push('google:start');
|
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
order.push('google:end');
|
|
return [];
|
|
}),
|
|
fetchCommonsCandidates: vi.fn(async () => {
|
|
order.push('commons:start');
|
|
return [];
|
|
}),
|
|
});
|
|
|
|
await make(maps, cacheStub()).enrich(1, REQ);
|
|
|
|
// Commons starts before Google finishes; the listings do not queue.
|
|
expect(order.indexOf('commons:start')).toBeLessThan(order.indexOf('google:end'));
|
|
});
|
|
|
|
it('ENRICH-013: falls back to a coordinate pseudo-id when the place has none', async () => {
|
|
const maps = mapsStub({ fetchCommonsCandidates: vi.fn(async () => [commonsCandidate()]) });
|
|
|
|
const out = await make(maps, cacheStub()).enrich(1, { lat: 50.9, lng: 6.96, name: 'Bench' });
|
|
|
|
expect(out.photos[0].key).toBe(candidateKey('coords:50.9:6.96', 'commons:1001'));
|
|
expect(maps.fetchGooglePhotoRefs).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
// ── Description ──────────────────────────────────────────────────────────────
|
|
|
|
describe('collectDescription', () => {
|
|
it('ENRICH-014: prefers the OpenStreetMap description, which costs nothing', async () => {
|
|
const maps = mapsStub({
|
|
getMapsKey: vi.fn(() => 'key'),
|
|
details: vi.fn(async () => ({
|
|
place: { summary: ' Ein Museum in Köln. ', source: 'openstreetmap', osm_url: 'https://osm.org/way/1' },
|
|
})),
|
|
});
|
|
|
|
const out = await make(maps, cacheStub()).enrich(1, OSM_REQ);
|
|
|
|
expect(out.description).toEqual({
|
|
text: 'Ein Museum in Köln.',
|
|
source: 'osm',
|
|
sourceUrl: 'https://osm.org/way/1',
|
|
license: 'ODbL 1.0',
|
|
});
|
|
expect(maps.fetchEditorialSummary).not.toHaveBeenCalled();
|
|
expect(maps.fetchWikiExtract).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('ENRICH-014b: leaves the OSM link empty when the details carry no osm_url', async () => {
|
|
const maps = mapsStub({
|
|
details: vi.fn(async () => ({ place: { summary: 'Ein Museum.', source: 'openstreetmap' } })),
|
|
});
|
|
|
|
const out = await make(maps, cacheStub()).enrich(1, OSM_REQ);
|
|
|
|
expect(out.description).toEqual({ text: 'Ein Museum.', source: 'osm', sourceUrl: null, license: 'ODbL 1.0' });
|
|
});
|
|
|
|
it('ENRICH-015: ignores a summary that came from Google on the OSM branch', async () => {
|
|
// getPlaceDetails returns `summary` for Google places too; only the OSM one
|
|
// is a mapper-written description we may label as such.
|
|
const maps = mapsStub({
|
|
details: vi.fn(async () => ({ place: { summary: 'Google blurb', source: 'google' } })),
|
|
});
|
|
|
|
const out = await make(maps, cacheStub()).enrich(1, REQ);
|
|
|
|
expect(out.description).toBeNull();
|
|
});
|
|
|
|
it('ENRICH-016: uses Googles editorial summary when there is no OSM text', async () => {
|
|
const maps = mapsStub({
|
|
getMapsKey: vi.fn(() => 'key'),
|
|
details: vi.fn(async () => ({ place: { google_maps_url: 'https://maps.google.com/x' } })),
|
|
fetchEditorialSummary: vi.fn(async () => 'A museum in Cologne.'),
|
|
});
|
|
|
|
const out = await make(maps, cacheStub()).enrich(1, REQ);
|
|
|
|
expect(out.description).toEqual({
|
|
text: 'A museum in Cologne.',
|
|
source: 'google',
|
|
sourceUrl: 'https://maps.google.com/x',
|
|
license: null,
|
|
});
|
|
});
|
|
|
|
it('ENRICH-016b: leaves the source link empty when the details carry no Maps URL', async () => {
|
|
const maps = mapsStub({
|
|
getMapsKey: vi.fn(() => 'key'),
|
|
details: vi.fn(async () => ({ place: null })),
|
|
fetchEditorialSummary: vi.fn(async () => 'A museum in Cologne.'),
|
|
});
|
|
|
|
const out = await make(maps, cacheStub()).enrich(1, REQ);
|
|
|
|
expect(out.description).toMatchObject({ source: 'google', sourceUrl: null });
|
|
});
|
|
|
|
it('ENRICH-018b: ignores a non-string wikipedia tag rather than passing it on', async () => {
|
|
// Provider blobs are open records — a numeric or object tag must not reach
|
|
// the wiki lookup as-is.
|
|
const maps = mapsStub({ details: vi.fn(async () => ({ place: { wikipedia: 42 } })) });
|
|
|
|
await make(maps, cacheStub()).enrich(1, OSM_REQ);
|
|
|
|
// Not a usable tag, so it counts as no identity at all: the lookup that
|
|
// finds one is tried instead, and the wiki itself is never asked.
|
|
expect(maps.resolveOsmIdentity).toHaveBeenCalled();
|
|
expect(maps.fetchWikiExtract).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('ENRICH-017: honours the details kill switch and the missing key', async () => {
|
|
const disabled = mapsStub({ getMapsKey: vi.fn(() => 'key'), detailsDisabled: vi.fn(() => true) });
|
|
await make(disabled, cacheStub()).enrich(1, REQ);
|
|
expect(disabled.fetchEditorialSummary).not.toHaveBeenCalled();
|
|
|
|
const keyless = mapsStub({ getMapsKey: vi.fn(() => null) });
|
|
await make(keyless, cacheStub()).enrich(1, REQ);
|
|
expect(keyless.fetchEditorialSummary).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('ENRICH-018: falls back to Wikipedia, resolved from the wiki tag', async () => {
|
|
const maps = mapsStub({
|
|
details: vi.fn(async () => ({ place: { wikipedia: 'de:Museum Ludwig' } })),
|
|
fetchWikiExtract: vi.fn(async () => ({
|
|
text: 'Das Museum Ludwig ist ein Museum.',
|
|
sourceUrl: 'https://de.wikipedia.org/wiki/Museum%20Ludwig',
|
|
source: 'wikipedia' as const,
|
|
})),
|
|
});
|
|
|
|
const out = await make(maps, cacheStub()).enrich(1, OSM_REQ);
|
|
|
|
expect(maps.fetchWikiExtract).toHaveBeenCalledWith('de:Museum Ludwig');
|
|
expect(out.description).toEqual({
|
|
text: 'Das Museum Ludwig ist ein Museum.',
|
|
source: 'wikipedia',
|
|
sourceUrl: 'https://de.wikipedia.org/wiki/Museum%20Ludwig',
|
|
license: 'CC BY-SA 4.0',
|
|
});
|
|
});
|
|
|
|
it('ENRICH-019: looks the place up when it carries no wiki tag, and gives up quietly when that finds nothing', async () => {
|
|
// The kiosk case. A place with no tag and no OSM match genuinely has no
|
|
// description, and inventing one from a name search is how you confidently
|
|
// describe a different building.
|
|
const maps = mapsStub({ details: vi.fn(async () => ({ place: { name: 'Kiosk' } })) });
|
|
|
|
const out = await make(maps, cacheStub()).enrich(1, OSM_REQ);
|
|
|
|
expect(maps.resolveOsmIdentity).toHaveBeenCalledWith('Museum Ludwig', 50.9, 6.96, { lang: undefined });
|
|
expect(maps.fetchWikiExtract).not.toHaveBeenCalled();
|
|
expect(out.description).toBeNull();
|
|
});
|
|
|
|
it('ENRICH-020: survives a details lookup that throws', async () => {
|
|
const maps = mapsStub({ details: vi.fn(async () => { throw new Error('overpass down'); }) });
|
|
|
|
const out = await make(maps, cacheStub()).enrich(1, OSM_REQ);
|
|
|
|
expect(out.description).toBeNull();
|
|
expect(out.photos).toEqual([]);
|
|
});
|
|
});
|
|
|
|
// ── Result cache ─────────────────────────────────────────────────────────────
|
|
|
|
describe('result cache', () => {
|
|
// v is the cache format version; an entry written before a release that
|
|
// changes the shape or the sources is discarded rather than served.
|
|
const cachedRow = (photos: unknown[], fetchedAt = Date.now(), v: number | undefined = 4) => ({
|
|
payload_json: JSON.stringify({ photos, description: null, facts: [], v }),
|
|
fetched_at: fetchedAt,
|
|
});
|
|
|
|
it('ENRICH-021: serves a fresh entry without calling any provider', async () => {
|
|
const maps = mapsStub();
|
|
const cache = cacheStub({ get: vi.fn(() => ({ photoUrl: '/x', filePath: '/tmp/x', attribution: null })) });
|
|
mockDbGet.mockImplementation((sql: string) =>
|
|
String(sql).includes('place_details_cache')
|
|
? cachedRow([{ key: 'ChIJmuseum~p0', url: '/x', attribution: null, license: null, licenseUrl: null, sourceUrl: null, source: 'wikimedia' }])
|
|
: undefined,
|
|
);
|
|
|
|
const out = await make(maps, cache).enrich(1, REQ);
|
|
|
|
expect(out.photos).toHaveLength(1);
|
|
expect(maps.fetchCommonsCandidates).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('ENRICH-022: refetches once the entry is older than a week', async () => {
|
|
const maps = mapsStub({ fetchCommonsCandidates: vi.fn(async () => [commonsCandidate()]) });
|
|
const eightDaysAgo = Date.now() - 8 * 24 * 60 * 60 * 1000;
|
|
mockDbGet.mockImplementation((sql: string) =>
|
|
String(sql).includes('place_details_cache') ? cachedRow([], eightDaysAgo) : undefined,
|
|
);
|
|
|
|
await make(maps, cacheStub()).enrich(1, REQ);
|
|
|
|
expect(maps.fetchCommonsCandidates).toHaveBeenCalled();
|
|
});
|
|
|
|
it('ENRICH-023: rebuilds when the nightly sweep removed a cached candidate', async () => {
|
|
// The sweep deletes every picture nobody picked, so this is the normal state
|
|
// of a cached entry the morning after. Serving the survivors would leave the
|
|
// column empty for the rest of the week for a place that has pictures.
|
|
const maps = mapsStub({ fetchCommonsCandidates: vi.fn(async () => [commonsCandidate()]) });
|
|
let onDisk = false;
|
|
const cache = cacheStub({
|
|
get: vi.fn(() => (onDisk ? { photoUrl: '/x', filePath: '/tmp/x', attribution: null } : null)),
|
|
put: vi.fn(async (key: string) => {
|
|
onDisk = true;
|
|
return { photoUrl: `/api/maps/place-photo/${key}/bytes`, filePath: '/tmp/x', attribution: null };
|
|
}),
|
|
});
|
|
mockDbGet.mockImplementation((sql: string) =>
|
|
String(sql).includes('place_details_cache')
|
|
? cachedRow([{ key: 'ChIJmuseum~p0', url: '/x', attribution: null, license: null, licenseUrl: null, sourceUrl: null, source: 'wikimedia' }])
|
|
: undefined,
|
|
);
|
|
|
|
const out = await make(maps, cache).enrich(1, REQ);
|
|
|
|
expect(maps.fetchCommonsCandidates).toHaveBeenCalled();
|
|
expect(out.photos).toHaveLength(1);
|
|
});
|
|
|
|
it('ENRICH-023b: keeps serving a cached entry while all its bytes are still there', async () => {
|
|
const maps = mapsStub();
|
|
const cache = cacheStub({ get: vi.fn(() => ({ photoUrl: '/x', filePath: '/tmp/x', attribution: null })) });
|
|
mockDbGet.mockImplementation((sql: string) =>
|
|
String(sql).includes('place_details_cache')
|
|
? cachedRow([{ key: 'ChIJmuseum~p0', url: '/x', attribution: null, license: null, licenseUrl: null, sourceUrl: null, source: 'wikimedia' }])
|
|
: undefined,
|
|
);
|
|
|
|
const out = await make(maps, cache).enrich(1, REQ);
|
|
|
|
expect(out.photos).toHaveLength(1);
|
|
expect(maps.fetchCommonsCandidates).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('ENRICH-023c: discards an entry written by an older format version', async () => {
|
|
// Otherwise a release that adds a field keeps serving last week's answers.
|
|
const maps = mapsStub({ fetchCommonsCandidates: vi.fn(async () => [commonsCandidate()]) });
|
|
mockDbGet.mockImplementation((sql: string) =>
|
|
String(sql).includes('place_details_cache') ? cachedRow([], Date.now(), 1) : undefined,
|
|
);
|
|
|
|
await make(maps, cacheStub()).enrich(1, REQ);
|
|
|
|
expect(maps.fetchCommonsCandidates).toHaveBeenCalled();
|
|
});
|
|
|
|
it('ENRICH-024: ignores an unreadable cache row and rebuilds', async () => {
|
|
const maps = mapsStub({ fetchCommonsCandidates: vi.fn(async () => [commonsCandidate()]) });
|
|
mockDbGet.mockImplementation((sql: string) =>
|
|
String(sql).includes('place_details_cache') ? { payload_json: '{not json', fetched_at: Date.now() } : undefined,
|
|
);
|
|
|
|
const out = await make(maps, cacheStub()).enrich(1, REQ);
|
|
|
|
expect(out.photos).toHaveLength(1);
|
|
});
|
|
|
|
it('ENRICH-025: stores the result under its own cache kind and language', async () => {
|
|
const maps = mapsStub({ fetchCommonsCandidates: vi.fn(async () => [commonsCandidate()]) });
|
|
|
|
await make(maps, cacheStub()).enrich(1, { ...REQ, lang: 'de' });
|
|
|
|
const write = mockDbRun.mock.calls.find((c) => String(c[0]).includes('INSERT OR REPLACE INTO place_details_cache'));
|
|
expect(write).toBeDefined();
|
|
expect(write![1]).toBe('ChIJmuseum');
|
|
expect(write![2]).toBe('de');
|
|
// expanded = 2 — the plain (0) and reviews (1) caches keep their rows.
|
|
expect(write![3]).toBe(2);
|
|
});
|
|
|
|
it('ENRICH-026: keeps working when the cache write fails', async () => {
|
|
const maps = mapsStub({ fetchCommonsCandidates: vi.fn(async () => [commonsCandidate()]) });
|
|
mockDbRun.mockImplementation(() => { throw new Error('db locked'); });
|
|
const err = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
|
|
const out = await make(maps, cacheStub()).enrich(1, REQ);
|
|
|
|
expect(out.photos).toHaveLength(1);
|
|
expect(err).toHaveBeenCalled();
|
|
err.mockRestore();
|
|
});
|
|
});
|
|
|
|
// ── Credit line ──────────────────────────────────────────────────────────────
|
|
|
|
describe('creditLine', () => {
|
|
it('ENRICH-027: joins author and licence into the one text column the cache has', () => {
|
|
expect(creditLine('Alice', 'CC BY-SA 4.0')).toBe('Alice · CC BY-SA 4.0');
|
|
expect(creditLine('Alice', null)).toBe('Alice');
|
|
expect(creditLine(null, 'CC BY-SA 4.0')).toBe('CC BY-SA 4.0');
|
|
expect(creditLine(null, null)).toBeNull();
|
|
});
|
|
|
|
it('ENRICH-028: stores the joined credit alongside a Commons picture', async () => {
|
|
const maps = mapsStub({ fetchCommonsCandidates: vi.fn(async () => [commonsCandidate()]) });
|
|
const cache = cacheStub();
|
|
|
|
await make(maps, cache).enrich(1, REQ);
|
|
|
|
expect(cache.put).toHaveBeenCalledWith(
|
|
candidateKey('ChIJmuseum', 'commons:1001'),
|
|
expect.any(Buffer),
|
|
'Alice · CC BY-SA 4.0',
|
|
);
|
|
});
|
|
|
|
it('ENRICH-029: reads a stored credit back by cache key', async () => {
|
|
const cache = cacheStub({
|
|
get: vi.fn((key: string) =>
|
|
key === 'ChIJmuseum~p0' ? { photoUrl: '/x', attribution: 'Alice · CC BY-SA 4.0' } : null,
|
|
),
|
|
});
|
|
const svc = make(mapsStub(), cache);
|
|
|
|
expect(await svc.credit('ChIJmuseum~p0')).toEqual({ credit: 'Alice · CC BY-SA 4.0' });
|
|
// An uploaded image or a key that was swept has nothing to name.
|
|
expect(await svc.credit('unknown')).toEqual({ credit: null });
|
|
});
|
|
});
|
|
|
|
// ── OpenStreetMap facts (ENRICH-050..) ───────────────────────────────────────
|
|
|
|
describe('collectFacts', () => {
|
|
const osm = (tags: Record<string, unknown>) => ({ source: 'openstreetmap', ...tags });
|
|
|
|
it('ENRICH-050: turns the tags a restaurant actually carries into chips', () => {
|
|
const facts = collectFacts(
|
|
osm({
|
|
cuisine: 'regional;german',
|
|
opening_hours: ['Mo-Sa 17:30+'],
|
|
menu_url: 'https://example.org/menu',
|
|
outdoor_seating: 'yes',
|
|
wheelchair: 'limited',
|
|
}),
|
|
);
|
|
|
|
expect(facts).toEqual([
|
|
// No hours chip: a week of opening times is its own block now, because
|
|
// seven lines joined with dots truncated to nothing in a 288px column.
|
|
// Semicolons and underscores are OSM syntax, not something to show a reader.
|
|
{ kind: 'cuisine', value: 'regional, german', url: null },
|
|
{ kind: 'menu', value: null, url: 'https://example.org/menu' },
|
|
{ kind: 'outdoorSeating', value: null, url: null },
|
|
{ kind: 'wheelchair', value: 'limited', url: null },
|
|
]);
|
|
});
|
|
|
|
it('ENRICH-051: lists only positive facts', () => {
|
|
// "No outdoor seating" is noise, and a missing tag reads the same to a user.
|
|
const facts = collectFacts(
|
|
osm({ outdoor_seating: 'no', takeaway: 'no', wheelchair: 'no', internet_access: 'no', diet_vegan: 'no' }),
|
|
);
|
|
expect(facts).toEqual([]);
|
|
});
|
|
|
|
it('ENRICH-052: accepts the "only" spelling for diets', () => {
|
|
const kinds = collectFacts(osm({ diet_vegetarian: 'only', diet_vegan: 'yes' })).map((f) => f.kind);
|
|
expect(kinds).toEqual(['vegetarian', 'vegan']);
|
|
});
|
|
|
|
it('ENRICH-052b: covers the remaining yes-tags and a non-string osm_url', () => {
|
|
const facts = collectFacts(osm({ takeaway: 'yes', delivery: 'yes', internet_access: 'wlan', wheelchair: 'yes' }));
|
|
expect(facts.map((f) => f.kind)).toEqual(['takeaway', 'delivery', 'wheelchair', 'internetAccess']);
|
|
// Any internet_access value other than "no" counts as available.
|
|
expect(facts.find((f) => f.kind === 'internetAccess')).toEqual({ kind: 'internetAccess', value: null, url: null });
|
|
});
|
|
|
|
it('ENRICH-052c: drops a menu_url that is not http(s)', () => {
|
|
// Anyone can edit the OSM tag and the client turns it into an href.
|
|
for (const menu_url of ['javascript:alert(1)', 'data:text/html,<script>1</script>', 'ftp://host/menu', '']) {
|
|
expect(collectFacts(osm({ menu_url })).map((f) => f.kind)).toEqual([]);
|
|
}
|
|
});
|
|
|
|
it('ENRICH-052d: keeps an http menu_url and trims the surrounding whitespace', () => {
|
|
expect(collectFacts(osm({ menu_url: ' http://example.org/menu ' }))).toEqual([
|
|
{ kind: 'menu', value: null, url: 'http://example.org/menu' },
|
|
]);
|
|
});
|
|
|
|
it('ENRICH-053: takes no chips at all from a Google place', () => {
|
|
expect(collectFacts(null)).toEqual([]);
|
|
// cuisine/outdoor_seating are OSM tagging and never appear on a Google blob;
|
|
// rating and hours are fields of their own.
|
|
expect(collectFacts({ source: 'google', cuisine: 'italian', outdoor_seating: 'yes' })).toEqual([]);
|
|
expect(
|
|
collectFacts({ source: 'google', rating: 4.5, rating_count: 1234, opening_hours: ['Mo-Fr 09:00-18:00'] }),
|
|
).toEqual([]);
|
|
});
|
|
|
|
it('ENRICH-053b: reads the rating as a number and a count, not as a sentence', () => {
|
|
expect(collectRating({ source: 'google', rating: 4.5, rating_count: 1234 })).toEqual({ value: 4.5, count: 1234 });
|
|
// Google's search results carry a rating but never a count.
|
|
expect(collectRating({ source: 'google', rating: 4 })).toEqual({ value: 4, count: null });
|
|
expect(collectRating({ source: 'google' })).toBeNull();
|
|
expect(collectRating(null)).toBeNull();
|
|
});
|
|
|
|
it('ENRICH-054: hands the week over whole, with the periods that go with it', () => {
|
|
// The lines are localised display text and the periods are what "open now"
|
|
// is computed from — neither substitutes for the other.
|
|
const periods = [{ open: { day: 1, hour: 9, minute: 0 }, close: { day: 1, hour: 18, minute: 0 } }];
|
|
expect(
|
|
collectHours(osm({ opening_hours: ['Mo-Fr 09:00-18:00', 'Sa 10:00-14:00'], opening_periods: periods })),
|
|
).toEqual({
|
|
weekdayDescriptions: ['Mo-Fr 09:00-18:00', 'Sa 10:00-14:00'],
|
|
periods,
|
|
// buildOsmDetails never sets this; only Google reports special days.
|
|
specialDays: null,
|
|
});
|
|
expect(collectHours(osm({}))).toBeNull();
|
|
expect(collectHours(null)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('picture source selection', () => {
|
|
it('ENRICH-055: prefers the Commons category over the coordinate search', async () => {
|
|
// geosearch around a city centre returns statues and passers-by; the
|
|
// category of a place returns the place.
|
|
const maps = mapsStub({
|
|
details: vi.fn(async () => ({ place: { source: 'openstreetmap', wikimedia_commons: 'Category:Museum Ludwig' } })),
|
|
fetchCommonsCategoryCandidates: vi.fn(async () =>
|
|
['Alice', 'Bob'].map((attribution) => commonsCandidate({ attribution })),
|
|
),
|
|
});
|
|
|
|
const out = await make(maps, cacheStub()).enrich(1, OSM_REQ);
|
|
|
|
expect(maps.fetchCommonsCategoryCandidates).toHaveBeenCalledWith('Category:Museum Ludwig', 5);
|
|
// Two pictures of the place is a strip; the coordinate search is not asked.
|
|
expect(maps.fetchCommonsCandidates).not.toHaveBeenCalled();
|
|
expect(out.photos).toHaveLength(2);
|
|
});
|
|
|
|
it('ENRICH-056: falls back to the coordinate search when the category is empty', async () => {
|
|
const maps = mapsStub({
|
|
details: vi.fn(async () => ({ place: { source: 'openstreetmap', wikimedia_commons: 'Category:Empty' } })),
|
|
fetchCommonsCategoryCandidates: vi.fn(async () => []),
|
|
fetchCommonsCandidates: vi.fn(async () => [commonsCandidate()]),
|
|
});
|
|
|
|
const out = await make(maps, cacheStub()).enrich(1, OSM_REQ);
|
|
|
|
expect(maps.fetchCommonsCandidates).toHaveBeenCalled();
|
|
expect(out.photos).toHaveLength(1);
|
|
});
|
|
|
|
it('ENRICH-057: reports Wikivoyage as the description source when it answered', async () => {
|
|
const maps = mapsStub({
|
|
details: vi.fn(async () => ({ place: { source: 'openstreetmap', wikipedia: 'de:Berlin' } })),
|
|
fetchWikiExtract: vi.fn(async () => ({
|
|
text: 'Berlin ist das politische Zentrum Deutschlands.',
|
|
sourceUrl: 'https://de.wikivoyage.org/wiki/Berlin',
|
|
source: 'wikivoyage' as const,
|
|
})),
|
|
});
|
|
|
|
const out = await make(maps, cacheStub()).enrich(1, OSM_REQ);
|
|
|
|
expect(out.description).toMatchObject({ source: 'wikivoyage', license: 'CC BY-SA 4.0' });
|
|
});
|
|
});
|
|
|
|
/**
|
|
* ENRICH-060..069 — the source ladder.
|
|
*
|
|
* The picker used to show aerial survey tiles for Hamburg Airport and the
|
|
* underground station for the Brandenburg Gate, because the only rung that ever
|
|
* ran was "photographed within 300m". These cases pin the order it climbs and
|
|
* when it is allowed to fall back that far.
|
|
*/
|
|
describe('picture source ladder', () => {
|
|
const withTags = (tags: Record<string, unknown>) =>
|
|
vi.fn(async () => ({ place: { source: 'openstreetmap', ...tags } }));
|
|
|
|
it('ENRICH-060: asks Wikidata first when the place carries a wikidata tag', async () => {
|
|
const maps = mapsStub({
|
|
details: withTags({ wikidata: 'Q1097' }),
|
|
fetchWikidataCandidates: vi.fn(async () => ({
|
|
candidates: ['Alice', 'Bob', 'Cara', 'Dana', 'Erin'].map((attribution) => commonsCandidate({ attribution })),
|
|
commonsCategory: 'Berlin Hauptbahnhof',
|
|
})),
|
|
});
|
|
|
|
const out = await make(maps, cacheStub()).enrich(1, OSM_REQ);
|
|
|
|
expect(maps.fetchWikidataCandidates).toHaveBeenCalledWith('Q1097', 5);
|
|
// A full strip of pictures somebody attached to this exact object — no rung
|
|
// below it is asked, least of all the coordinate search.
|
|
expect(maps.fetchCommonsCategoryCandidates).not.toHaveBeenCalled();
|
|
expect(maps.fetchCommonsCandidates).not.toHaveBeenCalled();
|
|
expect(out.photos).toHaveLength(5);
|
|
});
|
|
|
|
it('ENRICH-061: takes the Commons category Wikidata names when the tag omits it', async () => {
|
|
// Berlin Hauptbahnhof carries wikidata but no wikimedia_commons in OSM; the
|
|
// item's P373 knows the category anyway.
|
|
const maps = mapsStub({
|
|
details: withTags({ wikidata: 'Q1097' }),
|
|
fetchWikidataCandidates: vi.fn(async () => ({
|
|
candidates: [commonsCandidate()],
|
|
commonsCategory: 'Berlin Hauptbahnhof',
|
|
})),
|
|
fetchCommonsCategoryCandidates: vi.fn(async () => [commonsCandidate({ attribution: 'Bob' })]),
|
|
});
|
|
|
|
await make(maps, cacheStub()).enrich(1, OSM_REQ);
|
|
|
|
expect(maps.fetchCommonsCategoryCandidates).toHaveBeenCalledWith('Berlin Hauptbahnhof', 5);
|
|
});
|
|
|
|
it('ENRICH-062: reaches for the article lead image when there is no wikidata tag', async () => {
|
|
const maps = mapsStub({
|
|
details: withTags({ wikipedia: 'de:Flughafen Hamburg' }),
|
|
fetchWikiLeadImageName: vi.fn(async () => 'Hamburg airport terminals.jpg'),
|
|
fetchCommonsFilesByName: vi.fn(async () =>
|
|
new Map([['hamburg airport terminals.jpg', commonsCandidate({ attribution: 'Alice' })]]),
|
|
),
|
|
});
|
|
|
|
const out = await make(maps, cacheStub()).enrich(1, OSM_REQ);
|
|
|
|
expect(maps.fetchWikiLeadImageName).toHaveBeenCalledWith('de:Flughafen Hamburg');
|
|
expect(maps.fetchCommonsFilesByName).toHaveBeenCalledWith(['Hamburg airport terminals.jpg']);
|
|
// Reported as a Wikipedia picture, because that is where it was chosen.
|
|
expect(out.photos[0].source).toBe('wikipedia');
|
|
});
|
|
|
|
it('ENRICH-063: only falls back to what was photographed nearby when the rest came up thin', async () => {
|
|
const maps = mapsStub({
|
|
details: withTags({ wikidata: 'Q1097' }),
|
|
fetchWikidataCandidates: vi.fn(async () => ({ candidates: [commonsCandidate()], commonsCategory: null })),
|
|
fetchCommonsCandidates: vi.fn(async () => [commonsCandidate({ attribution: 'Bob' })]),
|
|
});
|
|
|
|
await make(maps, cacheStub()).enrich(1, OSM_REQ);
|
|
|
|
// One curated picture is not a strip, so the coordinate search tops it up.
|
|
expect(maps.fetchCommonsCandidates).toHaveBeenCalled();
|
|
});
|
|
|
|
it('ENRICH-064: does not run the coordinate search behind a full Google strip', async () => {
|
|
const maps = mapsStub({
|
|
getMapsKey: vi.fn(() => 'key'),
|
|
fetchGooglePhotoRefs: vi.fn(async () => [
|
|
{ name: 'places/p/photos/a', attribution: 'Bob' },
|
|
{ name: 'places/p/photos/b', attribution: 'Cara' },
|
|
{ name: 'places/p/photos/c', attribution: 'Dana' },
|
|
]),
|
|
fetchGooglePhotoBytes: vi.fn(async () => Buffer.from([1, 2, 3])),
|
|
});
|
|
|
|
const out = await make(maps, cacheStub()).enrich(1, REQ);
|
|
|
|
expect(out.photos).toHaveLength(3);
|
|
expect(out.photos.every((p) => p.source === 'google')).toBe(true);
|
|
});
|
|
|
|
it('ENRICH-065: keeps the coordinate search off the critical path of Google\'s listing', async () => {
|
|
// Waiting for the listing before deciding put two round trips end to end,
|
|
// which is what pushed this endpoint past the client timeout once before.
|
|
const order: string[] = [];
|
|
const maps = mapsStub({
|
|
getMapsKey: vi.fn(() => 'key'),
|
|
fetchGooglePhotoRefs: vi.fn(async () => {
|
|
order.push('google:start');
|
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
order.push('google:end');
|
|
return [];
|
|
}),
|
|
fetchCommonsCandidates: vi.fn(async () => {
|
|
order.push('commons:start');
|
|
return [];
|
|
}),
|
|
});
|
|
|
|
await make(maps, cacheStub()).enrich(1, REQ);
|
|
|
|
expect(order.indexOf('commons:start')).toBeLessThan(order.indexOf('google:end'));
|
|
});
|
|
|
|
it('ENRICH-066: drops a picture that two rungs both produced', async () => {
|
|
const shared = commonsCandidate({ pageId: 4242, attribution: 'Alice' });
|
|
const maps = mapsStub({
|
|
details: withTags({ wikidata: 'Q1097', wikimedia_commons: 'Category:X' }),
|
|
fetchWikidataCandidates: vi.fn(async () => ({ candidates: [shared], commonsCategory: null })),
|
|
fetchCommonsCategoryCandidates: vi.fn(async () => [{ ...shared, photoUrl: 'https://commons.org/other-url.jpg' }]),
|
|
});
|
|
|
|
const out = await make(maps, cacheStub()).enrich(1, OSM_REQ);
|
|
|
|
// Same page id, different thumbnail url — the url alone would let it through.
|
|
expect(out.photos).toHaveLength(1);
|
|
});
|
|
|
|
it('ENRICH-067: throws away the survey tiles a geosearch around an airport returns', async () => {
|
|
const maps = mapsStub({
|
|
fetchCommonsCandidates: vi.fn(async () =>
|
|
[2013, 2015, 2016, 2019].map((year) =>
|
|
commonsCandidate({
|
|
title: `File:Dop20rgb 32565 5943 ${year}.jpg`,
|
|
attribution: 'Landesbetrieb Geoinformation Hamburg',
|
|
width: 5000,
|
|
height: 5000,
|
|
descriptors: 'Orthophoto Sommerbefliegung',
|
|
}),
|
|
),
|
|
),
|
|
});
|
|
|
|
const out = await make(maps, cacheStub()).enrich(1, REQ);
|
|
|
|
expect(out.photos).toEqual([]);
|
|
});
|
|
|
|
it('ENRICH-068: keys a picture by which file it is, not by where it sat', async () => {
|
|
// The strip length changes with which providers answered. Keyed by position,
|
|
// a slot that held a Wikidata photo one minute held a category photo the
|
|
// next while the cache kept serving the first one's bytes — under CC BY
|
|
// that credits the wrong person, not just the wrong thumbnail.
|
|
const one = mapsStub({ fetchCommonsCandidates: vi.fn(async () => [commonsCandidate({ pageId: 777 })]) });
|
|
const first = (await make(one, cacheStub()).enrich(1, REQ)).photos[0].key;
|
|
|
|
const two = mapsStub({
|
|
fetchCommonsCandidates: vi.fn(async () => [
|
|
commonsCandidate({ pageId: 111, attribution: 'Zoe' }),
|
|
commonsCandidate({ pageId: 777 }),
|
|
]),
|
|
});
|
|
const shifted = (await make(two, cacheStub()).enrich(1, REQ)).photos.find((p) => p.attribution === 'Alice');
|
|
|
|
expect(shifted?.key).toBe(first);
|
|
});
|
|
});
|