mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
Ids were built from the feed's fid, which embeds an upstream server-start stamp, so every upstream restart renamed all cameras (it already had since the PR fixture). Ids now come from the validated frame URL (cat-sct-<stem>, or host and path for partner cameras), which also collapses the five duplicate rows. Frame links must sit directly in each host's frame directory with no explicit port, and sctidcam must be a bare image filename. Numeric entity decoding handles hex references and leaves invalid code points as written, so one bad name no longer empties the pack. Counts in the docs are now ~130.
684 lines
22 KiB
JavaScript
684 lines
22 KiB
JavaScript
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
|
|
import {
|
|
CATALONIA_CAMERAS_URL,
|
|
CATALONIA_MAX_CATALOG_BYTES,
|
|
} from '../../server/providers/cctv/constants.js';
|
|
import { decodeNumericEntities } from '../../server/providers/cctv/normalize.js';
|
|
import {
|
|
parseCataloniaXml,
|
|
normalizeCataloniaImageUrl,
|
|
cataloniaCameraName,
|
|
loadCataloniaSourcesFromOpenData,
|
|
} from '../../server/providers/cctv/sources.js';
|
|
|
|
/** Set (or, for `undefined`, delete) environment variables for one test. */
|
|
function withEnv(t, env) {
|
|
for (const [name, value] of Object.entries(env)) {
|
|
const previous = process.env[name];
|
|
t.after(() => {
|
|
if (previous === undefined) delete process.env[name];
|
|
else process.env[name] = previous;
|
|
});
|
|
if (value === undefined) delete process.env[name];
|
|
else process.env[name] = value;
|
|
}
|
|
}
|
|
|
|
/** Silence the loaders' progress and failure logging. */
|
|
function quiet(t) {
|
|
t.mock.method(console, 'log', () => {});
|
|
t.mock.method(console, 'warn', () => {});
|
|
}
|
|
|
|
/**
|
|
* A response whose body is a live stream, plus a flag that flips when the
|
|
* stream is cancelled. A rejection path that returns without cancelling holds
|
|
* the transport open, so the flag is what the refusal tests actually assert.
|
|
*/
|
|
const streamingResponse = (init = {}) => {
|
|
const state = { cancelled: false };
|
|
const body = new ReadableStream({
|
|
start(controller) {
|
|
controller.enqueue(new TextEncoder().encode('<?xml'));
|
|
},
|
|
cancel() {
|
|
state.cancelled = true;
|
|
},
|
|
});
|
|
return { response: new Response(body, init), state };
|
|
};
|
|
|
|
/** One `<gml:featureMember>` block matching the real feed's shape. */
|
|
function cataloniaFeature({
|
|
fid,
|
|
lon,
|
|
lat,
|
|
carretera = '',
|
|
municipi = '',
|
|
pk = '',
|
|
link = '',
|
|
font = '',
|
|
}) {
|
|
const pkTag = pk ? `<cite:pk>${pk}</cite:pk>` : '';
|
|
return (
|
|
`<gml:featureMember><cite:cameres fid="${fid}">` +
|
|
`<cite:geom><gml:Point srsName="http://www.opengis.net/gml/srs/epsg.xml#4326">` +
|
|
`<gml:coordinates xmlns:gml="http://www.opengis.net/gml" decimal="." cs="," ts=" ">${lon},${lat}</gml:coordinates>` +
|
|
`</gml:Point></cite:geom>` +
|
|
`<cite:carretera>${carretera}</cite:carretera>` +
|
|
`<cite:municipi>${municipi}</cite:municipi>${pkTag}` +
|
|
`<cite:link>${link}</cite:link>` +
|
|
`<cite:font>${font}</cite:font>` +
|
|
`</cite:cameres></gml:featureMember>`
|
|
);
|
|
}
|
|
|
|
function cataloniaFeed(features) {
|
|
return (
|
|
'<?xml version="1.0" encoding="UTF-8"?><wfs:FeatureCollection xmlns:wfs="http://www.opengis.net/wfs">' +
|
|
features.join('') +
|
|
'</wfs:FeatureCollection>'
|
|
);
|
|
}
|
|
|
|
test('parseCataloniaXml extracts every field from a feature block', () => {
|
|
const xml = cataloniaFeed([
|
|
cataloniaFeature({
|
|
fid: 'cameres.fid-52bf6eca_1a0a06bea1c_386f',
|
|
lon: '2.1849528',
|
|
lat: '41.45989301',
|
|
carretera: 'C-58',
|
|
municipi: 'Nus Trinitat',
|
|
pk: '0.50',
|
|
link: 'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=nc87.gif',
|
|
font: 'SCT',
|
|
}),
|
|
]);
|
|
const rows = parseCataloniaXml(xml);
|
|
assert.equal(rows.length, 1);
|
|
assert.deepEqual(rows[0], {
|
|
fid: 'cameres.fid-52bf6eca_1a0a06bea1c_386f',
|
|
lon: 2.1849528,
|
|
lat: 41.45989301,
|
|
carretera: 'C-58',
|
|
municipi: 'Nus Trinitat',
|
|
pk: '0.50',
|
|
link: 'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=nc87.gif',
|
|
font: 'SCT',
|
|
});
|
|
});
|
|
|
|
test('normalizeCataloniaImageUrl rewrites SCT RenderService links to the direct http TransitCamera URL', () => {
|
|
// RenderService always 302s to a hardcoded http:// Location (which
|
|
// fetchWithinHost refuses as a scheme downgrade), and mct.gencat.cat's TLS
|
|
// setup fails the handshake with Node's fetch regardless — TransitCamera
|
|
// over plain http is the only transport that actually works here, so we
|
|
// build that URL from RenderService's sctidcam id instead of registering
|
|
// the redirector.
|
|
assert.equal(
|
|
normalizeCataloniaImageUrl(
|
|
'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=nc87.gif',
|
|
),
|
|
'http://mct.gencat.cat/mct2bo/TransitCamera?nom=nc87.gif&visualitzacio=imatge',
|
|
);
|
|
assert.equal(
|
|
normalizeCataloniaImageUrl(
|
|
'https://mct.gencat.cat/mct2bo/RenderService?sctidcam=sc64.gif',
|
|
),
|
|
'http://mct.gencat.cat/mct2bo/TransitCamera?nom=sc64.gif&visualitzacio=imatge',
|
|
'an already-https RenderService link is rewritten the same way',
|
|
);
|
|
assert.equal(
|
|
normalizeCataloniaImageUrl('https://mct.gencat.cat/mct2bo/RenderService'),
|
|
'',
|
|
'a RenderService link with no sctidcam id is rejected',
|
|
);
|
|
});
|
|
|
|
test('normalizeCataloniaImageUrl accepts the other registered hosts, upgraded to https', () => {
|
|
assert.equal(
|
|
normalizeCataloniaImageUrl('http://www.bcn.cat/transit/imatges/a.gif'),
|
|
'https://www.bcn.cat/transit/imatges/a.gif',
|
|
);
|
|
assert.equal(
|
|
normalizeCataloniaImageUrl(
|
|
'https://emap.terrassa.cat/it_terrassa/cam02.jpeg',
|
|
),
|
|
'https://emap.terrassa.cat/it_terrassa/cam02.jpeg',
|
|
);
|
|
assert.equal(
|
|
normalizeCataloniaImageUrl('https://evil.example.com/mct.gencat.cat.gif'),
|
|
'',
|
|
'a look-alike host is rejected',
|
|
);
|
|
assert.equal(
|
|
normalizeCataloniaImageUrl('https://unlisted-publisher.example/frame.gif'),
|
|
'',
|
|
'any host outside the allowlist is rejected',
|
|
);
|
|
assert.equal(normalizeCataloniaImageUrl(''), '');
|
|
assert.equal(normalizeCataloniaImageUrl('not a url'), '');
|
|
});
|
|
|
|
test('normalizeCataloniaImageUrl rejects credential-bearing URLs on every allowed host', () => {
|
|
// The hostname matches the allowlist, so only an explicit userinfo check
|
|
// stops catalog data injecting credentials into the frame request.
|
|
for (const link of [
|
|
'https://user:[email protected]/transit/imatges/a.gif',
|
|
'http://[email protected]/transit/imatges/a.gif',
|
|
'https://:[email protected]/it_terrassa/cam02.jpeg',
|
|
'http://user:[email protected]/mct2bo/RenderService?sctidcam=nc87.gif',
|
|
]) {
|
|
assert.equal(normalizeCataloniaImageUrl(link), '', link);
|
|
}
|
|
});
|
|
|
|
test("normalizeCataloniaImageUrl rejects explicit ports and paths outside each host's frame directory", () => {
|
|
// An allowlisted hostname alone would let catalog data aim the frame
|
|
// request at another service or path on that host.
|
|
for (const link of [
|
|
'http://www.bcn.cat:8080/transit/imatges/a.gif',
|
|
'https://emap.terrassa.cat:8443/it_terrassa/cam02.jpeg',
|
|
'http://mct.gencat.cat:8080/mct2bo/RenderService?sctidcam=nc87.gif',
|
|
'http://www.bcn.cat/admin/a.gif',
|
|
'http://www.bcn.cat/transit/imatges/',
|
|
'http://www.bcn.cat/transit/imatges/sub/a.gif',
|
|
'http://www.bcn.cat/transit/imatges/../../admin/a.gif',
|
|
'https://emap.terrassa.cat/other/cam02.jpeg',
|
|
'http://mct.gencat.cat/mct2bo/OtherService?sctidcam=nc87.gif',
|
|
]) {
|
|
assert.equal(normalizeCataloniaImageUrl(link), '', link);
|
|
}
|
|
});
|
|
|
|
test('normalizeCataloniaImageUrl accepts only a bare image filename as the SCT sctidcam', () => {
|
|
for (const camId of [
|
|
'../../etc/passwd',
|
|
'..%2F..%2Fetc%2Fpasswd',
|
|
'nc87.png',
|
|
'nc87',
|
|
'nc87.gif/../x.gif',
|
|
'nc87.gif&nom=other.gif',
|
|
'nc-87.gif',
|
|
]) {
|
|
const link = `http://mct.gencat.cat/mct2bo/RenderService?sctidcam=${encodeURIComponent(camId)}`;
|
|
assert.equal(normalizeCataloniaImageUrl(link), '', camId);
|
|
}
|
|
assert.equal(
|
|
normalizeCataloniaImageUrl(
|
|
'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=CB10.JPEG',
|
|
),
|
|
'http://mct.gencat.cat/mct2bo/TransitCamera?nom=CB10.JPEG&visualitzacio=imatge',
|
|
);
|
|
});
|
|
|
|
test('decodeNumericEntities decodes decimal and hex references and leaves invalid ones as written', () => {
|
|
assert.equal(decodeNumericEntities('Lluís'), 'Lluís');
|
|
assert.equal(decodeNumericEntities('Lluís 🚦'), 'Lluís 🚦');
|
|
assert.equal(decodeNumericEntities('Lluís'), 'Lluís');
|
|
for (const raw of [
|
|
'a�b',
|
|
'a�b',
|
|
'a�b',
|
|
'a�b',
|
|
'a�b',
|
|
'a�b',
|
|
'a�b',
|
|
]) {
|
|
assert.equal(decodeNumericEntities(raw), raw, raw);
|
|
}
|
|
});
|
|
|
|
test('cataloniaCameraName joins road and municipality, skipping a redundant municipality', () => {
|
|
assert.equal(
|
|
cataloniaCameraName('C-58', 'Nus Trinitat'),
|
|
'C-58 (Nus Trinitat)',
|
|
);
|
|
assert.equal(
|
|
cataloniaCameraName('Plaça Urquinaona', 'Barcelona'),
|
|
'Plaça Urquinaona (Barcelona)',
|
|
);
|
|
assert.equal(
|
|
cataloniaCameraName('Barcelona Ronda Litoral', 'Barcelona'),
|
|
'Barcelona Ronda Litoral',
|
|
);
|
|
assert.equal(cataloniaCameraName('', ''), 'Càmera de trànsit');
|
|
assert.equal(cataloniaCameraName('C-58', ''), 'C-58');
|
|
});
|
|
|
|
test('Catalonia loader keeps SCT, Barcelona and Terrassa cameras with per-publisher credit', async (t) => {
|
|
quiet(t);
|
|
withEnv(t, { CCTV_CATALONIA_MAX_SOURCES: undefined });
|
|
const requested = [];
|
|
t.mock.method(globalThis, 'fetch', async (url, init) => {
|
|
requested.push([String(url), init.redirect]);
|
|
const xml = cataloniaFeed([
|
|
cataloniaFeature({
|
|
fid: 'cameres.fid-1',
|
|
lon: '2.1849528',
|
|
lat: '41.45989301',
|
|
carretera: 'C-58',
|
|
municipi: 'Nus Trinitat',
|
|
pk: '0.50',
|
|
link: 'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=nc87.gif',
|
|
font: 'SCT',
|
|
}),
|
|
cataloniaFeature({
|
|
fid: 'cameres.fid-2',
|
|
lon: '2.17228212',
|
|
lat: '41.38890733',
|
|
carretera: 'Plaça Urquinaona',
|
|
municipi: 'Barcelona',
|
|
link: 'http://www.bcn.cat/transit/imatges/PlUrquinaona.gif?a=1',
|
|
font: 'IMI',
|
|
}),
|
|
cataloniaFeature({
|
|
fid: 'cameres.fid-3',
|
|
lon: '2.0111',
|
|
lat: '41.5636',
|
|
carretera: 'Cam02',
|
|
municipi: 'Terrassa',
|
|
link: 'https://emap.terrassa.cat/it_terrassa/cam02.jpeg?a=1',
|
|
font: 'Terrassa',
|
|
}),
|
|
// Rejected: link host not in the allowlist.
|
|
cataloniaFeature({
|
|
fid: 'cameres.fid-4',
|
|
lon: '2.0',
|
|
lat: '41.5',
|
|
link: 'https://example.com/fake.gif',
|
|
font: 'SCT',
|
|
}),
|
|
// Rejected: outside the Catalonia bounding box.
|
|
cataloniaFeature({
|
|
fid: 'cameres.fid-5',
|
|
lon: '-3.7',
|
|
lat: '40.4',
|
|
link: 'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=x.gif',
|
|
font: 'SCT',
|
|
}),
|
|
]);
|
|
return new Response(xml, {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/xml' },
|
|
});
|
|
});
|
|
|
|
const cameras = await loadCataloniaSourcesFromOpenData();
|
|
|
|
assert.deepEqual(requested, [[CATALONIA_CAMERAS_URL, 'manual']]);
|
|
assert.ok(
|
|
CATALONIA_CAMERAS_URL.startsWith('https://'),
|
|
'the catalog is fetched over https',
|
|
);
|
|
assert.deepEqual(
|
|
cameras.map((camera) => camera.id).sort(),
|
|
[
|
|
'cat-emap-terrassa-cat-it-terrassa-cam02-jpeg',
|
|
'cat-sct-nc87',
|
|
'cat-www-bcn-cat-transit-imatges-plurquinaona-gif',
|
|
],
|
|
'the off-allowlist link (fid-4) and the out-of-bbox row (fid-5) are excluded',
|
|
);
|
|
|
|
const sct = cameras.find((c) => c.id === 'cat-sct-nc87');
|
|
assert.equal(sct.name, 'C-58 (Nus Trinitat)');
|
|
assert.equal(sct.provider, 'Servei Català de Trànsit');
|
|
assert.equal(sct.credit, '', 'SCT is the publisher, no separate credit');
|
|
assert.equal(
|
|
sct.url,
|
|
'http://mct.gencat.cat/mct2bo/TransitCamera?nom=nc87.gif&visualitzacio=imatge',
|
|
'registers the direct, non-redirecting http frame URL, not the RenderService redirector',
|
|
);
|
|
assert.equal(sct.groundElevationM, 80);
|
|
assert.equal(sct.headingConfidence, 'low', 'the feed carries no heading');
|
|
|
|
const bcn = cameras.find(
|
|
(c) => c.id === 'cat-www-bcn-cat-transit-imatges-plurquinaona-gif',
|
|
);
|
|
assert.equal(bcn.credit, 'Ajuntament de Barcelona');
|
|
assert.equal(bcn.city, 'Barcelona');
|
|
assert.equal(bcn.groundElevationM, 20);
|
|
|
|
const terrassa = cameras.find(
|
|
(c) => c.id === 'cat-emap-terrassa-cat-it-terrassa-cam02-jpeg',
|
|
);
|
|
assert.equal(terrassa.credit, 'Ajuntament de Terrassa');
|
|
assert.equal(terrassa.groundElevationM, 270);
|
|
});
|
|
|
|
test('Catalonia loader fails soft on HTTP errors, empty payloads and network errors', async (t) => {
|
|
quiet(t);
|
|
let respond;
|
|
t.mock.method(globalThis, 'fetch', (...args) => respond(...args));
|
|
for (respond of [
|
|
async () => new Response('unavailable', { status: 503 }),
|
|
async () => new Response('<wfs:FeatureCollection></wfs:FeatureCollection>'),
|
|
async () => {
|
|
throw new TypeError('fetch failed');
|
|
},
|
|
]) {
|
|
assert.deepEqual(await loadCataloniaSourcesFromOpenData(), []);
|
|
}
|
|
});
|
|
|
|
test('the Catalonia catalog fetch refuses redirects and oversized bodies', async (t) => {
|
|
t.mock.method(console, 'warn', () => {});
|
|
// A redirect is never followed: the list host cannot be steered or
|
|
// downgraded. Its body is a live stream, so the test also proves the
|
|
// refusal releases the transport.
|
|
const seen = [];
|
|
const redirected = streamingResponse({
|
|
status: 302,
|
|
headers: { location: 'http://www.gencat.cat/transit/opendata/cameres.xml' },
|
|
});
|
|
t.mock.method(globalThis, 'fetch', async (url, init) => {
|
|
seen.push([String(url), init.redirect]);
|
|
return redirected.response;
|
|
});
|
|
assert.deepEqual(await loadCataloniaSourcesFromOpenData(), []);
|
|
assert.deepEqual(seen, [[CATALONIA_CAMERAS_URL, 'manual']]);
|
|
assert.equal(
|
|
redirected.state.cancelled,
|
|
true,
|
|
'the redirect body is cancelled',
|
|
);
|
|
|
|
// An HTTP error body is released too.
|
|
t.mock.restoreAll();
|
|
t.mock.method(console, 'warn', () => {});
|
|
const failed = streamingResponse({ status: 503 });
|
|
t.mock.method(globalThis, 'fetch', async () => failed.response);
|
|
assert.deepEqual(await loadCataloniaSourcesFromOpenData(), []);
|
|
assert.equal(failed.state.cancelled, true, 'the error body is cancelled');
|
|
|
|
const validFeed = cataloniaFeed([
|
|
cataloniaFeature({
|
|
fid: 'cameres.fid-1',
|
|
lon: '2.1849528',
|
|
lat: '41.45989301',
|
|
carretera: 'C-58',
|
|
link: 'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=nc87.gif',
|
|
font: 'SCT',
|
|
}),
|
|
]);
|
|
|
|
// A body that declares a size over the cap is refused rather than buffered,
|
|
// even though its actual content would parse.
|
|
t.mock.restoreAll();
|
|
t.mock.method(console, 'warn', () => {});
|
|
t.mock.method(globalThis, 'fetch', async () => {
|
|
return new Response(validFeed, {
|
|
headers: {
|
|
'Content-Type': 'application/xml',
|
|
'content-length': String(CATALONIA_MAX_CATALOG_BYTES + 1),
|
|
},
|
|
});
|
|
});
|
|
assert.deepEqual(await loadCataloniaSourcesFromOpenData(), []);
|
|
|
|
// So is a body with no declared size that only turns out too long while
|
|
// streaming — a valid feed padded past the cap.
|
|
t.mock.restoreAll();
|
|
t.mock.method(console, 'warn', () => {});
|
|
t.mock.method(globalThis, 'fetch', async () => {
|
|
const oversized =
|
|
validFeed + ' '.repeat(CATALONIA_MAX_CATALOG_BYTES + 1024);
|
|
return new Response(
|
|
new ReadableStream({
|
|
start(controller) {
|
|
controller.enqueue(new TextEncoder().encode(oversized));
|
|
controller.close();
|
|
},
|
|
}),
|
|
{ headers: { 'Content-Type': 'application/xml' } },
|
|
);
|
|
});
|
|
assert.deepEqual(await loadCataloniaSourcesFromOpenData(), []);
|
|
});
|
|
|
|
test('CCTV catalog merges Catalonia cameras and CCTV_CATALONIA_ENABLED=0 skips the request', async (t) => {
|
|
quiet(t);
|
|
const sourceRoot = fs.mkdtempSync(
|
|
path.join(os.tmpdir(), 'gev-cctv-catalog-catalonia-'),
|
|
);
|
|
t.after(() => fs.rmSync(sourceRoot, { recursive: true, force: true }));
|
|
withEnv(t, {
|
|
CCTV_SOURCES_FILE: undefined,
|
|
CCTV_SOURCES_JSON: undefined,
|
|
CCTV_FORCE_AUSTIN: undefined,
|
|
CCTV_PREFER_AUSTIN: undefined,
|
|
CCTV_MAX_SOURCES: undefined,
|
|
CCTV_CALTRANS_DISTRICTS: undefined,
|
|
CCTV_TFL_ENABLED: undefined,
|
|
CCTV_CATALONIA_ENABLED: undefined,
|
|
CCTV_CATALONIA_MAX_SOURCES: undefined,
|
|
});
|
|
const requested = [];
|
|
t.mock.method(globalThis, 'fetch', async (url, init) => {
|
|
requested.push([String(url), init.redirect]);
|
|
if (String(url) !== CATALONIA_CAMERAS_URL) {
|
|
return new Response('unavailable', { status: 503 });
|
|
}
|
|
const xml = cataloniaFeed([
|
|
cataloniaFeature({
|
|
fid: 'cameres.fid-1',
|
|
lon: '2.1849528',
|
|
lat: '41.45989301',
|
|
link: 'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=nc87.gif',
|
|
font: 'SCT',
|
|
}),
|
|
]);
|
|
return new Response(xml, { status: 200 });
|
|
});
|
|
|
|
const sources = await createCctvCatalog({ sourceRoot })();
|
|
assert.deepEqual(
|
|
sources.map((source) => source.id),
|
|
['cat-sct-nc87'],
|
|
);
|
|
|
|
process.env.CCTV_CATALONIA_ENABLED = '0';
|
|
requested.length = 0;
|
|
assert.deepEqual(await createCctvCatalog({ sourceRoot })(), []);
|
|
assert.ok(requested.length > 0, 'the other live packs still load');
|
|
assert.equal(
|
|
requested.some(([url]) => url === CATALONIA_CAMERAS_URL),
|
|
false,
|
|
'a disabled pack makes no request',
|
|
);
|
|
});
|
|
|
|
/** The two SCT, one Barcelona and one Terrassa rows of a real feed snapshot,
|
|
* under one upstream run's `fid` prefix. */
|
|
function snapshotFeed(fidPrefix) {
|
|
return cataloniaFeed([
|
|
cataloniaFeature({
|
|
fid: `cameres.fid-${fidPrefix}_386f`,
|
|
lon: '2.1849528',
|
|
lat: '41.45989301',
|
|
carretera: 'C-58',
|
|
link: 'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=nc87.gif',
|
|
font: 'SCT',
|
|
}),
|
|
cataloniaFeature({
|
|
fid: `cameres.fid-${fidPrefix}_b48`,
|
|
lon: '2.18',
|
|
lat: '41.46',
|
|
carretera: 'C-31',
|
|
link: 'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=nc85.gif',
|
|
font: 'SCT',
|
|
}),
|
|
cataloniaFeature({
|
|
fid: `cameres.fid-${fidPrefix}_1c2`,
|
|
lon: '2.17228212',
|
|
lat: '41.38890733',
|
|
carretera: 'Plaça Urquinaona',
|
|
municipi: 'Barcelona',
|
|
link: 'http://www.bcn.cat/transit/imatges/PlUrquinaona.gif?a=1',
|
|
font: 'IMI',
|
|
}),
|
|
cataloniaFeature({
|
|
fid: `cameres.fid-${fidPrefix}_1c3`,
|
|
lon: '2.0111',
|
|
lat: '41.5636',
|
|
municipi: 'Terrassa',
|
|
link: 'https://emap.terrassa.cat/it_terrassa/cam01.jpeg?a=1 ',
|
|
font: 'Terrassa',
|
|
}),
|
|
]);
|
|
}
|
|
|
|
async function loadFromFeed(t, xml) {
|
|
t.mock.method(globalThis, 'fetch', async () => {
|
|
return new Response(xml, {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/xml' },
|
|
});
|
|
});
|
|
try {
|
|
return await loadCataloniaSourcesFromOpenData();
|
|
} finally {
|
|
t.mock.restoreAll();
|
|
}
|
|
}
|
|
|
|
test('Catalonia camera ids survive an upstream restart that changes every fid', async (t) => {
|
|
quiet(t);
|
|
withEnv(t, { CCTV_CATALONIA_MAX_SOURCES: undefined });
|
|
// The fid embeds the upstream server-start stamp: the PR fixture's
|
|
// 1a0a06bea1c run and a later 1a140311953 run name the same cameras.
|
|
const before = await loadFromFeed(t, snapshotFeed('52bf6eca_1a0a06bea1c'));
|
|
const after = await loadFromFeed(t, snapshotFeed('52bf6eca_1a140311953'));
|
|
const ids = (cameras) => cameras.map((camera) => camera.id).sort();
|
|
assert.deepEqual(ids(before), [
|
|
'cat-emap-terrassa-cat-it-terrassa-cam01-jpeg',
|
|
'cat-sct-nc85',
|
|
'cat-sct-nc87',
|
|
'cat-www-bcn-cat-transit-imatges-plurquinaona-gif',
|
|
]);
|
|
assert.deepEqual(ids(after), ids(before));
|
|
});
|
|
|
|
test('Catalonia loader collapses rows the feed repeats for one camera', async (t) => {
|
|
quiet(t);
|
|
withEnv(t, { CCTV_CATALONIA_MAX_SOURCES: undefined });
|
|
// The live feed lists nc85, cb5, cb10, cb25 and cd17 twice each, under
|
|
// different fids.
|
|
const row = (fid, camId) =>
|
|
cataloniaFeature({
|
|
fid,
|
|
lon: '2.18',
|
|
lat: '41.46',
|
|
carretera: 'C-31',
|
|
link: `http://mct.gencat.cat/mct2bo/RenderService?sctidcam=${camId}`,
|
|
font: 'SCT',
|
|
});
|
|
const cameras = await loadFromFeed(
|
|
t,
|
|
cataloniaFeed([
|
|
row('cameres.fid-x_1', 'nc85.gif'),
|
|
row('cameres.fid-x_2', 'cb5.gif'),
|
|
row('cameres.fid-x_3', 'nc85.gif'),
|
|
row('cameres.fid-x_4', 'cb5.gif'),
|
|
row('cameres.fid-x_5', 'cb10.gif'),
|
|
]),
|
|
);
|
|
assert.deepEqual(cameras.map((camera) => camera.id).sort(), [
|
|
'cat-sct-cb10',
|
|
'cat-sct-cb5',
|
|
'cat-sct-nc85',
|
|
]);
|
|
});
|
|
|
|
test('Catalonia loader drops ported and bad-sctidcam links but keeps the rest', async (t) => {
|
|
quiet(t);
|
|
withEnv(t, { CCTV_CATALONIA_MAX_SOURCES: undefined });
|
|
const cameras = await loadFromFeed(
|
|
t,
|
|
cataloniaFeed([
|
|
cataloniaFeature({
|
|
fid: 'cameres.fid-x_1',
|
|
lon: '2.17',
|
|
lat: '41.39',
|
|
link: 'http://www.bcn.cat:8080/transit/imatges/PlUrquinaona.gif?a=1',
|
|
font: 'IMI',
|
|
}),
|
|
cataloniaFeature({
|
|
fid: 'cameres.fid-x_2',
|
|
lon: '2.0111',
|
|
lat: '41.5636',
|
|
link: 'https://emap.terrassa.cat:8443/it_terrassa/cam02.jpeg?a=1',
|
|
font: 'Terrassa',
|
|
}),
|
|
cataloniaFeature({
|
|
fid: 'cameres.fid-x_3',
|
|
lon: '2.18',
|
|
lat: '41.46',
|
|
link: 'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=../../etc/passwd',
|
|
font: 'SCT',
|
|
}),
|
|
cataloniaFeature({
|
|
fid: 'cameres.fid-x_4',
|
|
lon: '2.18',
|
|
lat: '41.46',
|
|
link: 'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=nc87.gif',
|
|
font: 'SCT',
|
|
}),
|
|
]),
|
|
);
|
|
assert.deepEqual(
|
|
cameras.map((camera) => [camera.id, camera.url]),
|
|
[
|
|
[
|
|
'cat-sct-nc87',
|
|
'http://mct.gencat.cat/mct2bo/TransitCamera?nom=nc87.gif&visualitzacio=imatge',
|
|
],
|
|
],
|
|
);
|
|
});
|
|
|
|
test('a malformed numeric entity in one row neither throws nor empties the Catalonia pack', async (t) => {
|
|
quiet(t);
|
|
withEnv(t, { CCTV_CATALONIA_MAX_SOURCES: undefined });
|
|
const cameras = await loadFromFeed(
|
|
t,
|
|
cataloniaFeed([
|
|
cataloniaFeature({
|
|
fid: 'cameres.fid-x_1',
|
|
lon: '2.18',
|
|
lat: '41.46',
|
|
carretera: 'C-31 �',
|
|
municipi: 'Bad�',
|
|
link: 'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=nc85.gif',
|
|
font: 'SCT',
|
|
}),
|
|
cataloniaFeature({
|
|
fid: 'cameres.fid-x_2',
|
|
lon: '2.18',
|
|
lat: '41.46',
|
|
carretera: 'Plaça Catalunya',
|
|
municipi: 'Llinars del Vallès',
|
|
link: 'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=nc87.gif',
|
|
font: 'SCT',
|
|
}),
|
|
]),
|
|
);
|
|
const byId = new Map(cameras.map((camera) => [camera.id, camera]));
|
|
assert.equal(cameras.length, 2);
|
|
assert.equal(
|
|
byId.get('cat-sct-nc85').name,
|
|
'C-31 � (Bad�)',
|
|
'an invalid reference is kept as written',
|
|
);
|
|
assert.equal(
|
|
byId.get('cat-sct-nc87').name,
|
|
'Plaça Catalunya (Llinars del Vallès)',
|
|
);
|
|
});
|