mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
Add a contract for place enrichment
This commit is contained in:
@@ -3,6 +3,10 @@ import {
|
||||
mapsAutocompleteRequestSchema,
|
||||
mapsReverseQuerySchema,
|
||||
mapsResolveUrlRequestSchema,
|
||||
mapsPlaceEnrichmentRequestSchema,
|
||||
mapsPlaceEnrichmentResultSchema,
|
||||
placePhotoCandidateSchema,
|
||||
placeDescriptionSchema,
|
||||
} from './maps.schema';
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
@@ -60,3 +64,94 @@ describe('mapsResolveUrlRequestSchema', () => {
|
||||
expect(mapsResolveUrlRequestSchema.safeParse({ url: '' }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mapsPlaceEnrichmentRequestSchema', () => {
|
||||
const base = { lat: 52.5, lng: 13.4, name: 'Museum Ludwig' };
|
||||
|
||||
it('needs coordinates and a name, but not a place id', () => {
|
||||
expect(mapsPlaceEnrichmentRequestSchema.safeParse(base).success).toBe(true);
|
||||
expect(mapsPlaceEnrichmentRequestSchema.safeParse({ ...base, placeId: 'ChIJx' }).success).toBe(true);
|
||||
expect(mapsPlaceEnrichmentRequestSchema.safeParse({ lat: 52.5, lng: 13.4 }).success).toBe(false);
|
||||
expect(mapsPlaceEnrichmentRequestSchema.safeParse({ ...base, name: '' }).success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects coordinates that arrived as strings or null', () => {
|
||||
// The client reads these off a provider blob, where lat can be a string.
|
||||
expect(mapsPlaceEnrichmentRequestSchema.safeParse({ ...base, lat: '52.5' }).success).toBe(false);
|
||||
expect(mapsPlaceEnrichmentRequestSchema.safeParse({ ...base, lng: null }).success).toBe(false);
|
||||
});
|
||||
|
||||
it('caps the free-text fields so a pasted blob cannot reach the providers', () => {
|
||||
expect(mapsPlaceEnrichmentRequestSchema.safeParse({ ...base, name: 'x'.repeat(301) }).success).toBe(false);
|
||||
expect(mapsPlaceEnrichmentRequestSchema.safeParse({ ...base, placeId: 'x'.repeat(301) }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('placePhotoCandidateSchema', () => {
|
||||
const candidate = {
|
||||
key: 'ChIJx~p0',
|
||||
url: '/api/maps/place-photo/ChIJx~p0/bytes',
|
||||
attribution: 'Jane Doe',
|
||||
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('accepts a fully attributed candidate', () => {
|
||||
expect(placePhotoCandidateSchema.safeParse(candidate).success).toBe(true);
|
||||
});
|
||||
|
||||
it('allows every licence field to be null, because Google supplies none of them', () => {
|
||||
expect(
|
||||
placePhotoCandidateSchema.safeParse({
|
||||
...candidate,
|
||||
source: 'google',
|
||||
attribution: null,
|
||||
license: null,
|
||||
licenseUrl: null,
|
||||
sourceUrl: null,
|
||||
}).success,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('requires the fields to be present even when null, so the column cannot silently skip attribution', () => {
|
||||
const { attribution, ...withoutAttribution } = candidate;
|
||||
void attribution;
|
||||
expect(placePhotoCandidateSchema.safeParse(withoutAttribution).success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects an unknown source', () => {
|
||||
expect(placePhotoCandidateSchema.safeParse({ ...candidate, source: 'unsplash' }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('placeDescriptionSchema', () => {
|
||||
it('keeps the source alongside the text', () => {
|
||||
expect(
|
||||
placeDescriptionSchema.safeParse({
|
||||
text: 'A museum in Cologne.',
|
||||
source: 'wikipedia',
|
||||
sourceUrl: 'https://de.wikipedia.org/wiki/Museum_Ludwig',
|
||||
license: 'CC BY-SA 4.0',
|
||||
}).success,
|
||||
).toBe(true);
|
||||
expect(placeDescriptionSchema.safeParse({ text: 'A museum.' }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mapsPlaceEnrichmentResultSchema', () => {
|
||||
it('accepts an empty result, which is what a keyless instance with no Commons hit returns', () => {
|
||||
expect(mapsPlaceEnrichmentResultSchema.safeParse({ photos: [], description: null }).success).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts the kill-switch response', () => {
|
||||
expect(
|
||||
mapsPlaceEnrichmentResultSchema.safeParse({ photos: [], description: null, disabled: true }).success,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('requires description to be explicitly null rather than absent', () => {
|
||||
expect(mapsPlaceEnrichmentResultSchema.safeParse({ photos: [] }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,12 +3,12 @@ import { z } from 'zod';
|
||||
/**
|
||||
* Maps / geo API contract — single source of truth for the /api/maps endpoints.
|
||||
*
|
||||
* The legacy Express route (server/src/routes/maps.ts) is a thin layer over
|
||||
* services/mapsService.ts, which talks to Nominatim/Overpass (and optionally
|
||||
* Google Places when a key is configured) and applies the SSRF guard on every
|
||||
* outbound URL. The place objects these return are provider-shaped and vary by
|
||||
* source, so the response schemas keep them as open records — the contract pins
|
||||
* down the request shapes and the stable envelope fields, not the provider blobs.
|
||||
* server/src/nest/maps/maps.service.ts talks to Nominatim/Overpass (and
|
||||
* optionally Google Places when a key is configured) and applies the SSRF guard
|
||||
* on every outbound URL. The place objects these return are provider-shaped and
|
||||
* vary by source, so the response schemas keep them as open records — the
|
||||
* contract pins down the request shapes and the stable envelope fields, not the
|
||||
* provider blobs.
|
||||
*
|
||||
* Since the maps body-contract ratchet, the request schemas below are enforced
|
||||
* on the server via createZodDto wrappers (maps.dto.ts) and the global
|
||||
@@ -94,3 +94,62 @@ export const mapsResolveUrlResultSchema = z.object({
|
||||
google_ftid: z.string().nullable().optional(),
|
||||
});
|
||||
export type MapsResolveUrlResult = z.infer<typeof mapsResolveUrlResultSchema>;
|
||||
|
||||
/**
|
||||
* Place enrichment — the photo candidates and description shown next to the
|
||||
* search field while adding a place.
|
||||
*
|
||||
* Unlike the endpoints above this one is not provider-shaped: the whole point
|
||||
* is that a Commons image and a Google photo arrive in the same shape, so the
|
||||
* column renders one strip regardless of which sources the instance has.
|
||||
* Everything nullable is genuinely optional per source — Commons gives us a
|
||||
* licence and an author, Google gives us neither in a form we may reproduce.
|
||||
*/
|
||||
export const placePhotoSourceSchema = z.enum(['google', 'wikimedia', 'wikipedia', 'cached']);
|
||||
export type PlacePhotoSource = z.infer<typeof placePhotoSourceSchema>;
|
||||
|
||||
export const placePhotoCandidateSchema = z.object({
|
||||
/** Cache key, also the React key. Candidates use `<placeId>~p<n>`. */
|
||||
key: z.string(),
|
||||
/** Proxy URL (/api/maps/place-photo/<key>/bytes) — never a provider URL. */
|
||||
url: z.string(),
|
||||
/** Author/creator as the provider names them, not the provider itself. */
|
||||
attribution: z.string().nullable(),
|
||||
/** Short licence name, e.g. "CC BY-SA 4.0". */
|
||||
license: z.string().nullable(),
|
||||
licenseUrl: z.string().nullable(),
|
||||
/** The file description page, where the full terms live. */
|
||||
sourceUrl: z.string().nullable(),
|
||||
source: placePhotoSourceSchema,
|
||||
});
|
||||
export type PlacePhotoCandidate = z.infer<typeof placePhotoCandidateSchema>;
|
||||
|
||||
export const placeDescriptionSourceSchema = z.enum(['google', 'osm', 'wikipedia']);
|
||||
export type PlaceDescriptionSource = z.infer<typeof placeDescriptionSourceSchema>;
|
||||
|
||||
export const placeDescriptionSchema = z.object({
|
||||
text: z.string(),
|
||||
source: placeDescriptionSourceSchema,
|
||||
sourceUrl: z.string().nullable(),
|
||||
license: z.string().nullable(),
|
||||
});
|
||||
export type PlaceDescription = z.infer<typeof placeDescriptionSchema>;
|
||||
|
||||
export const mapsPlaceEnrichmentRequestSchema = z.object({
|
||||
/** Google place id or `osm:<type>/<id>`; empty for a coordinate-only lookup. */
|
||||
placeId: z.string().max(300).optional(),
|
||||
lat: z.number(),
|
||||
lng: z.number(),
|
||||
/** Used to resolve a Wikipedia article when the place carries no wiki tag. */
|
||||
name: z.string().min(1).max(300),
|
||||
lang: z.string().max(35).optional(),
|
||||
});
|
||||
export type MapsPlaceEnrichmentRequest = z.infer<typeof mapsPlaceEnrichmentRequestSchema>;
|
||||
|
||||
export const mapsPlaceEnrichmentResultSchema = z.object({
|
||||
photos: z.array(placePhotoCandidateSchema),
|
||||
description: placeDescriptionSchema.nullable(),
|
||||
/** True when the admin switched enrichment off; the column then stays quiet. */
|
||||
disabled: z.boolean().optional(),
|
||||
});
|
||||
export type MapsPlaceEnrichmentResult = z.infer<typeof mapsPlaceEnrichmentResultSchema>;
|
||||
|
||||
Reference in New Issue
Block a user