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 }); + }); +});