Merge branch 'main' into pr/panoramax

This commit is contained in:
Said Turksever authored and GitHub committed 2026-10-15 20:17:28 +01:00
commit ab14f48360
10 files changed
+385 -9

No files matched your search

+96
View File
@@ -17,6 +17,7 @@ import { createModel } from './model.js';
import { CITY_BY_ID } from './registry.js';
const AUSTIN = CITY_BY_ID.get('austin-capmetro');
const OSLO = CITY_BY_ID.get('oslo-bysykkel');
const STATION_INFORMATION = {
data: {
@@ -204,6 +205,101 @@ test('the proxy relays its cache policy and upstream host through the mounted ro
);
});
test('the Norwegian city-bike systems are served through the mounted route', async () => {
for (const [id, systemId] of [
['oslo-bysykkel', 'oslobysykkel.no'],
['bergen-bysykkel', 'bergenbysykkel.no'],
['trondheim-bysykkel', 'trondheimbysykkel.no'],
]) {
const system = CITY_BY_ID.get(id);
assert.ok(system, `${id} is registered`);
assert.deepEqual(system.hosts, ['gbfs.urbansharing.com']);
assert.equal(
system.stationStatusUrl,
`https://gbfs.urbansharing.com/${systemId}/station_status.json`,
);
}
await withUpstream(
{
[OSLO.stationInformationUrl]: STATION_INFORMATION,
[OSLO.stationStatusUrl]: STATION_STATUS,
},
async (requested) => {
const source = createBikeshareSource({ fetchImpl: mountGbfsProxy() });
const info = await source.getStations(OSLO.stationInformationUrl);
const status = await source.getStations(OSLO.stationStatusUrl);
assert.deepEqual(requested, [
OSLO.stationInformationUrl,
OSLO.stationStatusUrl,
]);
assert.equal(info.data.stations.length, 1);
assert.equal(status.data.stations.length, 1);
const response = await mountGbfsProxy()(
'/api/gbfs/' + encodeURIComponent(OSLO.stationStatusUrl),
);
assert.equal(response.status, 200);
assert.equal(
response.headers.get('x-gbfs-upstream'),
'gbfs.urbansharing.com',
);
},
);
});
test('every Norwegian city-bike system is registered and reachable through the mounted route', async () => {
const expected = {
'stavanger-kolumbus':
'https://api.entur.io/mobility/v2/gbfs/v2/kolumbusbysykkel/station_status',
'skien-farte':
'https://api.entur.io/mobility/v2/gbfs/v2/fartebysykkel/station_status',
'lillestrom-bysykkel':
'https://api.cyclocity.fr/contracts/lillestrom/gbfs/station_status.json',
};
const upstream = {};
for (const [id, statusUrl] of Object.entries(expected)) {
const system = CITY_BY_ID.get(id);
assert.ok(system, `${id} is registered`);
assert.equal(system.stationStatusUrl, statusUrl);
upstream[system.stationInformationUrl] = STATION_INFORMATION;
upstream[system.stationStatusUrl] = STATION_STATUS;
}
await withUpstream(upstream, async (requested) => {
const source = createBikeshareSource({ fetchImpl: mountGbfsProxy() });
for (const id of Object.keys(expected)) {
const system = CITY_BY_ID.get(id);
const info = await source.getStations(system.stationInformationUrl);
const status = await source.getStations(system.stationStatusUrl);
assert.equal(info.data.stations.length, 1, `${id} information`);
assert.equal(status.data.stations.length, 1, `${id} status`);
}
assert.equal(requested.length, 6);
// Entur's extensionless station_information keeps its 5-minute cache.
const kolumbus = CITY_BY_ID.get('stavanger-kolumbus');
const response = await mountGbfsProxy()(
'/api/gbfs/' + encodeURIComponent(kolumbus.stationInformationUrl),
);
assert.equal(response.headers.get('cache-control'), 'public, max-age=300');
});
});
test('the mounted route keeps multi-API hosts to their GBFS tree', async () => {
await withUpstream({}, async (requested) => {
const mounted = mountGbfsProxy();
for (const target of [
'https://api.entur.io/journey-planner/v3/station_status',
'https://api.cyclocity.fr/other/station_status.json',
]) {
const response = await mounted('/api/gbfs/' + encodeURIComponent(target));
assert.equal(response.status, 400, target);
}
assert.deepEqual(requested, []);
});
});
test('the query-string shape the client used to send is refused by the mounted route', async () => {
await withUpstream(
{ [AUSTIN.stationStatusUrl]: STATION_STATUS },
+99 -3
View File
@@ -23,6 +23,47 @@ function bcycleEntry({
...buildBcycleUrls(systemId),
};
}
function urbanSharingEntry({
id,
city,
centerLat,
centerLon,
systemId,
provider,
loadRadiusKm = 100,
}) {
return {
id,
city,
centerLat,
centerLon,
loadRadiusKm,
provider,
stationInformationUrl: `https://gbfs.urbansharing.com/${systemId}/station_information.json`,
stationStatusUrl: `https://gbfs.urbansharing.com/${systemId}/station_status.json`,
};
}
function enturEntry({
id,
city,
centerLat,
centerLon,
systemId,
provider,
loadRadiusKm = 100,
}) {
const base = `https://api.entur.io/mobility/v2/gbfs/v2/${systemId}`;
return {
id,
city,
centerLat,
centerLon,
loadRadiusKm,
provider,
stationInformationUrl: `${base}/station_information`,
stationStatusUrl: `${base}/station_status`,
};
}
const RAW_GBFS_CITY_REGISTRY = [
{
id: 'nyc-citibike',
@@ -368,6 +409,59 @@ const RAW_GBFS_CITY_REGISTRY = [
centerLon: -119.6982,
systemId: 'bcycle_santabarbara',
}),
urbanSharingEntry({
id: 'oslo-bysykkel',
city: 'Oslo, Norway',
centerLat: 59.9139,
centerLon: 10.7522,
systemId: 'oslobysykkel.no',
provider: 'Oslo Bysykkel',
}),
urbanSharingEntry({
id: 'bergen-bysykkel',
city: 'Bergen, Norway',
centerLat: 60.3913,
centerLon: 5.3221,
systemId: 'bergenbysykkel.no',
provider: 'Bergen Bysykkel',
}),
urbanSharingEntry({
id: 'trondheim-bysykkel',
city: 'Trondheim, Norway',
centerLat: 63.4305,
centerLon: 10.3951,
systemId: 'trondheimbysykkel.no',
provider: 'Trondheim Bysykkel',
}),
enturEntry({
id: 'stavanger-kolumbus',
city: 'Stavanger, Norway',
centerLat: 58.9,
centerLon: 5.72,
systemId: 'kolumbusbysykkel',
provider: 'Kolumbus Bysykkel',
}),
enturEntry({
id: 'skien-farte',
city: 'Skien/Porsgrunn, Norway',
centerLat: 59.16,
centerLon: 9.64,
systemId: 'fartebysykkel',
provider: 'Farte Bysykkel',
loadRadiusKm: 40,
}),
{
id: 'lillestrom-bysykkel',
city: 'Lillestrøm, Norway',
centerLat: 59.9556,
centerLon: 11.0492,
loadRadiusKm: 40,
stationInformationUrl:
'https://api.cyclocity.fr/contracts/lillestrom/gbfs/station_information.json',
stationStatusUrl:
'https://api.cyclocity.fr/contracts/lillestrom/gbfs/station_status.json',
provider: 'Bysykkel Lillestrøm',
},
];
function normalizeRegistryEntry(entry) {
const id = String(entry?.id || '')
@@ -396,11 +490,13 @@ function normalizeRegistryEntry(entry) {
) {
throw new Error(`GBFS entry "${id}" must use https URLs`);
}
// Validate that URLs end with expected GBFS endpoint filenames
if (!/\/station_information\.json$/i.test(stationInformationUrl.pathname)) {
// Validate that URLs end with expected GBFS endpoint names (Entur omits .json)
if (
!/\/station_information(\.json)?$/i.test(stationInformationUrl.pathname)
) {
throw new Error(`GBFS entry "${id}" station_information URL is invalid`);
}
if (!/\/station_status\.json$/i.test(stationStatusUrl.pathname)) {
if (!/\/station_status(\.json)?$/i.test(stationStatusUrl.pathname)) {
throw new Error(`GBFS entry "${id}" station_status URL is invalid`);
}