From 330644e88b42b40ccdffc587cf24e1c1fecfdf00 Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 30 Oct 2026 23:04:12 +0100 Subject: [PATCH] places: show the community's published pictures first in Pick a picture The service delivers the pictures review approved for a place, but TREK never read them. A new TrekCommunityService in the maps domain asks the TREK Places API for them (the index row for a gers place, /v1/community for an OSM one), accepts only paths of the service's picture store, and fetches the bytes with the installation's key, capped at 4 MB. Enrichment puts them ahead of Google and Commons, through the same photo cache, credited as TREK Community under CC BY 4.0. The enrichment cache now follows the community version for OSM places too, so a newly approved picture shows on the next open. Also lowers the client's no-non-null-assertion baseline to 380: moving Use this text into the description card removed two. --- client/scripts/eslint-baseline.json | 2 +- .../Planner/placePhotoCredit.test.ts | 1 + .../components/Planner/placePhotoCredit.ts | 1 + server/src/nest/maps/community-overlay.ts | 7 +- server/src/nest/maps/maps.module.ts | 4 +- .../src/nest/maps/trek-community.service.ts | 72 +++++++++++++++++ server/src/nest/maps/trek-places.client.ts | 2 + .../place-enrichment.service.ts | 28 ++++++- .../unit/nest/maps.community-overlay.test.ts | 3 +- .../unit/nest/place-enrichment.brand.test.ts | 11 ++- .../nest/place-enrichment.description.test.ts | 10 ++- .../nest/place-enrichment.service.test.ts | 42 +++++++++- .../unit/nest/trek-community.service.test.ts | 81 +++++++++++++++++++ shared/src/maps/maps.schema.ts | 2 +- 14 files changed, 249 insertions(+), 17 deletions(-) create mode 100644 server/src/nest/maps/trek-community.service.ts create mode 100644 server/tests/unit/nest/trek-community.service.test.ts diff --git a/client/scripts/eslint-baseline.json b/client/scripts/eslint-baseline.json index 41c27b43c..1df2b6b1a 100644 --- a/client/scripts/eslint-baseline.json +++ b/client/scripts/eslint-baseline.json @@ -1,6 +1,6 @@ { "src": { - "@typescript-eslint/no-non-null-assertion": 382, + "@typescript-eslint/no-non-null-assertion": 380, "@typescript-eslint/no-misused-promises": 847, "no-console": 88, "complexity": 218, diff --git a/client/src/components/Planner/placePhotoCredit.test.ts b/client/src/components/Planner/placePhotoCredit.test.ts index 572215e9c..2ad665f41 100644 --- a/client/src/components/Planner/placePhotoCredit.test.ts +++ b/client/src/components/Planner/placePhotoCredit.test.ts @@ -23,6 +23,7 @@ describe('placePhotoCredit', () => { expect(sourceLabelFor('google')).toBe('Google'); expect(sourceLabelFor('wikipedia')).toBe('Wikipedia'); expect(sourceLabelFor('wikimedia')).toBe('Wikimedia Commons'); + expect(sourceLabelFor('community')).toBe('TREK Community'); }); it('FE-PLANNER-PHOTOCREDIT-002: the author is credited, and the provider when there is none', () => { diff --git a/client/src/components/Planner/placePhotoCredit.ts b/client/src/components/Planner/placePhotoCredit.ts index a2d2e1c04..d8ba5de1a 100644 --- a/client/src/components/Planner/placePhotoCredit.ts +++ b/client/src/components/Planner/placePhotoCredit.ts @@ -12,6 +12,7 @@ export function creditOf(photo: PlacePhotoCandidate): string { export function sourceLabelFor(source: PlacePhotoCandidate['source']): string { if (source === 'google') return 'Google'; if (source === 'wikipedia') return 'Wikipedia'; + if (source === 'community') return 'TREK Community'; return 'Wikimedia Commons'; } diff --git a/server/src/nest/maps/community-overlay.ts b/server/src/nest/maps/community-overlay.ts index 2692c96b7..e89b57eab 100644 --- a/server/src/nest/maps/community-overlay.ts +++ b/server/src/nest/maps/community-overlay.ts @@ -1,4 +1,5 @@ import { buildOsmDetails } from './maps.helpers'; +import { communityKeyOf } from './trek-community.service'; import { trekCommunityByKeys, type TrekCommunityValues } from './trek-places.client'; import { normalizePlaceWebsite } from '@trek/shared'; @@ -10,12 +11,6 @@ import { normalizePlaceWebsite } from '@trek/shared'; * the same place everywhere. */ -/** The service's key of an OSM details id: `relation:3389505` is `osm:relation/3389505`. */ -export function communityKeyOf(placeId: string): string | null { - const match = /^(node|way|relation):(\d+)$/.exec(placeId); - return match ? `osm:${match[1]}/${match[2]}` : null; -} - /** The details record with what the community published laid over it; untouched where nothing was. */ export function withCommunityValues( details: Record, diff --git a/server/src/nest/maps/maps.module.ts b/server/src/nest/maps/maps.module.ts index c2120de3e..cff91369c 100644 --- a/server/src/nest/maps/maps.module.ts +++ b/server/src/nest/maps/maps.module.ts @@ -18,6 +18,7 @@ import { GooglePlacesClient } from './providers/google-places.provider'; import { OsmClient } from './providers/osm.client'; import { PhotonClient } from './providers/photon.client'; import { WikimediaClient } from './providers/wikimedia.client'; +import { TrekCommunityService } from './trek-community.service'; import { TrekPlacesLayersService } from './trek-places-layers.service'; import { MikroOrmModule } from '@mikro-orm/nestjs'; import { Module } from '@nestjs/common'; @@ -66,7 +67,8 @@ import { Module } from '@nestjs/common'; PlaceDetailsResolver, PoisAlongResolver, TrekPlacesLayersService, + TrekCommunityService, ], - exports: [MapsService, GooglePlacesClient, OsmClient, WikimediaClient, TrekPlacesLayersService], + exports: [MapsService, GooglePlacesClient, OsmClient, WikimediaClient, TrekPlacesLayersService, TrekCommunityService], }) export class MapsModule {} diff --git a/server/src/nest/maps/trek-community.service.ts b/server/src/nest/maps/trek-community.service.ts new file mode 100644 index 000000000..c4849aaa3 --- /dev/null +++ b/server/src/nest/maps/trek-community.service.ts @@ -0,0 +1,72 @@ +import { trekCommunityByKeys, trekPlacesBaseUrl, trekPlacesById } from './trek-places.client'; +import { identifiedFetch } from './trek-places.fetch'; +import { Injectable } from '@nestjs/common'; + +/** A published picture's path as the service writes it; anything else is never fetched. */ +const IMAGE_PATH = /^\/v1\/contrib\/img\/[A-Za-z0-9_-]{8,64}\.webp$/; +const IMAGE_MAX_BYTES = 4_000_000; +const IMAGE_TIMEOUT_MS = 8000; + +/** The service's key of an OSM details id: `relation:3389505` is `osm:relation/3389505`. */ +export function communityKeyOf(placeId: string): string | null { + const match = /^(node|way|relation):(\d+)$/.exec(placeId); + return match ? `osm:${match[1]}/${match[2]}` : null; +} + +/** + * The pictures the TREK community published for a place, read from the TREK + * Places API: which ones (cover first, then the order review set) and their + * bytes. An index place carries them on its own row, an OSM place gets them + * from /v1/community. The caller checks that the API is switched on. + */ +@Injectable() +export class TrekCommunityService { + /** Paths of the place's published pictures, cover first; empty when there are none or the API fails. */ + async photoPaths(placeId: string): Promise { + let images: { url?: string; cover?: boolean }[] | null | undefined; + try { + if (placeId.startsWith('gers:')) { + images = (await trekPlacesById(placeId.slice('gers:'.length)))?.images; + } else { + const key = communityKeyOf(placeId); + if (key) images = (await trekCommunityByKeys([key]))[key]?.images; + } + } catch { + // The service being down must never cost the place its other pictures. + return []; + } + if (!Array.isArray(images)) return []; + const ordered = [...images.filter((i) => i?.cover), ...images.filter((i) => !i?.cover)]; + return ordered.map((i) => i?.url).filter((u): u is string => typeof u === 'string' && IMAGE_PATH.test(u)); + } + + /** + * The bytes of one published picture, fetched with this installation's key, + * or null. Only a path of the service's own picture store is accepted: the + * value comes from the service's answer and becomes an authenticated download. + */ + async imageBytes(path: string): Promise { + if (!IMAGE_PATH.test(path)) return null; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), IMAGE_TIMEOUT_MS); + try { + const res = await identifiedFetch( + new URL(trekPlacesBaseUrl() + path), + { Accept: 'image/webp' }, + controller.signal, + ); + if (!res.ok) return null; + const declared = Number(res.headers.get('content-length') ?? 0); + if (declared > IMAGE_MAX_BYTES) { + await res.body?.cancel(); + return null; + } + const bytes = Buffer.from(await res.arrayBuffer()); + return bytes.length && bytes.length <= IMAGE_MAX_BYTES ? bytes : null; + } catch { + return null; + } finally { + clearTimeout(timer); + } + } +} diff --git a/server/src/nest/maps/trek-places.client.ts b/server/src/nest/maps/trek-places.client.ts index d78e25214..81d7ac5e5 100644 --- a/server/src/nest/maps/trek-places.client.ts +++ b/server/src/nest/maps/trek-places.client.ts @@ -98,6 +98,7 @@ export interface TrekPlace { hours: { osm: string; source: string | null; sourceUrl: string | null } | null; /** Quoted from the place's own site, when it publishes one for machines. */ description?: { text: string; source: string | null; sourceUrl: string | null } | null; + images?: { url?: string; cover?: boolean }[] | null; /** Bumped by the service whenever the community values it overlays for this place change. */ community?: { version?: number } | null; score?: number; @@ -642,6 +643,7 @@ export interface TrekCommunityValues { contact?: { website?: string | null; phone?: string | null; email?: string | null } | null; hours?: { osm?: string | null } | null; description?: { text?: string | null } | null; + images?: { url?: string; cover?: boolean }[] | null; community?: { version?: number; fields?: string[] } | null; } diff --git a/server/src/nest/place-enrichment/place-enrichment.service.ts b/server/src/nest/place-enrichment/place-enrichment.service.ts index b488e8845..28f4d57da 100644 --- a/server/src/nest/place-enrichment/place-enrichment.service.ts +++ b/server/src/nest/place-enrichment/place-enrichment.service.ts @@ -23,6 +23,7 @@ import { import { GooglePlacesClient } from '../maps/providers/google-places.provider'; import { OsmClient } from '../maps/providers/osm.client'; import { WikimediaClient } from '../maps/providers/wikimedia.client'; +import { communityKeyOf, TrekCommunityService } from '../maps/trek-community.service'; import { trekPlacesById } from '../maps/trek-places.client'; import { PlacePhotoCacheService } from '../place-photos/place-photo-cache.service'; import { InjectRepository } from '@mikro-orm/nestjs'; @@ -165,15 +166,15 @@ interface CachePayload extends CachedEnrichment { } /** - * The community version the index serves for a gers place, from the details - * record the request carries; null for any other place, or when it says none. + * The community version the service serves for a gers or OSM place, from the + * details record the request carries; null for any other place, or when it says none. * A cached answer written under another version is read again, so a value * another installation contributed shows up on the next open, not a week later. * A sender who puts another number here only makes their own request compute * afresh: nothing of the record is what gets cached. */ function communityVersion(placeId: string, details: Record | null | undefined): number | null { - if (!placeId.startsWith('gers:')) return null; + if (!placeId.startsWith('gers:') && !communityKeyOf(placeId)) return null; const version = details?.community_version; return typeof version === 'number' && Number.isInteger(version) && version >= 0 ? version : null; } @@ -382,6 +383,7 @@ export class PlaceEnrichmentService { private readonly googlePlaces: GooglePlacesClient, private readonly osm: OsmClient, private readonly wiki: WikimediaClient, + private readonly community: TrekCommunityService, ) {} /** @@ -588,8 +590,26 @@ export class PlaceEnrichmentService { // One ranking pass over everything: the same file reaches us from several // rungs and only the page id catches that. const ranked = rankCommonsCandidates(commonsPool, COMMONS_CAP, { perAuthor: curated > 0 ? 2 : 1 }); - // Google leads the strip — its pictures are of the business itself. + // The community's pictures lead: a person checked each one in review, and + // they show the place as someone who was there saw it. Then Google, whose + // pictures are of the business itself. + // Only index and OSM places have any; off means nothing leaves for the API. + const communityPlace = placeId.startsWith('gers:') || communityKeyOf(placeId) !== null; + const community = communityPlace && this.maps.trekPlacesEnabled() ? await this.community.photoPaths(placeId) : []; const pending: PendingCandidate[] = [ + ...community.map((path) => ({ + identity: `community:${path}`, + candidate: { + key: '', + url: '', + attribution: 'TREK Community', + license: 'CC BY 4.0', + licenseUrl: 'https://creativecommons.org/licenses/by/4.0/', + sourceUrl: null, + source: 'community' as const, + }, + fetchBytes: () => this.community.imageBytes(path), + })), ...googleRefs.map((ref) => ({ identity: `google:${ref.name}`, candidate: { diff --git a/server/tests/unit/nest/maps.community-overlay.test.ts b/server/tests/unit/nest/maps.community-overlay.test.ts index b1fd2a5db..4427646fd 100644 --- a/server/tests/unit/nest/maps.community-overlay.test.ts +++ b/server/tests/unit/nest/maps.community-overlay.test.ts @@ -3,7 +3,8 @@ * what is laid over the record, and that a place without published values, or * a service that does not answer, leaves the details as they were. */ -import { communityKeyOf, osmDetailsWithCommunity, withCommunityValues } from '../../../src/nest/maps/community-overlay'; +import { osmDetailsWithCommunity, withCommunityValues } from '../../../src/nest/maps/community-overlay'; +import { communityKeyOf } from '../../../src/nest/maps/trek-community.service'; import { trekCommunityByKeys } from '../../../src/nest/maps/trek-places.client'; import { beforeEach, describe, expect, it, vi } from 'vitest'; diff --git a/server/tests/unit/nest/place-enrichment.brand.test.ts b/server/tests/unit/nest/place-enrichment.brand.test.ts index 5b5dab7be..ae020280d 100644 --- a/server/tests/unit/nest/place-enrichment.brand.test.ts +++ b/server/tests/unit/nest/place-enrichment.brand.test.ts @@ -16,6 +16,7 @@ import type { MapsService } from '../../../src/nest/maps/maps.service'; import type { GooglePlacesClient } from '../../../src/nest/maps/providers/google-places.provider'; import type { OsmClient } from '../../../src/nest/maps/providers/osm.client'; import type { WikimediaClient } from '../../../src/nest/maps/providers/wikimedia.client'; +import type { TrekCommunityService } from '../../../src/nest/maps/trek-community.service'; import { PlaceEnrichmentService } from '../../../src/nest/place-enrichment/place-enrichment.service'; import type { PlacePhotoCacheService } from '../../../src/nest/place-photos/place-photo-cache.service'; @@ -49,7 +50,11 @@ vi.mock('../../../src/utils/ssrfGuard', () => ({ /** Every seam enrichment reaches: the maps orchestrator and the outbound clients it injects beside it. */ type Seams = { [K in keyof T]: T[K] }; -type EnrichmentSeams = Seams & Seams & Seams & Seams; +type EnrichmentSeams = Seams & + Seams & + Seams & + Seams & + Seams; const REQ = { lat: 54.088, lng: 12.1409, name: "L'Osteria Rostock", placeId: 'ChIJosteria', lang: 'de' }; @@ -86,6 +91,9 @@ function mapsStub(over: Partial> = {}) { async () => null as { tags: Record; osmUrl: string | null; matchedName: string } | null, ), details: vi.fn(async () => ({ place: null })), + trekPlacesEnabled: vi.fn(() => true), + photoPaths: vi.fn(async () => [] as string[]), + imageBytes: vi.fn(async () => null as Buffer | null), ...over, } as unknown as EnrichmentSeams; } @@ -113,6 +121,7 @@ const make = (maps: EnrichmentSeams) => maps as unknown as GooglePlacesClient, maps as unknown as OsmClient, maps as unknown as WikimediaClient, + maps as unknown as TrekCommunityService, ); /** A place whose own identity is empty but that belongs to a chain. */ diff --git a/server/tests/unit/nest/place-enrichment.description.test.ts b/server/tests/unit/nest/place-enrichment.description.test.ts index 44643a067..890324eff 100644 --- a/server/tests/unit/nest/place-enrichment.description.test.ts +++ b/server/tests/unit/nest/place-enrichment.description.test.ts @@ -13,6 +13,7 @@ import type { MapsService } from '../../../src/nest/maps/maps.service'; import type { GooglePlacesClient } from '../../../src/nest/maps/providers/google-places.provider'; import type { OsmClient } from '../../../src/nest/maps/providers/osm.client'; import type { WikimediaClient } from '../../../src/nest/maps/providers/wikimedia.client'; +import type { TrekCommunityService } from '../../../src/nest/maps/trek-community.service'; import { PlaceEnrichmentService } from '../../../src/nest/place-enrichment/place-enrichment.service'; import type { PlacePhotoCacheService } from '../../../src/nest/place-photos/place-photo-cache.service'; @@ -54,7 +55,11 @@ vi.mock('../../../src/nest/maps/trek-places.client', async (importOriginal) => ( /** Every seam enrichment reaches: the maps orchestrator and the outbound clients it injects beside it. */ type Seams = { [K in keyof T]: T[K] }; -type EnrichmentSeams = Seams & Seams & Seams & Seams; +type EnrichmentSeams = Seams & + Seams & + Seams & + Seams & + Seams; 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' }; @@ -86,6 +91,8 @@ function mapsStub(over: Partial> = {}) { ), details: vi.fn(async () => ({ place: null })), trekPlacesEnabled: vi.fn(() => true), + photoPaths: vi.fn(async () => [] as string[]), + imageBytes: vi.fn(async () => null as Buffer | null), ...over, } as unknown as EnrichmentSeams; } @@ -113,6 +120,7 @@ const make = (maps: EnrichmentSeams) => maps as unknown as GooglePlacesClient, maps as unknown as OsmClient, maps as unknown as WikimediaClient, + maps as unknown as TrekCommunityService, ); beforeEach(() => { diff --git a/server/tests/unit/nest/place-enrichment.service.test.ts b/server/tests/unit/nest/place-enrichment.service.test.ts index 8001dee1a..7508b7b21 100644 --- a/server/tests/unit/nest/place-enrichment.service.test.ts +++ b/server/tests/unit/nest/place-enrichment.service.test.ts @@ -19,6 +19,7 @@ import type { MapsService } from '../../../src/nest/maps/maps.service'; import type { GooglePlacesClient } from '../../../src/nest/maps/providers/google-places.provider'; import type { OsmClient } from '../../../src/nest/maps/providers/osm.client'; import type { WikimediaClient } from '../../../src/nest/maps/providers/wikimedia.client'; +import type { TrekCommunityService } from '../../../src/nest/maps/trek-community.service'; import { candidateKey, PlaceEnrichmentService, @@ -60,7 +61,11 @@ vi.mock('../../../src/nest/maps/trek-places.client', async (importOriginal) => ( /** Every seam enrichment reaches: the maps orchestrator and the outbound clients it injects beside it. */ type Seams = { [K in keyof T]: T[K] }; -type EnrichmentSeams = Seams & Seams & Seams & Seams; +type EnrichmentSeams = Seams & + Seams & + Seams & + Seams & + Seams; function appSettingsStub(): AppSettingsRepository { return { getValue: mockGetValue } as unknown as AppSettingsRepository; @@ -99,6 +104,8 @@ function mapsStub(over: Partial> = {}) { ), details: vi.fn(async () => ({ place: null })), trekPlacesEnabled: vi.fn(() => true), + photoPaths: vi.fn(async () => [] as string[]), + imageBytes: vi.fn(async () => null as Buffer | null), ...over, } as unknown as EnrichmentSeams; } @@ -124,6 +131,7 @@ function make(maps: EnrichmentSeams, cache: PlacePhotoCacheService) { maps as unknown as GooglePlacesClient, maps as unknown as OsmClient, maps as unknown as WikimediaClient, + maps as unknown as TrekCommunityService, ); } @@ -227,6 +235,38 @@ describe('collectPhotos', () => { expect(maps.fetchGooglePhotoRefs).not.toHaveBeenCalled(); }); + it('ENRICH-005b: puts the community pictures first, credited to the TREK community', 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()]), + photoPaths: vi.fn(async () => ['/v1/contrib/img/0123456789abcdef.webp']), + imageBytes: vi.fn(async () => Buffer.from([4, 5, 6])), + }); + + const out = await make(maps, cacheStub()).enrich(1, OSM_REQ); + + expect(maps.photoPaths).toHaveBeenCalledWith('way:12345'); + expect(maps.imageBytes).toHaveBeenCalledWith('/v1/contrib/img/0123456789abcdef.webp'); + expect(out.photos[0]).toMatchObject({ + source: 'community', + attribution: 'TREK Community', + license: 'CC BY 4.0', + sourceUrl: null, + }); + }); + + it('ENRICH-005c: asks no community pictures for a Google place or when the API is off', async () => { + const maps = mapsStub({ photoPaths: vi.fn(async () => ['/v1/contrib/img/0123456789abcdef.webp']) }); + await make(maps, cacheStub()).enrich(1, REQ); + expect(maps.photoPaths).not.toHaveBeenCalled(); + + const off = mapsStub({ trekPlacesEnabled: vi.fn(() => false), photoPaths: vi.fn(async () => []) }); + await make(off, cacheStub()).enrich(1, OSM_REQ); + expect(off.photoPaths).not.toHaveBeenCalled(); + }); + it('ENRICH-005: puts Google pictures ahead of Commons when a key is configured', async () => { const maps = mapsStub({ getMapsKey: vi.fn(() => 'key'), diff --git a/server/tests/unit/nest/trek-community.service.test.ts b/server/tests/unit/nest/trek-community.service.test.ts new file mode 100644 index 000000000..1f1d3fe7e --- /dev/null +++ b/server/tests/unit/nest/trek-community.service.test.ts @@ -0,0 +1,81 @@ +/** + * The community's pictures for a place: which ones the service publishes + * (cover first, only paths of its own picture store) and their bytes, fetched + * with the installation's key and capped. + */ +import { TrekCommunityService } from '../../../src/nest/maps/trek-community.service'; +import { trekCommunityByKeys, trekPlacesById } from '../../../src/nest/maps/trek-places.client'; +import { identifiedFetch } from '../../../src/nest/maps/trek-places.fetch'; + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../src/nest/maps/trek-places.client', () => ({ + trekCommunityByKeys: vi.fn(), + trekPlacesById: vi.fn(), + trekPlacesBaseUrl: () => 'https://places.example', +})); +vi.mock('../../../src/nest/maps/trek-places.fetch', () => ({ identifiedFetch: vi.fn() })); + +const byKeys = vi.mocked(trekCommunityByKeys); +const byId = vi.mocked(trekPlacesById); +const fetchWithKey = vi.mocked(identifiedFetch); + +const A = '/v1/contrib/img/aaaaaaaaaaaaaaaa.webp'; +const B = '/v1/contrib/img/bbbbbbbbbbbbbbbb.webp'; + +beforeEach(() => { + byKeys.mockReset(); + byId.mockReset(); + fetchWithKey.mockReset(); +}); + +describe('TrekCommunityService.photoPaths', () => { + it('TCOM-001: reads an OSM place from /v1/community, cover first', async () => { + byKeys.mockResolvedValue({ 'osm:way/7': { images: [{ url: A }, { url: B, cover: true }] } }); + expect(await new TrekCommunityService().photoPaths('way:7')).toEqual([B, A]); + expect(byKeys).toHaveBeenCalledWith(['osm:way/7']); + }); + + it('TCOM-002: reads an index place from its own row', async () => { + byId.mockResolvedValue({ images: [{ url: A, cover: true }] } as never); + expect(await new TrekCommunityService().photoPaths('gers:abc')).toEqual([A]); + expect(byId).toHaveBeenCalledWith('abc'); + }); + + it('TCOM-003: drops anything that is not a path of the picture store', async () => { + byKeys.mockResolvedValue({ + 'osm:node/1': { + images: [{ url: 'https://evil.example/x.webp' }, { url: '/v1/contrib/img/../gate' }, { url: A }], + }, + }); + expect(await new TrekCommunityService().photoPaths('node:1')).toEqual([A]); + }); + + it('TCOM-004: nothing for another kind of place, or when the service fails', async () => { + expect(await new TrekCommunityService().photoPaths('ChIJgoogle')).toEqual([]); + byId.mockRejectedValue(new Error('down')); + expect(await new TrekCommunityService().photoPaths('gers:abc')).toEqual([]); + }); +}); + +describe('TrekCommunityService.imageBytes', () => { + it('TCOM-005: fetches a store path with the key and returns the bytes', async () => { + fetchWithKey.mockResolvedValue(new Response(new Uint8Array([1, 2, 3]), { status: 200 })); + expect(await new TrekCommunityService().imageBytes(A)).toEqual(Buffer.from([1, 2, 3])); + expect(String(fetchWithKey.mock.calls[0]?.[0])).toBe(`https://places.example${A}`); + }); + + it('TCOM-006: never fetches another path, and a failed or oversized answer is null', async () => { + const svc = new TrekCommunityService(); + expect(await svc.imageBytes('/v1/place/abc')).toBeNull(); + expect(fetchWithKey).not.toHaveBeenCalled(); + fetchWithKey.mockResolvedValueOnce(new Response(null, { status: 404 })); + expect(await svc.imageBytes(A)).toBeNull(); + fetchWithKey.mockResolvedValueOnce( + new Response(new Uint8Array([1]), { status: 200, headers: { 'content-length': '9000000' } }), + ); + expect(await svc.imageBytes(A)).toBeNull(); + fetchWithKey.mockRejectedValueOnce(new Error('timeout')); + expect(await svc.imageBytes(A)).toBeNull(); + }); +}); diff --git a/shared/src/maps/maps.schema.ts b/shared/src/maps/maps.schema.ts index b43cbdb19..ba507d4ae 100644 --- a/shared/src/maps/maps.schema.ts +++ b/shared/src/maps/maps.schema.ts @@ -159,7 +159,7 @@ export type MapsResolveUrlResult = z.infer; * Everything nullable is genuinely optional per source — Commons gives us a * licence and an author, Google gives us neither in a form we may reproduce. */ -export const placePhotoSourceSchema = z.enum(['google', 'wikimedia', 'wikipedia', 'cached']); +export const placePhotoSourceSchema = z.enum(['google', 'wikimedia', 'wikipedia', 'cached', 'community']); export type PlacePhotoSource = z.infer; export const placePhotoCandidateSchema = z.object({