Files
TREK/server/tests/e2e/place-enrichment.e2e.test.ts
Maurice 724c88c201 style: format server and shared with prettier
A mechanical prettier --write over server/src, server/tests, server/scripts
and shared/src with the workspaces' .prettierrc; nothing but layout and
import order changes, and every file keeps the same set of imports. shared
is formatted throughout and its format baseline is empty. The server
baseline keeps the 35 files that Prettier would grow past their lint:size
limit (they need a split first, which is not a formatting change), and the
generated entity files stay as their generator writes them.
2026-10-10 21:23:37 +02:00

254 lines
11 KiB
TypeScript

/**
* 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 { AppSettings } from '../../src/db/entities/AppSettings.entity';
import { PlaceDetailsCache } from '../../src/db/entities/PlaceDetailsCache.entity';
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';
import { MapsService } from '../../src/nest/maps/maps.service';
import { OsmClient } from '../../src/nest/maps/providers/osm.client';
import { WikimediaClient } from '../../src/nest/maps/providers/wikimedia.client';
import { PlaceEnrichmentModule } from '../../src/nest/place-enrichment/place-enrichment.module';
import { candidateKey } from '../../src/nest/place-enrichment/place-enrichment.service';
import { PlacePhotoCacheService } from '../../src/nest/place-photos/place-photo-cache.service';
import { deleteRows } from '../helpers/factories/rows';
import { setAppSetting } from '../helpers/factories/settings';
import { createTestMikroOrmModule, createTestOrm, type TestOrm } from '../helpers/test-orm';
import { TestUnitOfWorkModule } from '../helpers/test-uow';
import { seedUser, sessionCookie } from './harness';
import { Test } from '@nestjs/testing';
import cookieParser from 'cookie-parser';
import type { Server } from 'http';
import request from 'supertest';
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
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: () => {} }));
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<ReturnType<typeof build>>;
let maps: MapsService;
let osm: OsmClient;
let wiki: WikimediaClient;
let orm: TestOrm;
async function build() {
const moduleRef = await Test.createTestingModule({
imports: [await TestUnitOfWorkModule.forRoot(db), await createTestMikroOrmModule(db), 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 });
orm = await createTestOrm(db);
app = await build();
server = app.getHttpServer();
maps = app.get(MapsService);
osm = app.get(OsmClient);
wiki = app.get(WikimediaClient);
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();
await orm.close();
});
beforeEach(async () => {
await deleteRows(orm, AppSettings);
await deleteRows(orm, PlaceDetailsCache);
await 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(wiki, 'fetchCommonsCandidates').mockClear().mockResolvedValue([]);
vi.spyOn(wiki, 'fetchWikiExtract').mockClear().mockResolvedValue(null);
vi.spyOn(wiki, 'fetchCommonsCategoryCandidates').mockClear().mockResolvedValue([]);
vi.spyOn(maps, 'details').mockClear().mockResolvedValue({ place: null });
// Every provider the service can reach has to be stubbed here, not just the
// ones a given case cares about: anything left open goes out over the real
// network from CI, which is both slow and rude to the provider.
vi.spyOn(osm, 'resolveOsmIdentity').mockClear().mockResolvedValue(null);
vi.spyOn(wiki, 'fetchWikidataSitelinks').mockClear().mockResolvedValue({});
vi.spyOn(wiki, 'fetchWikiExtractFor').mockClear().mockResolvedValue(null);
vi.spyOn(wiki, 'fetchWikidataCandidates').mockClear().mockResolvedValue({ candidates: [], commonsCategory: null });
vi.spyOn(wiki, 'fetchWikiLeadImageName').mockClear().mockResolvedValue(null);
vi.spyOn(wiki, 'fetchCommonsFilesByName').mockClear().mockResolvedValue(new Map());
});
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, facts: [], hours: null, rating: null });
});
it('200 with a Commons candidate carrying its licence', async () => {
vi.spyOn(wiki, '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',
pageId: 4711,
title: 'File:X.jpg',
width: 1600,
height: 1200,
descriptors: null,
},
]);
// 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({
// Keyed by the file's Commons page id, not by its slot in the strip.
key: candidateKey('way:12345', 'commons:4711'),
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 () => {
await setAppSetting(orm, 'places_enrich_enabled', 'false');
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, facts: [], disabled: true });
expect(wiki.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(wiki.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<typeof vi.fn>).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').mockResolvedValueOnce({
photoUrl: '/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' });
});
});