105 lines
10 KiB
JavaScript
105 lines
10 KiB
JavaScript
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);
|
||
}
|
||
});
|