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({