From e9f9cfb5d536f9f28072be3759581653dcb2db84 Mon Sep 17 00:00:00 2001 From: Maurice Date: Sun, 9 Aug 2026 22:31:31 +0200 Subject: [PATCH] Add the place enrichment endpoint Fans the new provider primitives out into one answer for a place that is being looked at but not saved yet: a strip of pictures and a description, each with its source. Free sources are the normal case - most instances have no Google key at all, so Commons and the OSM tags have to carry this on their own and Google is what an admin adds on top. Pictures are copied into the existing photo cache under "~pN" keys rather than hotlinked: Google's photo URLs expire (migration 107 had to repair exactly that once), and a provider URL in the strip would send every viewer's address to Google or Wikimedia. Whatever nobody picks is unreferenced and the nightly sweep clears it out. The result is cached for a week in place_details_cache under a third expanded value, so no migration. Rate limited per user, and a provider being down answers empty rather than failing the dialog. --- server/src/nest/app.module.ts | 3 +- .../place-enrichment.controller.ts | 66 +++ .../place-enrichment/place-enrichment.dto.ts | 9 + .../place-enrichment.module.ts | 22 + .../place-enrichment.service.ts | 318 +++++++++++ server/tests/e2e/place-enrichment.e2e.test.ts | 216 +++++++ .../nest/place-enrichment.controller.test.ts | 76 +++ .../nest/place-enrichment.service.test.ts | 530 ++++++++++++++++++ 8 files changed, 1239 insertions(+), 1 deletion(-) create mode 100644 server/src/nest/place-enrichment/place-enrichment.controller.ts create mode 100644 server/src/nest/place-enrichment/place-enrichment.dto.ts create mode 100644 server/src/nest/place-enrichment/place-enrichment.module.ts create mode 100644 server/src/nest/place-enrichment/place-enrichment.service.ts create mode 100644 server/tests/e2e/place-enrichment.e2e.test.ts create mode 100644 server/tests/unit/nest/place-enrichment.controller.test.ts create mode 100644 server/tests/unit/nest/place-enrichment.service.test.ts diff --git a/server/src/nest/app.module.ts b/server/src/nest/app.module.ts index 366c2636e..e06396c4d 100644 --- a/server/src/nest/app.module.ts +++ b/server/src/nest/app.module.ts @@ -12,6 +12,7 @@ import { AirportsModule } from './airports/airports.module'; import { ConfigModule } from './config/config.module'; import { SystemNoticesModule } from './system-notices/system-notices.module'; import { MapsModule } from './maps/maps.module'; +import { PlaceEnrichmentModule } from './place-enrichment/place-enrichment.module'; import { CategoriesModule } from './categories/categories.module'; import { TagsModule } from './tags/tags.module'; import { NotificationsModule } from './notifications/notifications.module'; @@ -60,7 +61,7 @@ import { IdempotencyInterceptor } from './common/idempotency.interceptor'; * migrated. */ @Module({ - imports: [AppConfigModule, DatabaseModule, RealtimeModule, McpModule.forRoot({ accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess }), WeatherModule, HelpModule, AirportsModule, ConfigModule, SystemNoticesModule, MapsModule, CategoriesModule, TagsModule, NotificationsModule, AtlasModule, VacayModule, PackingModule, TodoModule, BudgetModule, ReservationsModule, DaysModule, AssignmentsModule, PlacesModule, TripsModule, CollabModule, FilesModule, PhotosModule, MemoriesModule, AirtrailModule, JourneyModule, CollectionsModule, ShareModule, TripInviteModule, TransitModule, FeedsModule, SettingsModule, BackupModule, AuthModule, OidcModule, OauthModule, AdminModule, AddonsModule, AuditModule, PermissionsModule, PluginsModule, BookingImportModule, LlmParseModule], + imports: [AppConfigModule, DatabaseModule, RealtimeModule, McpModule.forRoot({ accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess }), WeatherModule, HelpModule, AirportsModule, ConfigModule, SystemNoticesModule, MapsModule, PlaceEnrichmentModule, CategoriesModule, TagsModule, NotificationsModule, AtlasModule, VacayModule, PackingModule, TodoModule, BudgetModule, ReservationsModule, DaysModule, AssignmentsModule, PlacesModule, TripsModule, CollabModule, FilesModule, PhotosModule, MemoriesModule, AirtrailModule, JourneyModule, CollectionsModule, ShareModule, TripInviteModule, TransitModule, FeedsModule, SettingsModule, BackupModule, AuthModule, OidcModule, OauthModule, AdminModule, AddonsModule, AuditModule, PermissionsModule, PluginsModule, BookingImportModule, LlmParseModule], controllers: [HealthController], providers: [ HealthService, diff --git a/server/src/nest/place-enrichment/place-enrichment.controller.ts b/server/src/nest/place-enrichment/place-enrichment.controller.ts new file mode 100644 index 000000000..d11016506 --- /dev/null +++ b/server/src/nest/place-enrichment/place-enrichment.controller.ts @@ -0,0 +1,66 @@ +import { Body, Controller, Get, HttpCode, HttpException, Param, Post, Req, UseGuards } from '@nestjs/common'; +import type { Request } from 'express'; +import type { MapsPlaceEnrichmentResult } from '@trek/shared'; +import type { User } from '../../types'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { CurrentUser } from '../auth/current-user.decorator'; +import { RateLimitService } from '../common/rate-limit.service'; +import { PlaceEnrichmentService } from './place-enrichment.service'; +import { PlaceEnrichmentDto } from './place-enrichment.dto'; + +const MINUTE = 60_000; +/** + * Generous enough that browsing a shortlist never trips it, tight enough that a + * script cannot walk the provider APIs on an admin's key. One request covers one + * selected search result, and the answer is cached for a week afterwards. + */ +const MAX_PER_MINUTE = 60; + +/** + * /api/maps/enrichment — pictures and a description for a place that is being + * looked at but has not been saved. + * + * Sits next to the maps endpoints because that is where the client already + * looks, but in its own module: this is the only maps-adjacent route that fans + * out to several providers per call, and it is the only one with a rate limit. + */ +@Controller('api/maps/enrichment') +@UseGuards(JwtAuthGuard) +export class PlaceEnrichmentController { + constructor( + private readonly enrichment: PlaceEnrichmentService, + private readonly rl: RateLimitService, + ) {} + + @Post() + @HttpCode(200) // Nest would otherwise default POST to 201; every other maps route answers 200. + async enrich( + @CurrentUser() user: User, + @Req() req: Request, + @Body() body: PlaceEnrichmentDto, + ): Promise { + // Keyed by user, not by IP: the cost lands on the key of whoever is signed + // in, and a household behind one address should not share a budget. + if (!this.rl.check('place_enrichment', String(user.id), MAX_PER_MINUTE, MINUTE, Date.now())) { + throw new HttpException({ error: 'Too many requests' }, 429); + } + + try { + return await this.enrichment.enrich(user.id, body); + } catch (err: unknown) { + // A provider being unreachable must not break adding a place — the column + // is an aid, not a step. Log it and answer with an empty result. + console.error('Place enrichment error:', err); + return { photos: [], description: null }; + } + } + + /** + * Author and licence for an already-stored picture. Read-only and purely + * local — no provider is contacted, so it needs no rate limit. + */ + @Get('credit/:key') + credit(@Param('key') key: string): { credit: string | null } { + return this.enrichment.credit(key); + } +} diff --git a/server/src/nest/place-enrichment/place-enrichment.dto.ts b/server/src/nest/place-enrichment/place-enrichment.dto.ts new file mode 100644 index 000000000..4aa68de3c --- /dev/null +++ b/server/src/nest/place-enrichment/place-enrichment.dto.ts @@ -0,0 +1,9 @@ +import { createZodDto } from 'nestjs-zod'; +import { mapsPlaceEnrichmentRequestSchema } from '@trek/shared'; + +/** + * Server-side createZodDto wrapper over the @trek/shared enrichment contract. + * The global ZodValidationPipe validates the body by metatype — the Zod schema + * in shared/ stays the single source of truth for the wire contract. + */ +export class PlaceEnrichmentDto extends createZodDto(mapsPlaceEnrichmentRequestSchema) {} diff --git a/server/src/nest/place-enrichment/place-enrichment.module.ts b/server/src/nest/place-enrichment/place-enrichment.module.ts new file mode 100644 index 000000000..f9dffd6fb --- /dev/null +++ b/server/src/nest/place-enrichment/place-enrichment.module.ts @@ -0,0 +1,22 @@ +import { Module } from '@nestjs/common'; +import { MapsModule } from '../maps/maps.module'; +import { PlacePhotosModule } from '../place-photos/place-photos.module'; +import { RateLimitModule } from '../common/rate-limit.module'; +import { PlaceEnrichmentController } from './place-enrichment.controller'; +import { PlaceEnrichmentService } from './place-enrichment.service'; + +/** + * Place enrichment (L4 leaf module). Registered in AppModule. + * + * Consumes MapsService for the provider primitives and PlacePhotoCacheService + * for the picture bytes — both already single instances in the container, which + * matters here: the photo cache's stampede guard and the maps concurrency limit + * only hold if this module queues behind the same ones everything else uses. + * Nothing outside the container consumes it, so there is no bridge. + */ +@Module({ + imports: [MapsModule, PlacePhotosModule, RateLimitModule], + controllers: [PlaceEnrichmentController], + providers: [PlaceEnrichmentService], +}) +export class PlaceEnrichmentModule {} diff --git a/server/src/nest/place-enrichment/place-enrichment.service.ts b/server/src/nest/place-enrichment/place-enrichment.service.ts new file mode 100644 index 000000000..925464ab6 --- /dev/null +++ b/server/src/nest/place-enrichment/place-enrichment.service.ts @@ -0,0 +1,318 @@ +import { Injectable } from '@nestjs/common'; +import type { + MapsPlaceEnrichmentRequest, + MapsPlaceEnrichmentResult, + PlaceDescription, + PlacePhotoCandidate, +} from '@trek/shared'; +import { safeFetchFollow } from '../../utils/ssrfGuard'; +import { DatabaseService } from '../database/database.service'; +import { MapsService, withPhotoFetchSlot } from '../maps/maps.service'; +import { isGooglePlaceId } from '../maps/maps.helpers'; +import { PlacePhotoCacheService } from '../place-photos/place-photo-cache.service'; + +/** + * How many pictures each source may contribute. + * + * Commons is generous because geosearch returns the whole strip in one request + * either way. Google is not: the reference list is one billed call, but every + * picture we actually show costs a second billed /media call, on the key of an + * admin who may not be watching the bill. Three is enough to choose from. + */ +const COMMONS_CAP = 5; +const GOOGLE_CAP = 3; + +/** + * Enrichment results live in the same table as the plain details cache, under a + * third `expanded` value. Descriptions and the set of pictures near a place + * change on the order of months, so a week is short enough to pick up edits and + * long enough that a re-opened planner never pays for the same place twice. + */ +const CACHE_KIND = 2; +const CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000; + +/** Cache key for one candidate picture. Deliberately not a bare place id. */ +function candidateKey(placeId: string, index: number): string { + return `${placeId}~p${index}`; +} + +/** + * The credit line stored alongside a cached picture. + * + * `google_place_photo_meta` has one TEXT column for this, so author and licence + * go in together rather than the author alone. A picture chosen here can end up + * on the map, in the PDF and behind a share link long after the dialog is gone, + * and at that point this string is the only record of who made it. + */ +export function creditLine(attribution: string | null, license: string | null): string | null { + if (attribution && license) return `${attribution} · ${license}`; + return attribution || license || null; +} + +interface CachedEnrichment { + photos: PlacePhotoCandidate[]; + description: PlaceDescription | null; +} + +/** + * Photos and a description for a place the user is looking at but has not saved + * yet — the detail column next to the search field in the add-place dialog. + * + * Two things shape this service. First, most TREK instances have no Google key, + * so the free path (OpenStreetMap tags, Wikimedia Commons, Wikipedia) is the + * normal case and has to stand on its own; Google is an addition an admin opts + * into, not the design centre. Second, every picture shown here is somebody + * else's work, so a candidate carries its author and licence from the start + * rather than having attribution bolted on later. + */ +@Injectable() +export class PlaceEnrichmentService { + constructor( + private readonly database: DatabaseService, + private readonly maps: MapsService, + private readonly photoCache: PlacePhotoCacheService, + ) {} + + /** + * Fail-open, unlike the three older places_* switches: an instance that has + * never seen this setting gets the feature. Reading it the other way round + * would mean backfilling a row for every existing install just to keep them + * working, and there is nothing here that warrants a migration. + */ + enrichDisabled(): boolean { + const row = this.database.get<{ value: string }>('SELECT value FROM app_settings WHERE key = ?', 'places_enrich_enabled'); + return row?.value === 'false'; + } + + async enrich(userId: number, req: MapsPlaceEnrichmentRequest): Promise { + if (this.enrichDisabled()) return { photos: [], description: null, disabled: true }; + + const placeId = req.placeId?.trim() || `coords:${req.lat}:${req.lng}`; + const lang = req.lang; + + const cached = this.readCache(placeId, lang); + if (cached) return cached; + + // Both halves are independent provider fan-outs; running them in sequence + // would make the column wait for the slower one twice over. + const [photos, description] = await Promise.all([ + this.collectPhotos(userId, placeId, req), + this.collectDescription(userId, placeId, req), + ]); + + const result: CachedEnrichment = { photos, description }; + this.writeCache(placeId, lang, result); + return result; + } + + /** + * The stored credit for a cached picture, looked up by its cache key. + * + * The picker shows author and licence at the moment of choosing, but the + * obligation does not end there — the place inspector reads this so a picture + * that was chosen weeks ago still names whoever made it. + */ + credit(key: string): { credit: string | null } { + return { credit: this.photoCache.get(key)?.attribution ?? null }; + } + + // ── Photos ───────────────────────────────────────────────────────────────── + + private async collectPhotos( + userId: number, + placeId: string, + req: MapsPlaceEnrichmentRequest, + ): Promise { + const out: PlacePhotoCandidate[] = []; + + // Google first when it is available at all — its pictures are of the place + // itself, while Commons geosearch finds whatever was photographed nearby. + const apiKey = this.maps.getMapsKey(userId); + if (apiKey && !this.maps.photosDisabled() && isGooglePlaceId(placeId)) { + const refs = await this.maps.fetchGooglePhotoRefs(placeId, apiKey, GOOGLE_CAP); + for (const ref of refs) { + const key = candidateKey(placeId, out.length); + const url = await this.storeCandidate(key, creditLine(ref.attribution, null), () => + this.maps.fetchGooglePhotoBytes(ref.name, apiKey), + ); + if (!url) continue; + out.push({ + key, + url, + attribution: ref.attribution, + // Google grants no reusable licence for these; the author line it + // hands back is all we may show, so the rest stays honestly empty. + license: null, + licenseUrl: null, + sourceUrl: null, + source: 'google', + }); + } + } + + const commons = await this.maps.fetchCommonsCandidates(req.lat, req.lng, COMMONS_CAP); + for (const candidate of commons) { + const key = candidateKey(placeId, out.length); + const url = await this.storeCandidate(key, creditLine(candidate.attribution, candidate.license), () => + this.fetchRemoteBytes(candidate.photoUrl), + ); + if (!url) continue; + out.push({ + key, + url, + attribution: candidate.attribution, + license: candidate.license, + licenseUrl: candidate.licenseUrl, + sourceUrl: candidate.sourceUrl, + source: 'wikimedia', + }); + } + + return out; + } + + /** + * Puts one picture in the shared photo cache and returns its proxy URL. + * + * Candidates are never hotlinked. A provider URL in the strip would send the + * IP of everyone who opens the dialog to Google or Wikimedia, and Google's + * photo URLs expire — which is exactly the bug migration 107 had to repair + * for the pictures already stored. Whatever nobody picks is unreferenced and + * the nightly sweep removes it. + */ + private async storeCandidate( + key: string, + credit: string | null, + fetchBytes: () => Promise, + ): Promise { + const hit = this.photoCache.get(key); + if (hit) return hit.photoUrl; + + const bytes = await withPhotoFetchSlot(fetchBytes); + if (!bytes?.length) return null; + + try { + const stored = await this.photoCache.put(key, bytes, credit); + return stored.photoUrl; + } catch (err) { + console.error('Failed to cache place enrichment photo:', err); + return null; + } + } + + /** Downloads a non-Google image, re-checking every redirect hop against the SSRF guard. */ + private async fetchRemoteBytes(url: string): Promise { + try { + const res = await safeFetchFollow(url, undefined, { bypassInternalIpAllowed: true }); + if (!res.ok) return null; + const bytes = Buffer.from(await res.arrayBuffer()); + return bytes.length ? bytes : null; + } catch { + return null; + } + } + + // ── Description ──────────────────────────────────────────────────────────── + + private async collectDescription( + userId: number, + placeId: string, + req: MapsPlaceEnrichmentRequest, + ): Promise { + const details = await this.readDetails(userId, placeId, req.lang); + + // OpenStreetMap first: it costs nothing, it is already fetched, and a + // description someone wrote into the map data beats a generated blurb. + const osmSummary = typeof details?.summary === 'string' ? details.summary.trim() : ''; + if (osmSummary && details?.source === 'openstreetmap') { + return { + text: osmSummary, + source: 'osm', + sourceUrl: typeof details.osm_url === 'string' ? details.osm_url : null, + license: 'ODbL 1.0', + }; + } + + const apiKey = this.maps.getMapsKey(userId); + if (apiKey && !this.maps.detailsDisabled() && isGooglePlaceId(placeId)) { + const summary = await this.maps.fetchEditorialSummary(placeId, apiKey, req.lang); + if (summary) { + return { + text: summary, + source: 'google', + sourceUrl: typeof details?.google_maps_url === 'string' ? details.google_maps_url : null, + license: null, + }; + } + } + + // Wikipedia last, and only when the place carries a wiki tag. Looking the + // article up by name lands on the wrong one for anything ambiguous, and a + // confident description of somewhere else is worse than none. + const wikipediaTag = typeof details?.wikipedia === 'string' ? details.wikipedia : null; + const extract = await this.maps.fetchWikipediaExtract(wikipediaTag); + if (extract) { + return { text: extract.text, source: 'wikipedia', sourceUrl: extract.sourceUrl, license: 'CC BY-SA 4.0' }; + } + + return null; + } + + /** + * The place's own details, for the OSM tags and the Maps URL. + * + * This is the same lookup the dialog already made when the user picked the + * search result, and it is row-cached, so in practice it is a cache read + * rather than a second provider call. + */ + private async readDetails( + userId: number, + placeId: string, + lang: string | undefined, + ): Promise | null> { + try { + const { place } = await this.maps.details(userId, placeId, lang); + return place ?? null; + } catch { + return null; + } + } + + // ── Result cache ─────────────────────────────────────────────────────────── + + private readCache(placeId: string, lang: string | undefined): CachedEnrichment | null { + try { + const row = this.database.get<{ payload_json: string; fetched_at: number }>( + 'SELECT payload_json, fetched_at FROM place_details_cache WHERE place_id = ? AND lang = ? AND expanded = ?', + placeId, + lang ?? '', + CACHE_KIND, + ); + if (!row || Date.now() - row.fetched_at >= CACHE_TTL_MS) return null; + const parsed = JSON.parse(row.payload_json) as CachedEnrichment; + + // A cached candidate is only usable while its bytes are still on disk — + // the nightly sweep removes pictures nobody picked, and serving their + // proxy URLs afterwards would fill the strip with broken images. + const photos = parsed.photos.filter((photo) => this.photoCache.get(photo.key)); + return { photos, description: parsed.description ?? null }; + } catch { + return null; + } + } + + private writeCache(placeId: string, lang: string | undefined, value: CachedEnrichment): void { + try { + this.database.run( + 'INSERT OR REPLACE INTO place_details_cache (place_id, lang, expanded, payload_json, fetched_at) VALUES (?, ?, ?, ?, ?)', + placeId, + lang ?? '', + CACHE_KIND, + JSON.stringify(value), + Date.now(), + ); + } catch (err) { + console.error('Failed to cache place enrichment:', err); + } + } +} diff --git a/server/tests/e2e/place-enrichment.e2e.test.ts b/server/tests/e2e/place-enrichment.e2e.test.ts new file mode 100644 index 000000000..73f845e92 --- /dev/null +++ b/server/tests/e2e/place-enrichment.e2e.test.ts @@ -0,0 +1,216 @@ +/** + * Place enrichment e2e — exercises POST /api/maps/enrichment through the real + * JwtAuthGuard and the real Zod validation pipe against a temp SQLite db. The + * provider fan-out on the container's MapsService is stubbed (no outbound + * HTTP); the photo cache is stubbed on its own instance so nothing touches disk. + */ +import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest'; +import request from 'supertest'; +import cookieParser from 'cookie-parser'; +import type { Server } from 'http'; +import { Test } from '@nestjs/testing'; +import { seedUser, sessionCookie } from './harness'; + +const { db } = vi.hoisted(() => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const Database = require('better-sqlite3'); + const tmp = new Database(':memory:'); + tmp.exec('PRAGMA journal_mode = WAL'); + tmp.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL, + email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0, + maps_api_key TEXT);`); + tmp.exec('CREATE TABLE app_settings (key TEXT PRIMARY KEY, value TEXT);'); + // The enrichment result cache shares the details cache table (expanded = 2). + tmp.exec(`CREATE TABLE place_details_cache (place_id TEXT NOT NULL, lang TEXT NOT NULL DEFAULT '', + expanded INTEGER NOT NULL DEFAULT 0, payload_json TEXT NOT NULL, fetched_at INTEGER NOT NULL, + PRIMARY KEY (place_id, lang, expanded));`); + return { db: tmp }; +}); + +vi.mock('../../src/db/database', () => ({ db, closeDb: () => {}, reinitialize: () => {} })); + +import { PlaceEnrichmentModule } from '../../src/nest/place-enrichment/place-enrichment.module'; +import { MapsService } from '../../src/nest/maps/maps.service'; +import { PlacePhotoCacheService } from '../../src/nest/place-photos/place-photo-cache.service'; +import { DatabaseModule } from '../../src/nest/database/database.module'; +import { RateLimitService } from '../../src/nest/common/rate-limit.service'; +import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; +import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe'; + +const BODY = { lat: 50.9, lng: 6.96, name: 'Museum Ludwig', placeId: 'way:12345' }; + +describe('Place enrichment e2e (real auth guard + real validation pipe)', () => { + let server: Server; + let app: Awaited>; + let maps: MapsService; + + async function build() { + const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, PlaceEnrichmentModule] }).compile(); + const nest = moduleRef.createNestApplication(); + nest.use(cookieParser()); + nest.useGlobalFilters(new TrekExceptionFilter()); + // The APP_PIPE from app.module.ts isn't in this focused graph — wire it by + // hand so the body really is validated against the shared Zod contract. + nest.useGlobalPipes(new ZodValidationPipe()); + await nest.init(); + return nest; + } + + beforeAll(async () => { + seedUser(db as never, { id: 1 }); + app = await build(); + server = app.getHttpServer(); + + maps = app.get(MapsService); + const photoCache = app.get(PlacePhotoCacheService); + vi.spyOn(photoCache, 'get').mockReturnValue(null); + vi.spyOn(photoCache, 'put').mockImplementation(async (key: string, _b: Buffer, attribution: string | null) => ({ + photoUrl: `/api/maps/place-photo/${encodeURIComponent(key)}/bytes`, + filePath: `/tmp/${key}.jpg`, + attribution, + })); + }); + + afterAll(async () => { + await app.close(); + }); + + beforeEach(() => { + db.prepare('DELETE FROM app_settings').run(); + db.prepare('DELETE FROM place_details_cache').run(); + app.get(RateLimitService).reset('place_enrichment'); + // spyOn hands back the same spy on a second call, so the call counts carry + // over between tests unless they are cleared explicitly. + vi.spyOn(maps, 'fetchCommonsCandidates').mockClear().mockResolvedValue([]); + vi.spyOn(maps, 'fetchWikipediaExtract').mockClear().mockResolvedValue(null); + vi.spyOn(maps, 'details').mockClear().mockResolvedValue({ place: null }); + }); + + it('401 without a session cookie', async () => { + const res = await request(server).post('/api/maps/enrichment').send(BODY); + expect(res.status).toBe(401); + }); + + it('400 when the coordinates are missing', async () => { + const res = await request(server) + .post('/api/maps/enrichment') + .set('Cookie', sessionCookie(1)) + .send({ name: 'Museum Ludwig' }); + expect(res.status).toBe(400); + expect(res.body).toHaveProperty('error'); + }); + + it('400 when a coordinate arrives as a string', async () => { + const res = await request(server) + .post('/api/maps/enrichment') + .set('Cookie', sessionCookie(1)) + .send({ ...BODY, lat: '50.9' }); + expect(res.status).toBe(400); + }); + + it('200 with an empty result when no provider has anything (POST stays 200)', async () => { + const res = await request(server).post('/api/maps/enrichment').set('Cookie', sessionCookie(1)).send(BODY); + expect(res.status).toBe(200); + expect(res.body).toEqual({ photos: [], description: null }); + }); + + it('200 with a Commons candidate carrying its licence', async () => { + vi.spyOn(maps, 'fetchCommonsCandidates').mockResolvedValue([ + { + 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', + }, + ]); + // The bytes download goes through the SSRF-guarded fetch — stub the network. + vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer }))); + + const res = await request(server).post('/api/maps/enrichment').set('Cookie', sessionCookie(1)).send(BODY); + + expect(res.status).toBe(200); + expect(res.body.photos).toHaveLength(1); + expect(res.body.photos[0]).toMatchObject({ + key: 'way:12345~p0', + attribution: 'Alice', + license: 'CC BY-SA 4.0', + source: 'wikimedia', + }); + vi.unstubAllGlobals(); + }); + + it('200 with the disabled envelope once an admin switches enrichment off', async () => { + db.prepare("INSERT INTO app_settings (key, value) VALUES ('places_enrich_enabled', 'false')").run(); + + const res = await request(server).post('/api/maps/enrichment').set('Cookie', sessionCookie(1)).send(BODY); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ photos: [], description: null, disabled: true }); + expect(maps.fetchCommonsCandidates).not.toHaveBeenCalled(); + }); + + it('stays enabled on an instance that has never seen the setting', async () => { + const res = await request(server).post('/api/maps/enrichment').set('Cookie', sessionCookie(1)).send(BODY); + expect(res.body.disabled).toBeUndefined(); + expect(maps.fetchCommonsCandidates).toHaveBeenCalled(); + }); + + it('serves the second request for the same place from the row cache', async () => { + vi.spyOn(maps, 'details').mockResolvedValue({ + place: { summary: 'Ein Museum in Köln.', source: 'openstreetmap', osm_url: 'https://osm.org/way/12345' }, + }); + + const first = await request(server).post('/api/maps/enrichment').set('Cookie', sessionCookie(1)).send(BODY); + expect(first.body.description).toMatchObject({ text: 'Ein Museum in Köln.', source: 'osm', license: 'ODbL 1.0' }); + + (maps.details as unknown as ReturnType).mockClear(); + const second = await request(server).post('/api/maps/enrichment').set('Cookie', sessionCookie(1)).send(BODY); + + expect(second.body.description).toEqual(first.body.description); + expect(maps.details).not.toHaveBeenCalled(); + }); + + it('200 with the stored credit for a cached picture', async () => { + const photoCache = app.get(PlacePhotoCacheService); + vi.spyOn(photoCache, 'get').mockReturnValueOnce({ + photoUrl: '/x', filePath: '/tmp/x', attribution: 'Alice · CC BY-SA 4.0', + }); + + const res = await request(server) + .get('/api/maps/enrichment/credit/' + encodeURIComponent('way:12345~p0')) + .set('Cookie', sessionCookie(1)); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ credit: 'Alice · CC BY-SA 4.0' }); + }); + + it('200 with a null credit for an image the cache does not know', async () => { + const res = await request(server) + .get('/api/maps/enrichment/credit/' + encodeURIComponent('uploaded.jpg')) + .set('Cookie', sessionCookie(1)); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ credit: null }); + }); + + it('401 on the credit route without a session cookie', async () => { + const res = await request(server).get('/api/maps/enrichment/credit/x'); + expect(res.status).toBe(401); + }); + + it('429 once one user passes sixty requests a minute', async () => { + const send = () => + request(server) + .post('/api/maps/enrichment') + .set('Cookie', sessionCookie(1)) + // Vary the place so the row cache cannot mask the limiter. + .send({ ...BODY, placeId: `way:${Math.random()}` }); + + for (let i = 0; i < 60; i++) expect((await send()).status).toBe(200); + const blocked = await send(); + + expect(blocked.status).toBe(429); + expect(blocked.body).toEqual({ error: 'Too many requests' }); + }); +}); diff --git a/server/tests/unit/nest/place-enrichment.controller.test.ts b/server/tests/unit/nest/place-enrichment.controller.test.ts new file mode 100644 index 000000000..1f874cb2e --- /dev/null +++ b/server/tests/unit/nest/place-enrichment.controller.test.ts @@ -0,0 +1,76 @@ +/** + * Unit tests for PlaceEnrichmentController — ENRICH-040 through ENRICH-046. + * Service and rate limiter are stubbed; this covers the rate-limit gate, the + * pass-through and the deliberate swallowing of provider failures. + */ +import { describe, it, expect, vi } from 'vitest'; +import { HttpException } from '@nestjs/common'; +import type { Request } from 'express'; +import { PlaceEnrichmentController } from '../../../src/nest/place-enrichment/place-enrichment.controller'; +import type { PlaceEnrichmentService } from '../../../src/nest/place-enrichment/place-enrichment.service'; +import { RateLimitService } from '../../../src/nest/common/rate-limit.service'; +import type { User } from '../../../src/types'; + +const USER = { id: 7 } as User; +const REQ = {} as Request; +const BODY = { lat: 50.9, lng: 6.96, name: 'Museum Ludwig', placeId: 'ChIJmuseum' }; +const RESULT = { photos: [], description: null }; + +function make(over: Partial = {}, rl = new RateLimitService()) { + const service = { enrich: vi.fn(async () => RESULT), ...over } as unknown as PlaceEnrichmentService; + return { controller: new PlaceEnrichmentController(service, rl), service, rl }; +} + +describe('PlaceEnrichmentController', () => { + it('ENRICH-040: forwards the validated body with the signed-in user id', async () => { + const { controller, service } = make(); + + await expect(controller.enrich(USER, REQ, BODY)).resolves.toEqual(RESULT); + expect(service.enrich).toHaveBeenCalledWith(7, BODY); + }); + + it('ENRICH-041: answers 429 once a user passes sixty calls a minute', async () => { + const { controller, service } = make(); + + for (let i = 0; i < 60; i++) await controller.enrich(USER, REQ, BODY); + await expect(controller.enrich(USER, REQ, BODY)).rejects.toBeInstanceOf(HttpException); + expect(service.enrich).toHaveBeenCalledTimes(60); + }); + + it('ENRICH-042: carries the 429 status and message', async () => { + const rl = new RateLimitService(); + const { controller } = make({}, rl); + vi.spyOn(rl, 'check').mockReturnValue(false); + + const err = await controller.enrich(USER, REQ, BODY).catch((e: HttpException) => e); + + expect((err as HttpException).getStatus()).toBe(429); + expect((err as HttpException).getResponse()).toEqual({ error: 'Too many requests' }); + }); + + it('ENRICH-043: buckets per user, so one busy account cannot lock out another', async () => { + const { controller } = make(); + + for (let i = 0; i < 60; i++) await controller.enrich(USER, REQ, BODY); + await expect(controller.enrich(USER, REQ, BODY)).rejects.toBeInstanceOf(HttpException); + await expect(controller.enrich({ id: 8 } as User, REQ, BODY)).resolves.toEqual(RESULT); + }); + + it('ENRICH-044: turns a provider failure into an empty result, not a 500', async () => { + // The column is an aid, not a step — adding a place must not break because + // Wikimedia is down. + const { controller } = make({ enrich: vi.fn(async () => { throw new Error('commons down'); }) }); + const err = vi.spyOn(console, 'error').mockImplementation(() => {}); + + await expect(controller.enrich(USER, REQ, BODY)).resolves.toEqual({ photos: [], description: null }); + expect(err).toHaveBeenCalled(); + err.mockRestore(); + }); + + it('ENRICH-045: passes the disabled envelope through untouched', async () => { + const disabled = { photos: [], description: null, disabled: true }; + const { controller } = make({ enrich: vi.fn(async () => disabled) }); + + await expect(controller.enrich(USER, REQ, BODY)).resolves.toEqual(disabled); + }); +}); diff --git a/server/tests/unit/nest/place-enrichment.service.test.ts b/server/tests/unit/nest/place-enrichment.service.test.ts new file mode 100644 index 000000000..629ee14de --- /dev/null +++ b/server/tests/unit/nest/place-enrichment.service.test.ts @@ -0,0 +1,530 @@ +/** + * 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'; + +const { mockDbGet, mockDbRun, mockSafeFetchFollow } = vi.hoisted(() => ({ + mockDbGet: vi.fn(() => undefined as any), + mockDbRun: vi.fn(), + 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 { PlaceEnrichmentService, creditLine } 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> = {}) { + 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), + fetchWikipediaExtract: vi.fn(async () => null as { text: string; sourceUrl: string } | null), + details: vi.fn(async () => ({ place: null })), + ...over, + } as unknown as MapsService; +} + +function cacheStub(over: Record = {}) { + 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); +} + +const commonsCandidate = (over: Record = {}) => ({ + 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', + ...over, +}); + +beforeEach(() => { + 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, 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); + + expect(out.photos).toEqual([ + { + key: 'ChIJmuseum~p0', + url: '/api/maps/place-photo/ChIJmuseum~p0/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: 'ChIJmuseum~p0', + 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('ChIJmuseum~p1'); + }); + + 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' }), commonsCandidate()]), + }); + 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 takes index 0 — keys number the strip, not the source list. + expect(out.photos).toHaveLength(1); + expect(out.photos[0].key).toBe('ChIJmuseum~p0'); + }); + + 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: 'ChIJmuseum~p0', attribution: 'Cara' }); + }); + + 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('coords:50.9:6.96~p0'); + 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.fetchWikipediaExtract).not.toHaveBeenCalled(); + }); + + 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); + + expect(maps.fetchWikipediaExtract).toHaveBeenCalledWith(null); + }); + + 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' } })), + fetchWikipediaExtract: vi.fn(async () => ({ + text: 'Das Museum Ludwig ist ein Museum.', + sourceUrl: 'https://de.wikipedia.org/wiki/Museum%20Ludwig', + })), + }); + + const out = await make(maps, cacheStub()).enrich(1, OSM_REQ); + + expect(maps.fetchWikipediaExtract).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: passes null on when the place carries no wiki tag', async () => { + const maps = mapsStub({ details: vi.fn(async () => ({ place: { name: 'Kiosk' } })) }); + + const out = await make(maps, cacheStub()).enrich(1, OSM_REQ); + + expect(maps.fetchWikipediaExtract).toHaveBeenCalledWith(null); + 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', () => { + const cachedRow = (photos: unknown[], fetchedAt = Date.now()) => ({ + payload_json: JSON.stringify({ photos, description: null }), + 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: drops cached candidates whose bytes the nightly sweep removed', async () => { + const maps = mapsStub(); + const cache = cacheStub({ get: vi.fn(() => null) }); // nothing on disk any more + 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).toEqual([]); + }); + + 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('ChIJmuseum~p0', expect.any(Buffer), 'Alice · CC BY-SA 4.0'); + }); + + it('ENRICH-029: reads a stored credit back by cache key', () => { + const cache = cacheStub({ + get: vi.fn((key: string) => + key === 'ChIJmuseum~p0' ? { photoUrl: '/x', filePath: '/tmp/x', attribution: 'Alice · CC BY-SA 4.0' } : null, + ), + }); + const svc = make(mapsStub(), cache); + + expect(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(svc.credit('unknown')).toEqual({ credit: null }); + }); +});