add passenger telemetry
This commit is contained in:
142
backend/test/passengerTelemetryAggregator.test.js
Normal file
142
backend/test/passengerTelemetryAggregator.test.js
Normal file
@@ -0,0 +1,142 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
|
||||
const { PassengerTelemetryAggregator } = require('../src/services/passengerTelemetryAggregator');
|
||||
|
||||
const NOW = new Date('2026-05-20T12:30:00Z');
|
||||
const routeRows = [{
|
||||
id: 1,
|
||||
route_number: '12',
|
||||
name: 'Route 12',
|
||||
type: 'bus',
|
||||
color: '#0066CC',
|
||||
stops: [
|
||||
{ stop_id: 1, sequence: 1, latitude: 55.485, longitude: 28.768 },
|
||||
{ stop_id: 2, sequence: 2, latitude: 55.485, longitude: 28.778 }
|
||||
]
|
||||
}];
|
||||
|
||||
function createPool(rows = routeRows) {
|
||||
return {
|
||||
async query(sql, params = []) {
|
||||
if (params.length > 0) {
|
||||
return { rows: rows.filter((row) => row.id === params[0]) };
|
||||
}
|
||||
|
||||
return { rows };
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function createAggregator(options = {}) {
|
||||
return new PassengerTelemetryAggregator({
|
||||
pool: createPool(options.rows),
|
||||
nowProvider: () => NOW,
|
||||
...options
|
||||
});
|
||||
}
|
||||
|
||||
async function ingestCluster(aggregator, accuracyM = 18) {
|
||||
await aggregator.ingest({
|
||||
session_id: 'session-a',
|
||||
latitude: 55.48502,
|
||||
longitude: 28.7700,
|
||||
accuracy_m: accuracyM,
|
||||
speed_kmh: 31,
|
||||
heading: 90,
|
||||
observed_at: '2026-05-20T12:29:55Z',
|
||||
route_id: 1
|
||||
});
|
||||
|
||||
await aggregator.ingest({
|
||||
session_id: 'session-b',
|
||||
latitude: 55.48498,
|
||||
longitude: 28.77015,
|
||||
accuracy_m: accuracyM,
|
||||
speed_kmh: 33,
|
||||
heading: 92,
|
||||
observed_at: '2026-05-20T12:29:58Z',
|
||||
route_id: 1
|
||||
});
|
||||
}
|
||||
|
||||
test('one passenger point does not create a virtual vehicle', async () => {
|
||||
const aggregator = createAggregator();
|
||||
const result = await aggregator.ingest({
|
||||
session_id: 'session-a',
|
||||
latitude: 55.485,
|
||||
longitude: 28.770,
|
||||
accuracy_m: 18,
|
||||
speed_kmh: 32,
|
||||
heading: 90,
|
||||
observed_at: '2026-05-20T12:29:55Z',
|
||||
route_id: 1
|
||||
});
|
||||
|
||||
assert.equal(result.accepted, true);
|
||||
assert.equal(result.matched, true);
|
||||
assert.deepEqual(result.virtualVehicles, []);
|
||||
assert.deepEqual(await aggregator.getVirtualVehiclesForRoute(1), []);
|
||||
});
|
||||
|
||||
test('nearby points from distinct sessions create a virtual crowdsourced vehicle', async () => {
|
||||
const aggregator = createAggregator();
|
||||
await ingestCluster(aggregator);
|
||||
const vehicles = await aggregator.getVirtualVehiclesForRoute(1);
|
||||
|
||||
assert.equal(vehicles.length, 1);
|
||||
assert.match(vehicles[0].vehicle_id, /^crowd:1:/);
|
||||
assert.equal(vehicles[0].source, 'crowdsourced');
|
||||
assert.equal(vehicles[0].source_ref, null);
|
||||
assert.equal(vehicles[0].is_estimated, true);
|
||||
assert.equal('session_id' in vehicles[0], false);
|
||||
assert.equal('session_key' in vehicles[0], false);
|
||||
});
|
||||
|
||||
test('poor accuracy lowers crowdsourced confidence', async () => {
|
||||
const goodAggregator = createAggregator();
|
||||
await ingestCluster(goodAggregator, 15);
|
||||
const [goodVehicle] = await goodAggregator.getVirtualVehiclesForRoute(1);
|
||||
|
||||
const poorAggregator = createAggregator();
|
||||
await ingestCluster(poorAggregator, 150);
|
||||
const [poorVehicle] = await poorAggregator.getVirtualVehiclesForRoute(1);
|
||||
|
||||
assert.ok(goodVehicle.confidence > poorVehicle.confidence);
|
||||
assert.equal(poorVehicle.source, 'crowdsourced');
|
||||
});
|
||||
|
||||
test('points outside route geometry are accepted but ignored', async () => {
|
||||
const aggregator = createAggregator();
|
||||
const result = await aggregator.ingest({
|
||||
session_id: 'session-a',
|
||||
latitude: 56.0,
|
||||
longitude: 29.5,
|
||||
accuracy_m: 20,
|
||||
observed_at: '2026-05-20T12:29:55Z',
|
||||
route_id: 1
|
||||
});
|
||||
|
||||
assert.equal(result.accepted, true);
|
||||
assert.equal(result.matched, false);
|
||||
assert.deepEqual(await aggregator.getVirtualVehiclesForRoute(1), []);
|
||||
});
|
||||
|
||||
test('official telemetry suppresses nearby crowdsourced virtual vehicle', async () => {
|
||||
const aggregator = createAggregator();
|
||||
await ingestCluster(aggregator);
|
||||
const [vehicle] = await aggregator.getVirtualVehiclesForRoute(1);
|
||||
|
||||
assert.ok(vehicle);
|
||||
|
||||
const suppressed = await aggregator.getVirtualVehiclesForRoute(1, {
|
||||
officialVehicles: [{
|
||||
vehicle_id: 99,
|
||||
latitude: vehicle.latitude,
|
||||
longitude: vehicle.longitude,
|
||||
is_online: true
|
||||
}]
|
||||
});
|
||||
|
||||
assert.deepEqual(suppressed, []);
|
||||
});
|
||||
110
backend/test/passengerTelemetryApi.test.js
Normal file
110
backend/test/passengerTelemetryApi.test.js
Normal file
@@ -0,0 +1,110 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
|
||||
const { PassengerTelemetryController } = require('../src/controllers/passengerTelemetryController');
|
||||
const { decorateOfficialVehicle } = require('../src/services/liveVehicleMapper');
|
||||
|
||||
function createResponse() {
|
||||
return {
|
||||
statusCode: null,
|
||||
body: null,
|
||||
status(code) {
|
||||
this.statusCode = code;
|
||||
return this;
|
||||
},
|
||||
json(payload) {
|
||||
this.body = payload;
|
||||
return this;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
test('POST passenger telemetry ingest returns accepted response and broadcasts virtual vehicles', async () => {
|
||||
const broadcasts = [];
|
||||
const service = {
|
||||
async ingest(payload) {
|
||||
assert.equal(payload.session_id, 'temporary-anonymous-id');
|
||||
return {
|
||||
accepted: true,
|
||||
routeId: 12,
|
||||
virtualVehicles: [
|
||||
{
|
||||
vehicle_id: 'crowd:12:abc123',
|
||||
route_id: 12,
|
||||
source: 'crowdsourced',
|
||||
source_ref: null,
|
||||
is_estimated: true
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const controller = new PassengerTelemetryController({
|
||||
service,
|
||||
broadcastToRoute: (routeId, message) => broadcasts.push({ routeId, message })
|
||||
});
|
||||
controller.getRouteSnapshot = async () => [
|
||||
{
|
||||
vehicle_id: 'crowd:12:abc123',
|
||||
route_id: 12,
|
||||
source: 'crowdsourced',
|
||||
source_ref: null,
|
||||
is_estimated: true
|
||||
}
|
||||
];
|
||||
const res = createResponse();
|
||||
|
||||
await controller.ingest({
|
||||
body: {
|
||||
session_id: 'temporary-anonymous-id',
|
||||
latitude: 55.485,
|
||||
longitude: 28.768
|
||||
}
|
||||
}, res);
|
||||
|
||||
assert.equal(res.statusCode, 202);
|
||||
assert.deepEqual(res.body, { accepted: true });
|
||||
assert.equal(broadcasts.length, 1);
|
||||
assert.equal(broadcasts[0].routeId, 12);
|
||||
assert.equal(broadcasts[0].message.type, 'snapshot:route');
|
||||
assert.equal(broadcasts[0].message.vehicles[0].source, 'crowdsourced');
|
||||
assert.equal('session_id' in broadcasts[0].message.vehicles[0], false);
|
||||
});
|
||||
|
||||
test('passenger telemetry controller maps validation failures to 400', async () => {
|
||||
const service = {
|
||||
async ingest() {
|
||||
const error = new Error('latitude must be between -90 and 90');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
const controller = new PassengerTelemetryController({
|
||||
service,
|
||||
broadcastToRoute: () => {}
|
||||
});
|
||||
const res = createResponse();
|
||||
|
||||
await controller.ingest({ body: {} }, res);
|
||||
|
||||
assert.equal(res.statusCode, 400);
|
||||
assert.deepEqual(res.body, { error: 'latitude must be between -90 and 90' });
|
||||
});
|
||||
|
||||
test('official live vehicle mapping adds optional realtime fields', () => {
|
||||
const vehicle = decorateOfficialVehicle({
|
||||
vehicle_id: 7,
|
||||
vehicle_number: 'DEMO-7A',
|
||||
route_id: 12,
|
||||
latitude: '55.485',
|
||||
longitude: '28.768',
|
||||
accuracy_meters: '5.5',
|
||||
last_seen_at: '2026-05-20T12:29:58Z'
|
||||
});
|
||||
|
||||
assert.equal(vehicle.source, 'official');
|
||||
assert.equal(vehicle.confidence, 1);
|
||||
assert.equal(vehicle.accuracy_m, 5.5);
|
||||
assert.equal(vehicle.is_estimated, false);
|
||||
});
|
||||
70
backend/test/passengerTelemetryValidator.test.js
Normal file
70
backend/test/passengerTelemetryValidator.test.js
Normal file
@@ -0,0 +1,70 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
|
||||
const { validatePassengerTelemetryPayload } = require('../src/services/passengerTelemetryValidator');
|
||||
|
||||
const NOW = new Date('2026-05-20T12:30:00Z');
|
||||
|
||||
test('validates and normalizes passenger telemetry payload', () => {
|
||||
const payload = validatePassengerTelemetryPayload({
|
||||
session_id: ' temporary-anonymous-id ',
|
||||
latitude: '55.485',
|
||||
longitude: '28.768',
|
||||
accuracy_m: '18.5',
|
||||
speed_kmh: '32',
|
||||
heading: '91',
|
||||
observed_at: '2026-05-20T12:29:30Z',
|
||||
route_id: '12',
|
||||
stop_id: '123',
|
||||
app_state: 'foreground'
|
||||
}, { now: NOW });
|
||||
|
||||
assert.equal(payload.sessionId, 'temporary-anonymous-id');
|
||||
assert.equal(payload.latitude, 55.485);
|
||||
assert.equal(payload.longitude, 28.768);
|
||||
assert.equal(payload.accuracyM, 18.5);
|
||||
assert.equal(payload.speedKmh, 32);
|
||||
assert.equal(payload.heading, 91);
|
||||
assert.equal(payload.routeId, 12);
|
||||
assert.equal(payload.stopId, 123);
|
||||
assert.equal(payload.appState, 'foreground');
|
||||
});
|
||||
|
||||
test('rejects invalid latitude and longitude', () => {
|
||||
assert.throws(() => validatePassengerTelemetryPayload({
|
||||
session_id: 's1',
|
||||
latitude: 91,
|
||||
longitude: 28.768
|
||||
}, { now: NOW }), /latitude must be between -90 and 90/);
|
||||
|
||||
assert.throws(() => validatePassengerTelemetryPayload({
|
||||
session_id: 's1',
|
||||
latitude: 55.485,
|
||||
longitude: 181
|
||||
}, { now: NOW }), /longitude must be between -180 and 180/);
|
||||
});
|
||||
|
||||
test('rejects stale and far-future observations', () => {
|
||||
assert.throws(() => validatePassengerTelemetryPayload({
|
||||
session_id: 's1',
|
||||
latitude: 55.485,
|
||||
longitude: 28.768,
|
||||
observed_at: '2026-05-20T12:10:00Z'
|
||||
}, { now: NOW }), /observed_at is too old/);
|
||||
|
||||
assert.throws(() => validatePassengerTelemetryPayload({
|
||||
session_id: 's1',
|
||||
latitude: 55.485,
|
||||
longitude: 28.768,
|
||||
observed_at: '2026-05-20T12:40:00Z'
|
||||
}, { now: NOW }), /observed_at is too far in the future/);
|
||||
});
|
||||
|
||||
test('rejects implausible city transit speed', () => {
|
||||
assert.throws(() => validatePassengerTelemetryPayload({
|
||||
session_id: 's1',
|
||||
latitude: 55.485,
|
||||
longitude: 28.768,
|
||||
speed_kmh: 180
|
||||
}, { now: NOW }), /speed_kmh must be between 0 and 120/);
|
||||
});
|
||||
Reference in New Issue
Block a user