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,
|
mapsAutocompleteRequestSchema,
|
||||||
mapsReverseQuerySchema,
|
mapsReverseQuerySchema,
|
||||||
mapsResolveUrlRequestSchema,
|
mapsResolveUrlRequestSchema,
|
||||||
|
mapsPlaceEnrichmentRequestSchema,
|
||||||
|
mapsPlaceEnrichmentResultSchema,
|
||||||
|
placePhotoCandidateSchema,
|
||||||
|
placeDescriptionSchema,
|
||||||
} from './maps.schema';
|
} from './maps.schema';
|
||||||
|
|
||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
@@ -60,3 +64,94 @@ describe('mapsResolveUrlRequestSchema', () => {
|
|||||||
expect(mapsResolveUrlRequestSchema.safeParse({ url: '' }).success).toBe(false);
|
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.
|
* 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
|
* server/src/nest/maps/maps.service.ts talks to Nominatim/Overpass (and
|
||||||
* services/mapsService.ts, which talks to Nominatim/Overpass (and optionally
|
* optionally Google Places when a key is configured) and applies the SSRF guard
|
||||||
* Google Places when a key is configured) and applies the SSRF guard on every
|
* on every outbound URL. The place objects these return are provider-shaped and
|
||||||
* outbound URL. The place objects these return are provider-shaped and vary by
|
* vary by source, so the response schemas keep them as open records — the
|
||||||
* source, so the response schemas keep them as open records — the contract pins
|
* contract pins down the request shapes and the stable envelope fields, not the
|
||||||
* down the request shapes and the stable envelope fields, not the provider blobs.
|
* provider blobs.
|
||||||
*
|
*
|
||||||
* Since the maps body-contract ratchet, the request schemas below are enforced
|
* Since the maps body-contract ratchet, the request schemas below are enforced
|
||||||
* on the server via createZodDto wrappers (maps.dto.ts) and the global
|
* 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(),
|
google_ftid: z.string().nullable().optional(),
|
||||||
});
|
});
|
||||||
export type MapsResolveUrlResult = z.infer<typeof mapsResolveUrlResultSchema>;
|
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