Files
gods-eye-view/src/layers/streetLevel/providers/mapillary/source.test.mjs
T
Sameh Khamis 8cdd184a90 feat(street-level): provider exports and a Mapillary Graph endpoint for hosts (#1324)
* feat(street-level): export the KartaView and Panoramax providers

A host that composes Street Level with its own provider list can already
import the Mapillary and Google Street View providers; KartaView and Panoramax
were reachable only through the default list, which needs a Mapillary source.
Exporting them lets a host offer the keyless providers without Mapillary.

* chore: changelog entry for the Street Level provider exports

* feat(street-level): send Mapillary Graph lookups to a source endpoint

A Mapillary source can take a graphEndpoint, a Graph API base that adds
the token itself. Its sequence lookups go there through the source's
transport, and the photo viewer uses MapillaryJS's stock GraphDataProvider
at that endpoint, so a host that proxies Mapillary needs no client token in
the page. Without it, behaviour is unchanged: direct Graph API calls with
the client token.
2026-10-23 07:47:57 -07:00

160 lines
5.6 KiB
JavaScript

import assert from 'node:assert/strict';
import test from 'node:test';
import { createMapillarySource, MapillarySourceError } from './source.js';
function fakeFetch(handler) {
const calls = [];
const fetchImpl = async (url, init = {}) => {
calls.push({ url: String(url), init });
return handler(String(url), init);
};
return { calls, fetchImpl };
}
const jsonResponse = (payload, status = 200) =>
new Response(JSON.stringify(payload), {
status,
headers: { 'content-type': 'application/json' },
});
test('tiles go through the local proxy and never carry the token', async () => {
const { calls, fetchImpl } = fakeFetch(
() => new Response(new Uint8Array([1, 2, 3]), { status: 200 }),
);
const source = createMapillarySource({ token: 'MLY|1|abc', fetchImpl });
const bytes = await source.getTile('coverage', 14, 2662, 6285);
assert.deepEqual([...bytes], [1, 2, 3]);
assert.equal(calls[0].url, '/api/mapillary/tiles/coverage/14/2662/6285');
assert.doesNotMatch(calls[0].url, /MLY/);
});
test('an empty tile (204) is an empty byte array, not an error', async () => {
const { fetchImpl } = fakeFetch(() => new Response(null, { status: 204 }));
const source = createMapillarySource({ token: 'MLY|1|abc', fetchImpl });
assert.equal((await source.getTile('coverage', 14, 1, 1)).length, 0);
});
test('a missing key on the proxy surfaces as keyRequired', async () => {
const { fetchImpl } = fakeFetch(() =>
jsonResponse({ error: 'no_key', keyRequired: true }, 503),
);
const source = createMapillarySource({ token: '', fetchImpl });
assert.equal(source.hasToken(), false);
await assert.rejects(
() => source.getTile('coverage', 14, 1, 1),
(error) =>
error instanceof MapillarySourceError &&
error.status === 503 &&
error.keyRequired === true,
);
});
test('graph lookups call graph.mapillary.com with the client token and requested fields', async () => {
const { calls, fetchImpl } = fakeFetch(() =>
jsonResponse({ data: [{ id: '1', geometry: { coordinates: [1, 2] } }] }),
);
const source = createMapillarySource({ token: 'MLY|1|abc', fetchImpl });
const images = await source.getSequenceImages('seq-1');
assert.equal(images.length, 1);
const url = new URL(calls[0].url);
assert.equal(url.host, 'graph.mapillary.com');
assert.equal(url.pathname, '/images');
assert.equal(url.searchParams.get('access_token'), 'MLY|1|abc');
assert.equal(url.searchParams.get('sequence_ids'), 'seq-1');
assert.match(url.searchParams.get('fields'), /geometry/);
});
test('graph calls without a token fail fast as keyRequired without a request', async () => {
const { calls, fetchImpl } = fakeFetch(() => jsonResponse({}));
const source = createMapillarySource({ token: '', fetchImpl });
await assert.rejects(
() => source.getSequenceImages('123'),
(error) =>
error instanceof MapillarySourceError && error.keyRequired === true,
);
assert.equal(calls.length, 0);
});
test('graph API errors carry the upstream message and status', async () => {
const { fetchImpl } = fakeFetch(() =>
jsonResponse({ error: { message: 'Invalid OAuth 2.0 Access Token' } }, 400),
);
const source = createMapillarySource({ token: 'MLY|1|abc', fetchImpl });
await assert.rejects(
() => source.getSequenceImages('999'),
(error) =>
error instanceof MapillarySourceError &&
error.status === 400 &&
/Invalid OAuth/.test(error.message),
);
});
test('a rejected key and a rate limit are told apart from other tile errors', async () => {
const rejectedSource = createMapillarySource({
token: 'MLY|1|abc',
fetchImpl: fakeFetch(() =>
jsonResponse(
{ error: 'Mapillary rejected the access token', keyRejected: true },
403,
),
).fetchImpl,
});
await assert.rejects(
() => rejectedSource.getTile('coverage', 14, 1, 1),
(error) =>
error.keyRejected === true &&
error.keyRequired === false &&
error.retryAfterSec === null,
);
const limitedSource = createMapillarySource({
token: 'MLY|1|abc',
fetchImpl: fakeFetch(
() =>
new Response(
JSON.stringify({ error: 'rate limited', retryAfter: 45 }),
{
status: 429,
headers: { 'retry-after': '30' },
},
),
).fetchImpl,
});
await assert.rejects(
() => limitedSource.getTile('coverage', 14, 1, 1),
(error) => error.retryAfterSec === 30 && error.keyRejected === false,
'the Retry-After header wins over the body',
);
});
test('with a Graph endpoint, sequence lookups go there without a token', async () => {
const { calls, fetchImpl } = fakeFetch(() =>
jsonResponse({ data: [{ id: '1', geometry: { coordinates: [1, 2] } }] }),
);
const source = createMapillarySource({
graphEndpoint: '/api/mapillary/graph',
fetchImpl,
});
assert.equal(source.graphEndpoint, '/api/mapillary/graph/');
assert.equal(source.hasToken(), false);
const images = await source.getSequenceImages('seq-1');
assert.equal(images.length, 1);
const url = new URL(calls[0].url, 'http://localhost');
assert.equal(url.pathname, '/api/mapillary/graph/images');
assert.equal(url.searchParams.get('sequence_ids'), 'seq-1');
assert.equal(url.searchParams.has('access_token'), false);
});
test('Graph endpoint errors keep their status', async () => {
const { fetchImpl } = fakeFetch(() =>
jsonResponse({ error: 'no_key', keyRequired: true }, 503),
);
const source = createMapillarySource({
graphEndpoint: '/api/mapillary/graph/',
fetchImpl,
});
await assert.rejects(
() => source.getSequenceImages('1'),
(error) => error.status === 503 && error.keyRequired === true,
);
});