mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 13:36:48 +00:00
724c88c201
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.
601 lines
26 KiB
TypeScript
601 lines
26 KiB
TypeScript
import { MapsController } from '../../../src/nest/maps/maps.controller';
|
|
import type { MapsService } from '../../../src/nest/maps/maps.service';
|
|
import type { StorageService } from '../../../src/nest/storage/storage.service';
|
|
import type { User } from '../../../src/types';
|
|
import { HttpException } from '@nestjs/common';
|
|
|
|
import type { Response } from 'express';
|
|
import { PassThrough } from 'node:stream';
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
|
|
const user = { id: 3 } as User;
|
|
|
|
// The bytes route is the only storage consumer; everything else ignores it.
|
|
const getStream = vi.fn();
|
|
const storageStub = { getStream } as unknown as StorageService;
|
|
|
|
function makeController(svc: Partial<MapsService>) {
|
|
return new MapsController(svc as MapsService, storageStub);
|
|
}
|
|
|
|
/** Run an async handler, expecting an HttpException; return its { status, body }. */
|
|
async function thrown(fn: () => Promise<unknown>): Promise<{ status: number; body: unknown }> {
|
|
try {
|
|
await fn();
|
|
} catch (err) {
|
|
expect(err).toBeInstanceOf(HttpException);
|
|
const e = err as HttpException;
|
|
return { status: e.getStatus(), body: e.getResponse() };
|
|
}
|
|
throw new Error('expected the handler to throw');
|
|
}
|
|
|
|
function withError(status: number, message: string): Error {
|
|
return Object.assign(new Error(message), { status });
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
});
|
|
|
|
describe('MapsController (parity with the legacy /api/maps route)', () => {
|
|
// Body validation (required query/input/url, locationBias shapes, input
|
|
// length) moved to the @trek/shared maps schemas enforced by the global
|
|
// ZodValidationPipe — the pipe's uniform envelope replaced the legacy
|
|
// bespoke 400 strings, and the 400-status contract is pinned by the
|
|
// integration/e2e suites. These unit cases cover the handler bodies only.
|
|
describe('POST /search', () => {
|
|
it('returns the service result', async () => {
|
|
const search = vi.fn().mockResolvedValue({ places: [], source: 'osm' });
|
|
const res = await makeController({ search }).search(user, { query: 'berlin' }, 'de');
|
|
expect(res).toEqual({ places: [], source: 'osm' });
|
|
expect(search).toHaveBeenCalledWith(3, 'berlin', 'de', undefined, undefined);
|
|
});
|
|
|
|
it('forwards the provider a search is sent to alone', async () => {
|
|
const search = vi.fn().mockResolvedValue({ places: [], source: 'google' });
|
|
await makeController({ search }).search(user, { query: 'x', provider: 'google' }, 'de');
|
|
expect(search).toHaveBeenCalledWith(3, 'x', 'de', undefined, 'google');
|
|
});
|
|
|
|
it('forwards a valid locationBias to the service', async () => {
|
|
const search = vi.fn().mockResolvedValue({ places: [], source: 'osm' });
|
|
const bias = { lat: 1, lng: 2, radius: 5000 };
|
|
await makeController({ search }).search(user, { query: 'x', locationBias: bias }, 'de');
|
|
expect(search).toHaveBeenCalledWith(3, 'x', 'de', bias, undefined);
|
|
});
|
|
|
|
it('maps a service error to its status + message', async () => {
|
|
const search = vi.fn().mockRejectedValue(withError(429, 'Rate limited'));
|
|
expect(await thrown(() => makeController({ search }).search(user, { query: 'x' }))).toEqual({
|
|
status: 429,
|
|
body: { error: 'Rate limited' },
|
|
});
|
|
});
|
|
|
|
it('defaults a non-Error rejection to 500 + the fallback message', async () => {
|
|
const search = vi.fn().mockRejectedValue('boom');
|
|
expect(await thrown(() => makeController({ search }).search(user, { query: 'x' }))).toEqual({
|
|
status: 500,
|
|
body: { error: 'Search error' },
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('POST /nearby', () => {
|
|
it('forwards the point, the circle, the count and the language', async () => {
|
|
const nearbyPlaces = vi.fn().mockResolvedValue({ places: [], source: 'trek-places' });
|
|
const res = await makeController({ nearbyPlaces }).nearby(
|
|
user,
|
|
{ lat: 52.5, lng: 13.4, radius: 300, limit: 5 },
|
|
'de',
|
|
);
|
|
expect(res).toEqual({ places: [], source: 'trek-places' });
|
|
expect(nearbyPlaces).toHaveBeenCalledWith(3, 52.5, 13.4, { radius: 300, limit: 5, lang: 'de' });
|
|
});
|
|
|
|
it('maps a provider error to its status, and anything else to 500', async () => {
|
|
const refused = vi.fn().mockRejectedValue(withError(429, 'Quota exceeded'));
|
|
expect(await thrown(() => makeController({ nearbyPlaces: refused }).nearby(user, { lat: 1, lng: 2 }))).toEqual({
|
|
status: 429,
|
|
body: { error: 'Quota exceeded' },
|
|
});
|
|
const broken = vi.fn().mockRejectedValue('boom');
|
|
expect(await thrown(() => makeController({ nearbyPlaces: broken }).nearby(user, { lat: 1, lng: 2 }))).toEqual({
|
|
status: 500,
|
|
body: { error: 'Nearby search error' },
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('GET /area', () => {
|
|
const BOX = { minLat: 54, minLng: 12, maxLat: 54.2, maxLng: 12.3 };
|
|
|
|
it('delegates a valid box with parsed numbers', async () => {
|
|
const placesInArea = vi.fn().mockResolvedValue({ results: [{ name: 'x' }], truncated: false });
|
|
const res = await makeController({ placesInArea }).area('54', '12', '54.2', '12.3');
|
|
expect(res).toEqual({ results: [{ name: 'x' }], truncated: false });
|
|
expect(placesInArea).toHaveBeenCalledWith(BOX, 2000);
|
|
});
|
|
|
|
it('400 when the box has a non-finite value', async () => {
|
|
const placesInArea = vi.fn();
|
|
expect(await thrown(() => makeController({ placesInArea }).area('x', '12', '54.2', '12.3'))).toEqual({
|
|
status: 400,
|
|
body: { error: 'A valid bbox (minLat, minLng, maxLat, maxLng) is required' },
|
|
});
|
|
expect(placesInArea).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('caps the limit rather than passing a caller number straight through', async () => {
|
|
const placesInArea = vi.fn().mockResolvedValue({ results: [], truncated: false });
|
|
await makeController({ placesInArea }).area('54', '12', '54.2', '12.3', '999999');
|
|
expect(placesInArea).toHaveBeenCalledWith(BOX, 5000);
|
|
});
|
|
|
|
it('says the index is unavailable instead of failing the caller', async () => {
|
|
// This runs inside an offline sync. A null here means the index is off or
|
|
// unreachable, and the client caches nothing and carries on.
|
|
const placesInArea = vi.fn().mockResolvedValue(null);
|
|
const res = await makeController({ placesInArea }).area('54', '12', '54.2', '12.3');
|
|
expect(res).toEqual({ results: [], truncated: false, unavailable: true });
|
|
});
|
|
});
|
|
|
|
describe('GET /pois', () => {
|
|
it('400 when category is missing', async () => {
|
|
const pois = vi.fn();
|
|
expect(await thrown(() => makeController({ pois }).pois(undefined, '1', '2', '3', '4'))).toEqual({
|
|
status: 400,
|
|
body: { error: 'A category is required' },
|
|
});
|
|
expect(pois).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('400 when the bbox has a non-finite value', async () => {
|
|
const pois = vi.fn();
|
|
expect(await thrown(() => makeController({ pois }).pois('cafe', 'x', '2', '3', '4'))).toEqual({
|
|
status: 400,
|
|
body: { error: 'A valid bbox (south, west, north, east) is required' },
|
|
});
|
|
expect(pois).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('delegates a valid request with a parsed numeric bbox and forwards lang', async () => {
|
|
const pois = vi.fn().mockResolvedValue({ places: [] });
|
|
const res = await makeController({ pois }).pois('cafe', '1', '2', '3', '4', 'fr');
|
|
expect(res).toEqual({ places: [] });
|
|
expect(pois).toHaveBeenCalledWith('cafe', { south: 1, west: 2, north: 3, east: 4 }, 'fr');
|
|
});
|
|
|
|
it('maps a service error, defaulting to 500', async () => {
|
|
const pois = vi.fn().mockRejectedValue(new Error('Overpass down'));
|
|
expect(await thrown(() => makeController({ pois }).pois('cafe', '1', '2', '3', '4'))).toEqual({
|
|
status: 500,
|
|
body: { error: 'Overpass down' },
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('POST /autocomplete', () => {
|
|
it('returns the disabled envelope when the kill-switch is off', async () => {
|
|
const autocomplete = vi.fn();
|
|
const res = await makeController({ autocompleteDisabled: async () => true, autocomplete }).autocomplete(user, {
|
|
input: 'be',
|
|
});
|
|
expect(res).toEqual({ suggestions: [], source: 'disabled' });
|
|
expect(autocomplete).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('delegates a valid request', async () => {
|
|
const autocomplete = vi.fn().mockResolvedValue({ suggestions: [], source: 'osm' });
|
|
const bias = { low: { lat: 1, lng: 2 }, high: { lat: 3, lng: 4 } };
|
|
await makeController({ autocompleteDisabled: async () => false, autocomplete }).autocomplete(user, {
|
|
input: 'be',
|
|
lang: 'en',
|
|
locationBias: bias,
|
|
});
|
|
expect(autocomplete).toHaveBeenCalledWith(3, 'be', 'en', bias, undefined);
|
|
});
|
|
|
|
// Session tokens tie a search's keystrokes and its details lookup into one
|
|
// Google billing session instead of charging each request.
|
|
it('passes a session token through to the service', async () => {
|
|
const autocomplete = vi.fn().mockResolvedValue({ suggestions: [], source: 'google' });
|
|
await makeController({ autocompleteDisabled: async () => false, autocomplete }).autocomplete(user, {
|
|
input: 'be',
|
|
sessionToken: 'abc123',
|
|
});
|
|
expect(autocomplete).toHaveBeenCalledWith(3, 'be', undefined, undefined, 'abc123');
|
|
});
|
|
|
|
it('maps a service error', async () => {
|
|
const autocomplete = vi.fn().mockRejectedValue(withError(503, 'Upstream down'));
|
|
const c = makeController({ autocompleteDisabled: async () => false, autocomplete });
|
|
expect(await thrown(() => c.autocomplete(user, { input: 'be' }))).toEqual({
|
|
status: 503,
|
|
body: { error: 'Upstream down' },
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('GET /details/:placeId', () => {
|
|
it('returns the disabled envelope when off', async () => {
|
|
const res = await makeController({ detailsDisabled: async () => true }).details(user, 'p1');
|
|
expect(res).toEqual({ place: null, disabled: true });
|
|
});
|
|
|
|
it('uses the expanded lookup when expand is set', async () => {
|
|
const detailsExpanded = vi.fn().mockResolvedValue({ place: { id: 'p1' } });
|
|
const details = vi.fn();
|
|
await makeController({ detailsDisabled: async () => false, detailsExpanded, details }).details(
|
|
user,
|
|
'p1',
|
|
'full',
|
|
'de',
|
|
'1',
|
|
);
|
|
expect(detailsExpanded).toHaveBeenCalledWith(3, 'p1', 'de', true);
|
|
expect(details).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('uses the plain lookup without expand', async () => {
|
|
const details = vi.fn().mockResolvedValue({ place: { id: 'p1' } });
|
|
await makeController({ detailsDisabled: async () => false, details }).details(user, 'p1', undefined, 'de');
|
|
expect(details).toHaveBeenCalledWith(3, 'p1', 'de', undefined);
|
|
});
|
|
|
|
// The details query is not Zod-validated, so the token is shape-checked here
|
|
// and a junk value degrades to per-request billing instead of reaching Google.
|
|
it('forwards a well-formed session token and drops a malformed one', async () => {
|
|
const details = vi.fn().mockResolvedValue({ place: { id: 'p1' } });
|
|
const c = makeController({ detailsDisabled: async () => false, details });
|
|
|
|
await c.details(user, 'p1', undefined, 'de', undefined, 'a-b_C9');
|
|
expect(details).toHaveBeenLastCalledWith(3, 'p1', 'de', 'a-b_C9');
|
|
|
|
await c.details(user, 'p1', undefined, 'de', undefined, 'has spaces & symbols!');
|
|
expect(details).toHaveBeenLastCalledWith(3, 'p1', 'de', undefined);
|
|
|
|
await c.details(user, 'p1', undefined, 'de', undefined, 'x'.repeat(37));
|
|
expect(details).toHaveBeenLastCalledWith(3, 'p1', 'de', undefined);
|
|
});
|
|
|
|
it('maps a service error', async () => {
|
|
const details = vi.fn().mockRejectedValue(withError(404, 'Not found'));
|
|
expect(
|
|
await thrown(() => makeController({ detailsDisabled: async () => false, details }).details(user, 'p1')),
|
|
).toEqual({
|
|
status: 404,
|
|
body: { error: 'Not found' },
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('GET /place-photo/:placeId', () => {
|
|
it('returns { photoUrl: null } when photos are disabled (non-coords)', async () => {
|
|
const photo = vi.fn();
|
|
const res = await makeController({ photosDisabled: async () => true, photo }).placePhoto(user, 'p1', '1', '2');
|
|
expect(res).toEqual({ photoUrl: null });
|
|
expect(photo).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('bypasses the kill-switch for coords: ids', async () => {
|
|
const photo = vi.fn().mockResolvedValue({ photoUrl: 'u', attribution: null });
|
|
await makeController({ photosDisabled: async () => true, photo }).placePhoto(
|
|
user,
|
|
'coords:1,2',
|
|
'1',
|
|
'2',
|
|
'Spot',
|
|
);
|
|
expect(photo).toHaveBeenCalledWith(3, 'coords:1,2', 1, 2, 'Spot');
|
|
});
|
|
|
|
it('maps a 4xx service error', async () => {
|
|
const photo = vi.fn().mockRejectedValue(withError(429, 'Rate limited'));
|
|
expect(
|
|
await thrown(() =>
|
|
makeController({ photosDisabled: async () => false, photo }).placePhoto(user, 'p1', '1', '2'),
|
|
),
|
|
).toEqual({
|
|
status: 429,
|
|
body: { error: 'Rate limited' },
|
|
});
|
|
});
|
|
|
|
// A place without a photo is an empty result, not a 404 — one 404 per photo-less
|
|
// place gets the user banned by any 404-rate IPS in front of TREK (#1727).
|
|
it('passes a photo-less place through as a 200 with photoUrl null', async () => {
|
|
const photo = vi.fn().mockResolvedValue({ photoUrl: null, attribution: null });
|
|
const res = await makeController({ photosDisabled: async () => false, photo }).placePhoto(
|
|
user,
|
|
'node:123',
|
|
'1',
|
|
'2',
|
|
);
|
|
expect(res).toEqual({ photoUrl: null, attribution: null });
|
|
});
|
|
|
|
it('logs and maps a 5xx service error', async () => {
|
|
const photo = vi.fn().mockRejectedValue(withError(502, 'Upstream failed'));
|
|
expect(
|
|
await thrown(() =>
|
|
makeController({ photosDisabled: async () => false, photo }).placePhoto(user, 'p1', '1', '2'),
|
|
),
|
|
).toEqual({
|
|
status: 502,
|
|
body: { error: 'Upstream failed' },
|
|
});
|
|
expect(console.error).toHaveBeenCalledWith('Place photo error:', expect.any(Error));
|
|
});
|
|
|
|
it('defaults a status-less error to 500 and parses NaN coords', async () => {
|
|
const photo = vi.fn().mockRejectedValue(new Error('Error fetching photo'));
|
|
expect(
|
|
await thrown(() => makeController({ photosDisabled: async () => false, photo }).placePhoto(user, 'p1')),
|
|
).toEqual({
|
|
status: 500,
|
|
body: { error: 'Error fetching photo' },
|
|
});
|
|
const [, , lat, lng] = photo.mock.calls[0];
|
|
expect(Number.isNaN(lat)).toBe(true);
|
|
expect(Number.isNaN(lng)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('GET /place-photo/:placeId/bytes', () => {
|
|
// A real PassThrough (like storage.service.test.ts's makeRes) rather than
|
|
// a { on, pipe } stub: pipeline() needs authentic stream/eos semantics
|
|
// (close/error/finish wiring) that a bare mock can't satisfy.
|
|
function makeRes() {
|
|
const sink = new PassThrough();
|
|
const chunks: Buffer[] = [];
|
|
sink.on('data', (c: Buffer) => chunks.push(c));
|
|
// Capture the real PassThrough#end BEFORE overwriting the property
|
|
// below — res and sink are the same object (Object.assign mutates and
|
|
// returns its target), so referencing sink.end inside the wrapper
|
|
// would recurse into itself.
|
|
const realEnd = sink.end.bind(sink) as (...a: unknown[]) => unknown;
|
|
const res = Object.assign(sink, {
|
|
statusCode: 200,
|
|
status: vi.fn(function (this: unknown, c: number) {
|
|
(res as { statusCode: number }).statusCode = c;
|
|
return res;
|
|
}),
|
|
json: vi.fn(),
|
|
set: vi.fn(),
|
|
type: vi.fn(),
|
|
end: vi.fn((...args: unknown[]) => realEnd(...args)),
|
|
body: () => Buffer.concat(chunks).toString(),
|
|
});
|
|
return res as unknown as Response & {
|
|
status: ReturnType<typeof vi.fn>;
|
|
json: ReturnType<typeof vi.fn>;
|
|
set: ReturnType<typeof vi.fn>;
|
|
type: ReturnType<typeof vi.fn>;
|
|
end: ReturnType<typeof vi.fn>;
|
|
body: () => string;
|
|
};
|
|
}
|
|
|
|
// Braced body on purpose: mockReset() returns the mock, and a function
|
|
// returned from beforeEach is invoked as a teardown callback — which would
|
|
// call getStream() bare and turn a mockRejectedValue impl into an
|
|
// unhandled rejection.
|
|
beforeEach(() => {
|
|
getStream.mockReset();
|
|
});
|
|
|
|
// Places persist this URL in image_url, so an evicted cache entry means one
|
|
// request per place on a trip render. 404 for each of them is the ban vector
|
|
// from #1727 — an uncached photo answers 204 with an empty body instead.
|
|
it('204 without a body when the photo is not cached', async () => {
|
|
const res = makeRes();
|
|
await makeController({ photoBytesKey: async () => null }).placePhotoBytes('p1', res);
|
|
expect(res.status).toHaveBeenCalledWith(204);
|
|
expect(res.end).toHaveBeenCalled();
|
|
expect(res.json).not.toHaveBeenCalled();
|
|
expect(getStream).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('streams the cached bytes with image/jpeg + an immutable cache header on a hit', async () => {
|
|
const stream = new PassThrough();
|
|
stream.end('hello');
|
|
getStream.mockResolvedValue({ stream, stat: { key: 'photos/google/abc.jpg', size: 5, mtimeMs: 0 } });
|
|
const res = makeRes();
|
|
await makeController({ photoBytesKey: async () => 'abc.jpg' }).placePhotoBytes('p1', res);
|
|
expect(res.set).toHaveBeenCalledWith('Cache-Control', 'public, max-age=2592000, immutable');
|
|
expect(res.type).toHaveBeenCalledWith('image/jpeg');
|
|
expect(getStream).toHaveBeenCalledWith('photos-google', 'abc.jpg');
|
|
expect(res.body()).toBe('hello');
|
|
});
|
|
|
|
it('falls back to an empty 204 when the stream cannot be opened (cache-delete race)', async () => {
|
|
getStream.mockRejectedValue(new Error('storage object not found'));
|
|
const res = makeRes();
|
|
await makeController({ photoBytesKey: async () => 'abc.jpg' }).placePhotoBytes('p1', res);
|
|
expect(res.status).toHaveBeenCalledWith(204);
|
|
expect(res.end).toHaveBeenCalled();
|
|
// The hit path already asked for a month of immutable caching — that header
|
|
// must not survive onto the empty answer, or the photo stays hidden once it
|
|
// is back in the cache.
|
|
expect(res.set).toHaveBeenLastCalledWith('Cache-Control', 'no-store');
|
|
});
|
|
|
|
it('falls back to an empty 204 when the stream errors before headers were flushed', async () => {
|
|
const stream = new PassThrough();
|
|
getStream.mockResolvedValue({ stream, stat: { key: 'photos/google/abc.jpg', size: 5, mtimeMs: 0 } });
|
|
const res = makeRes();
|
|
const pending = makeController({ photoBytesKey: async () => 'abc.jpg' }).placePhotoBytes('p1', res);
|
|
// Let pipeline() attach its listeners before erroring the source, so
|
|
// the event isn't missed to a scheduling race.
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
// No bytes reached res yet — pipeline rejects before any write.
|
|
stream.destroy(new Error('source boom'));
|
|
await pending;
|
|
expect(res.status).toHaveBeenCalledWith(204);
|
|
expect(res.end).toHaveBeenCalled();
|
|
expect(res.set).toHaveBeenLastCalledWith('Cache-Control', 'no-store');
|
|
});
|
|
|
|
it('swallows an abort-like error once headers are flushed (client walked away)', async () => {
|
|
const stream = new PassThrough();
|
|
getStream.mockResolvedValue({ stream, stat: { key: 'photos/google/abc.jpg', size: 5, mtimeMs: 0 } });
|
|
const res = makeRes();
|
|
(res as unknown as { headersSent: boolean }).headersSent = true;
|
|
const pending = makeController({ photoBytesKey: async () => 'abc.jpg' }).placePhotoBytes('p1', res);
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
stream.destroy(Object.assign(new Error('reset'), { code: 'ECONNRESET' }));
|
|
await expect(pending).resolves.toBeUndefined();
|
|
expect(res.status).not.toHaveBeenCalled();
|
|
expect(res.end).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('re-throws a real (non-abort) error once headers are flushed', async () => {
|
|
const stream = new PassThrough();
|
|
getStream.mockResolvedValue({ stream, stat: { key: 'photos/google/abc.jpg', size: 5, mtimeMs: 0 } });
|
|
const res = makeRes();
|
|
(res as unknown as { headersSent: boolean }).headersSent = true;
|
|
const pending = makeController({ photoBytesKey: async () => 'abc.jpg' }).placePhotoBytes('p1', res);
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
stream.destroy(Object.assign(new Error('disk read error'), { code: 'EIO' }));
|
|
await expect(pending).rejects.toThrow('disk read error');
|
|
expect(res.status).not.toHaveBeenCalled();
|
|
expect(res.end).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('GET /reverse', () => {
|
|
it('400 when lat/lng missing', async () => {
|
|
expect(await thrown(() => makeController({}).reverse(undefined, '2'))).toEqual({
|
|
status: 400,
|
|
body: { error: 'lat and lng required' },
|
|
});
|
|
});
|
|
|
|
it('returns the reverse result', async () => {
|
|
const reverse = vi.fn().mockResolvedValue({ name: 'Spot', address: 'Street 1' });
|
|
expect(await makeController({ reverse }).reverse('1', '2', 'de')).toEqual({ name: 'Spot', address: 'Street 1' });
|
|
});
|
|
|
|
it('swallows a failure into an empty result (no error)', async () => {
|
|
const reverse = vi.fn().mockRejectedValue(new Error('boom'));
|
|
expect(await makeController({ reverse }).reverse('1', '2')).toEqual({ name: null, address: null });
|
|
});
|
|
});
|
|
|
|
describe('POST /resolve-url', () => {
|
|
it('returns the resolved coordinates', async () => {
|
|
const resolveUrl = vi.fn().mockResolvedValue({ lat: 1, lng: 2, name: null, address: null });
|
|
expect(await makeController({ resolveUrl }).resolveUrl({ url: 'https://maps.app.goo.gl/x' })).toEqual({
|
|
lat: 1,
|
|
lng: 2,
|
|
name: null,
|
|
address: null,
|
|
});
|
|
});
|
|
|
|
it('maps a service error, defaulting to 400', async () => {
|
|
const resolveUrl = vi.fn().mockRejectedValue(new Error('Failed to resolve URL'));
|
|
expect(await thrown(() => makeController({ resolveUrl }).resolveUrl({ url: 'bad' }))).toEqual({
|
|
status: 400,
|
|
body: { error: 'Failed to resolve URL' },
|
|
});
|
|
});
|
|
|
|
it('honours an explicit status on the thrown error', async () => {
|
|
const resolveUrl = vi.fn().mockRejectedValue(withError(422, 'Unsupported link'));
|
|
expect(await thrown(() => makeController({ resolveUrl }).resolveUrl({ url: 'bad' }))).toEqual({
|
|
status: 422,
|
|
body: { error: 'Unsupported link' },
|
|
});
|
|
});
|
|
|
|
it('falls back to the default message when a non-Error is thrown', async () => {
|
|
const resolveUrl = vi.fn().mockRejectedValue('nope');
|
|
expect(await thrown(() => makeController({ resolveUrl }).resolveUrl({ url: 'bad' }))).toEqual({
|
|
status: 400,
|
|
body: { error: 'Failed to resolve URL' },
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('GET /reverse', () => {
|
|
it('forwards lang through to the service', async () => {
|
|
const reverse = vi.fn().mockResolvedValue({ name: null, address: null });
|
|
await makeController({ reverse }).reverse('1', '2', 'fr');
|
|
expect(reverse).toHaveBeenCalledWith('1', '2', 'fr');
|
|
});
|
|
});
|
|
|
|
describe('GET /brand-logo/:wikidataId', () => {
|
|
/** The same response double the photo-bytes cases use, minus their streaming. */
|
|
function makeRes() {
|
|
const sent: Buffer[] = [];
|
|
const res = {
|
|
statusCode: 200,
|
|
status: vi.fn((c: number) => {
|
|
res.statusCode = c;
|
|
return res;
|
|
}),
|
|
set: vi.fn(),
|
|
type: vi.fn(),
|
|
send: vi.fn((b: Buffer) => {
|
|
sent.push(b);
|
|
return res;
|
|
}),
|
|
end: vi.fn(),
|
|
json: vi.fn(),
|
|
body: () => Buffer.concat(sent),
|
|
};
|
|
return res as unknown as Response & {
|
|
status: ReturnType<typeof vi.fn>;
|
|
set: ReturnType<typeof vi.fn>;
|
|
type: ReturnType<typeof vi.fn>;
|
|
send: ReturnType<typeof vi.fn>;
|
|
end: ReturnType<typeof vi.fn>;
|
|
body: () => Buffer;
|
|
};
|
|
}
|
|
|
|
it('serves the flattened bytes with the type the service reports', async () => {
|
|
const bytes = Buffer.from('89504e470d0a1a0a', 'hex');
|
|
const brandLogo = vi.fn().mockResolvedValue({ bytes, contentType: 'image/png' });
|
|
const res = makeRes();
|
|
|
|
await makeController({ brandLogo }).brandLogo('Q565734', res);
|
|
|
|
expect(brandLogo).toHaveBeenCalledWith('Q565734');
|
|
expect(res.type).toHaveBeenCalledWith('image/png');
|
|
expect(res.set).toHaveBeenCalledWith('Cache-Control', 'public, max-age=2592000, immutable');
|
|
expect(res.body()).toEqual(bytes);
|
|
});
|
|
|
|
// A pin whose brand has no logo keeps its category icon. Answering 404 for each of
|
|
// those is the ban vector from #1727 all over again — a map full of them is a burst
|
|
// of 404s at the instance — so an unknown brand answers 204 with no body.
|
|
it('204 without a body, and without an immutable header, when there is no logo', async () => {
|
|
const res = makeRes();
|
|
|
|
await makeController({ brandLogo: vi.fn().mockResolvedValue(null) }).brandLogo('Q1', res);
|
|
|
|
expect(res.status).toHaveBeenCalledWith(204);
|
|
expect(res.end).toHaveBeenCalled();
|
|
expect(res.send).not.toHaveBeenCalled();
|
|
// A brand whose logo appears later must not stay hidden behind a month-old empty.
|
|
expect(res.set).toHaveBeenCalledWith('Cache-Control', 'no-store');
|
|
});
|
|
|
|
it('an id that is not a wikidata id is answered, not thrown at the caller', async () => {
|
|
const brandLogo = vi.fn().mockResolvedValue(null);
|
|
const res = makeRes();
|
|
|
|
await makeController({ brandLogo }).brandLogo('../../etc/passwd', res);
|
|
|
|
expect(res.status).toHaveBeenCalledWith(204);
|
|
expect(brandLogo).toHaveBeenCalledWith('../../etc/passwd');
|
|
});
|
|
});
|
|
});
|