Files
ridebus-admin/backend/test/boardDevices.test.js

105 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const test = require('node:test');
const assert = require('node:assert/strict');
const { createBoardDeviceService, tokenHash } = require('../src/services/boardDeviceService');
const { defaults, validSettings } = require('../src/services/boardDeviceSettings');
const { memoryRepository } = require('./helpers/boardDeviceMemoryRepository');
test('device pairing expires, preserves leading zeroes, retries collisions and has a secret distinct from the display code', async () => {
let now = Date.now(), codes = ['0847', '0847', '0009']; const repo = memoryRepository({ now: () => now });
const service = createBoardDeviceService(repo, { now: () => now, makeCode: () => codes.shift() });
const a = await service.start({ width: 879, height: 593, version: 'test' });
assert.equal(a.code, '0847'); assert.match(a.token, /^[A-Za-z0-9_-]{43}$/); assert.equal(repo.pairings.get('0847').token_hash, tokenHash(a.token));
assert.equal((await service.start({})).code, '0009');
await assert.rejects(service.status('0847'), e => e.status === 401);
now += 300001; assert.equal((await service.status(a.token)).state, 'expired');
await assert.rejects(service.claim(1, { code: '0847', name: 'A', settings: defaults }, 1), e => e.status === 409);
});
test('only one concurrent claim succeeds and the same device credential survives retry/restart', async () => {
const service = createBoardDeviceService(memoryRepository()); const p = await service.start({});
const input = { code: p.code, name: 'Улица', settings: defaults };
const claims = await Promise.allSettled([service.claim(1, input, 1), service.claim(1, input, 2)]);
assert.equal(claims.filter(c => c.status === 'fulfilled').length, 1);
assert.equal((await service.status(p.token)).state, 'paired'); assert.equal((await service.status(p.token)).device.name, 'Улица');
const json = JSON.stringify(await service.list(1)); assert.ok(!json.includes(p.token)); assert.ok(!json.includes(tokenHash(p.token)));
});
test('city/stop/route validation happens before consuming a pairing code', async () => {
const service = createBoardDeviceService(memoryRepository()); const p = await service.start({});
for (const settings of [{ ...defaults, stopId: 3 }, { ...defaults, routeFilter: '2' }, { ...defaults, rowCount: 7 }, { ...defaults, colorScheme: 'sky' }]) await assert.rejects(service.claim(1, { code: p.code, name: 'A', settings }, 1), e => e.status === 400);
assert.equal((await service.status(p.token)).state, 'pending'); await service.claim(2, { code: p.code, name: 'B', settings: { ...defaults, stopId: 3, routeFilter: '2' } }, 1);
assert.equal((await service.list(1)).length, 0); assert.equal((await service.list(2)).length, 1);
assert.deepEqual(await service.routes(2, 3), [{ id: 2, city_id: 2, route_number: '1', name: 'Новополоцк', stops: [3] }]);
});
test('configuration revisions are optimistic and applied/online state comes from device acknowledgement', async () => {
let now = Date.now(); const service = createBoardDeviceService(memoryRepository({ now: () => now }), { now: () => now }); const p = await service.start({}); const d = await service.claim(1, { code: p.code, name: 'A', settings: defaults }, 1);
const input = { name: 'A', revision: 1, settings: { ...defaults, theme: 'light', lightColorScheme: 'forest', infoQrEnabled: false } };
await assert.rejects(service.update(d.id, 2, { ...input, settings: { ...defaults, stopId: 3 } }, 1), e => e.status === 409);
const r = await Promise.allSettled([service.update(d.id, 1, input, 1), service.update(d.id, 1, input, 1)]); assert.equal(r.filter(x => x.status === 'fulfilled').length, 1);
let row = (await service.list(1))[0]; assert.equal(row.revision, 2); assert.equal(row.appliedRevision, 0); assert.equal(row.online, false);
await service.heartbeat(p.token, { appliedRevision: 2, width: 1920, height: 1080 }); row = (await service.list(1))[0]; assert.equal(row.appliedRevision, 2); assert.equal(row.online, true);
await service.heartbeat(p.token, { appliedRevision: 1 }); assert.equal((await service.list(1))[0].appliedRevision, 2);
await assert.rejects(service.heartbeat(p.token, { appliedRevision: 999 }), e => e.status === 400);
now += 90001; assert.equal((await service.list(1))[0].online, false);
});
test('revocation rejects device access and is scoped to the selected city', async () => {
const service = createBoardDeviceService(memoryRepository()); const p = await service.start({}); const d = await service.claim(1, { code: p.code, name: 'A', settings: defaults }, 1);
await assert.rejects(service.revoke(d.id, 2, 1), e => e.status === 404); await service.revoke(d.id, 1, 1);
await assert.rejects(service.status(p.token), e => e.status === 410); await assert.rejects(service.heartbeat(p.token, { appliedRevision: 1 }), e => e.status === 410);
});
test('remote settings are bounded, preserve supported palettes and cannot enable demo or accept unknown properties', () => {
assert.equal(validSettings({ ...defaults, demo: true }).demo, false);
for (const bad of [{ ...defaults, holdNear: 'yes' }, { ...defaults, adText: 'x'.repeat(201) }, { ...defaults, toString: 'bad' }, { ...defaults, stopId: NaN }, { ...defaults, routeFilter: '-1' }]) assert.throws(() => validSettings(bad), e => e.status === 400);
assert.equal(validSettings({ ...defaults, colorScheme: 'cyan' }).colorScheme, 'cyan');
});
test('night preferences validate, survive updates and migrate old stored device settings without a SQL migration', async () => {
const repo=memoryRepository(), service=createBoardDeviceService(repo);const p=await service.start({});const d=await service.claim(1,{code:p.code,name:'Night',settings:defaults},1);
delete repo.devices.get(d.id).settings.nightMode;delete repo.devices.get(d.id).settings.nightWakeMinutes;
assert.equal((await service.status(p.token)).device.settings.nightMode,'auto');assert.equal((await service.list(1))[0].settings.nightWakeMinutes,60);
assert.equal(repo.devices.get(d.id).settings.nightMode,undefined);
for(const settings of [{...defaults,nightMode:'always'},{...defaults,nightWakeMinutes:5}]) assert.throws(()=>validSettings(settings),e=>e.status===400);
await service.update(d.id,1,{name:'Night',revision:1,settings:{...defaults,nightMode:'off',nightWakeMinutes:45}},1);
const saved=(await service.status(p.token)).device;assert.equal(saved.settings.nightMode,'off');assert.equal(saved.settings.nightWakeMinutes,45);assert.equal(saved.revision,2);
});
test('transliteration migrates old devices, validates booleans and survives a remote update', async () => {
const repo = memoryRepository(), service = createBoardDeviceService(repo);
const pairing = await service.start({});
const device = await service.claim(1, { code: pairing.code, name: 'Latin', settings: defaults }, 1);
delete repo.devices.get(device.id).settings.journeyTranslitEnabled;
assert.equal((await service.status(pairing.token)).device.settings.journeyTranslitEnabled, false);
assert.equal((await service.list(1))[0].settings.journeyTranslitEnabled, false);
for (const flag of ['true', 1, null]) assert.throws(() => validSettings({ ...defaults, journeyTranslitEnabled: flag }), e => e.status === 400);
await service.update(device.id, 1, { name: 'Latin', revision: 1, settings: { ...defaults, journeyTranslitEnabled: true } }, 1);
const saved = (await service.status(pairing.token)).device;
assert.equal(saved.settings.journeyTranslitEnabled, true);
assert.equal(saved.revision, 2);
});
test('passenger configuration validates shape and city/stop/route identity before consuming a code', async () => {
const service = createBoardDeviceService(memoryRepository()), p = await service.start({});
const rule = { routeId: 1, price: 'Особый тариф', payment: 'Подтверждённый способ', checkedAt: '2026-10-11' };
for (const patch of [{ places: [{ stopId: 3, name: 'Другой город' }] }, { places: [{ stopId: 1, name: 'Та же остановка' }] },
{ fareRules: [{ ...rule, routeId: 2 }] }, { fareByType: [{ cityId: 2, type: 'bus', ...rule }] }, { places: null }, { infoFareEnabled: 'true' }]) {
await assert.rejects(service.claim(1, { code: p.code, name: 'Guide', settings: { ...defaults, ...patch } }, 1), e => e.status === 400);
}
await assert.rejects(service.claim(1, { code: p.code, name: 'Guide', settings: null }, 1), e => e.status === 400);
await assert.rejects(service.claim(1, { code:p.code, name:'Guide', settings:{...defaults, fareByType:[{cityId:2,type:'bus',price:'1 BYN',payment:'Наличные',checkedAt:'2026-10-11'}]} }, 1), e => e.code === 'invalid_fare_city');
assert.equal((await service.status(p.token)).state, 'pending');
const d = await service.claim(1, { code: p.code, name: 'Guide', settings: { ...defaults, places: [{ stopId: 2, name: 'Вокзал' }], fareRules: [rule] } }, 1);
const saved = (await service.status(p.token)).device.settings;
assert.equal(saved.places[0].name, 'Вокзал');assert.equal(saved.fareRules[0].price, 'Особый тариф');
await service.update(d.id,1,{name:'Guide',revision:1,settings:{...saved,weatherHintEnabled:false,lastTripNoticeEnabled:false}},1);
assert.equal((await service.status(p.token)).device.settings.weatherHintEnabled,false);
});
test('legacy Polotsk tariffs migrate using the operator confirmation; other cities stay unconfigured', async () => {
for(const cityId of [1,2]) {
const repo=memoryRepository(),service=createBoardDeviceService(repo),p=await service.start({});
const d=await service.claim(cityId,{code:p.code,name:'Legacy',settings:{...defaults,stopId:cityId===1?1:3}},1);
for(const key of ['infoPlacesEnabled','infoFareEnabled','lastTripNoticeEnabled','weatherHintEnabled','places','fareRules','fareByType']) delete repo.devices.get(d.id).settings[key];
const saved=(await service.status(p.token)).device.settings;
assert.equal(saved.lastTripNoticeEnabled,true);assert.deepEqual(saved.places,[]);
assert.equal(saved.fareByType.length,cityId===1?2:0);
if(cityId===1) {assert.equal(saved.fareByType[0].price,'1,05 BYN');assert.equal(saved.fareByType[1].price,'2,10 BYN');assert.equal(saved.fareByType[0].checkedAt,'2026-10-11');}
assert.equal(repo.devices.get(d.id).settings.fareByType,undefined);
}
});