import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { createRequire } from 'node:module'; import test from 'node:test'; import ts from 'typescript'; const require = createRequire(import.meta.url); function load(path, dependencies = {}) { const source = readFileSync(new URL(`../src/${path}`, import.meta.url), 'utf8'); const { outputText } = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }); const module = { exports: {} }; new Function('require', 'module', 'exports', outputText)(name => dependencies[name] ?? require(name), module, module.exports); return module.exports; } const mapping = load('entities/message/model/liveMessage.ts'); const { LiveMessageStore } = load('entities/message/model/liveMessageStore.ts', { './liveMessage': mapping }); const incoming = (id, important = false, status = 'pending') => ({ id, fromId: '22', important, message: 'Сообщение', createdAt: '2026-10-28T10:00:00Z', receivers: [{ toId: '11', status }] }); const tick = () => new Promise(resolve => setImmediate(resolve)); test('preserves 64-bit message IDs, ordering, own/read/important states', () => { const large = '9007199254740993'; const small = '9007199254740992'; assert.deepEqual(mapping.chronological([incoming(large), incoming(small), incoming(large)]).map(message => message.id), [small, large]); assert.equal(mapping.mapLiveMessage(incoming(large, true), '11').type, 'important'); assert.equal(mapping.mapLiveMessage(incoming(large, true, 'read'), '11').confirmed, true); const own = mapping.mapLiveMessage({ ...incoming(large), fromId: '11', receivers: [] }, '11'); assert.equal(own.type, 'driver_self'); assert.equal(own.deliveryStatus, 'sent'); }); test('reports delivery, reads visible ordinary messages, keeps every important message pending', async () => { const writes = []; const messages = [incoming('101'), incoming('102', true), incoming('103', true)]; const store = new LiveMessageStore('11', async (path, options) => { if (options?.method === 'POST') { writes.push([path, options.body]); return {}; } return { messages: path === '/messages/list' && options.query?.fromId ? [] : messages }; }); try { store.start(); await tick(); assert.equal(store.chats.length, 3); assert.deepEqual(writes[0], ['/messages/report/delivered', { messageIds: ['101', '102', '103'] }]); assert.equal(writes.some(([path]) => path === '/messages/report/read'), false); await store.markVisibleRead(['101', '102']); assert.deepEqual(writes[1], ['/messages/report/read', { messageIds: ['101'] }]); assert.equal(store.important.id, '102'); messages[1].receivers[0].status = 'read'; assert.equal(await store.acknowledge('102'), true); assert.equal(store.important.id, '103'); } finally { store.stop(); } }); test('routes answers to the selected message rather than the latest sender; failed writes retain an error', async () => { const writes = []; let fail = false; const store = new LiveMessageStore('11', async (path, options) => { if (path === '/messages/reply') { writes.push(options.body); if (fail) throw new Error('Нет связи'); return { ...incoming('999'), fromId: '11', replyId: options.body.replyId }; } return { messages: [] }; }); try { store.start(); await tick(); store.messages = [incoming('9007199254740993'), { ...incoming('9007199254740994'), fromId: '33' }]; store.selectReply('9007199254740993'); assert.equal(await store.send('Ответ'), true); assert.deepEqual(writes[0], { replyId: '9007199254740993', message: 'Ответ' }); fail = true; assert.equal(await store.send('Повторный ответ'), false); assert.equal(store.sendError, 'Нет связи'); assert.equal(store.sending, false); } finally { store.stop(); } }); test('failed read acknowledgement does not dismiss an important message', async () => { const store = new LiveMessageStore('11', async (path) => { if (path === '/messages/report/read') throw new Error('Нет связи'); return { messages: [] }; }); try { store.start(); await tick(); store.messages = [incoming('102', true)]; assert.equal(await store.acknowledge('102'), false); assert.equal(store.important.id, '102'); } finally { store.stop(); } }); class HttpError extends Error { constructor(status, message) { super(message); this.status = status; } } function sessionFixture(handler, activationState = { value: { deviceId: '77', deviceSecret: 'secret' } }, suppliedStorage) { const storage = suppliedStorage ?? { values: new Map(), getJson(key) { return this.values.get(key); }, setJson(key, value) { this.values.set(key, value); }, remove(key) { this.values.delete(key); } }; const deviceActivationStorage = { load: async () => activationState.value, save: async value => { if (activationState.failSave) throw new Error('Storage unavailable'); activationState.value = value; }, clear: async () => { activationState.value = null; } }; const { LiveSessionStore } = load('entities/session/model/liveSessionStore.ts', { '../api/deviceActivation': { deviceActivationStorage }, '@/shared/api': { HttpError }, '@/shared/api/irida': { iridaRequest: handler }, '@/shared/lib': { storage, hydrateStorage: async () => {} }, '@/shared/config': { apiBaseUrl: 'https://irida.example' }, }); const store = new LiveSessionStore(); store.activation = activationState.value; return store; } test('creates a device-bound driver session and revokes the preliminary login', async () => { const calls = []; const store = sessionFixture(async (path, options) => { calls.push([path, options]); if (path === '/auth/email/login') return { accessToken: 'access', refreshToken: options.body.shiftId ? 'bound' : 'temporary', user: { id: '11' }, ...(options.body.shiftId ? { shift: { id: '55', deviceId: '77' } } : {}) }; if (path === '/shifts/start') return { driverShift: { id: '55', deviceId: '77' } }; return {}; }); await store.login(' driver@example.com ', 'password'); assert.equal(store.session.shift.id, '55'); assert.deepEqual(calls.find(([path]) => path === '/shifts/start')[1].body, { userId: '11', deviceId: '77', deviceSecret: 'secret' }); assert.equal(calls.filter(([path]) => path === '/auth/email/login')[1][1].body.shiftId, '55'); assert.equal(calls.at(-1)[1].body.refreshToken, 'temporary'); }); test('coalesces concurrent refreshes and retries requests with Bearer authorization', async () => { let refreshes = 0; const store = sessionFixture(async (path, options) => { if (path === '/auth/refresh') { refreshes++; await tick(); return { accessToken: 'fresh' }; } if (options.headers.Authorization === 'Bearer old') throw new HttpError(401, 'Expired'); assert.equal(options.headers.Authorization, 'Bearer fresh'); return { ok: true }; }); store.session = { accessToken: 'old', refreshToken: 'refresh', user: { id: '11' }, shift: { id: '55' } }; await Promise.all([store.authorized('/messages/list'), store.authorized('/messages/pending')]); assert.equal(refreshes, 1); assert.equal(store.session.accessToken, 'fresh'); }); test('reuses the created shift after a network failure in the device-bound login', async () => { let starts = 0; let fail = true; const store = sessionFixture(async (path, options) => { if (path === '/shifts/start') { starts++; return { driverShift: { id: '55', deviceId: '77' } }; } if (path === '/auth/email/login') { if (options.body.shiftId && fail) throw new Error('Нет связи'); return { accessToken: 'access', refreshToken: 'refresh', user: { id: '11' }, ...(options.body.shiftId ? { shift: { id: '55', deviceId: '77' } } : {}) }; } return {}; }); await store.login('driver@example.com', 'password'); assert.equal(store.session, null); fail = false; await store.login('driver@example.com', 'password'); assert.equal(starts, 1); assert.equal(store.session.shift.id, '55'); }); test('an invalid refresh returns to login; a network error retains the session', async () => { for (const status of [401, 0]) { const store = sessionFixture(async path => { throw new HttpError(path === '/auth/refresh' ? status : 401, 'Ошибка'); }); store.session = { accessToken: 'old', refreshToken: 'refresh', user: { id: '11' }, shift: { id: '55' } }; await assert.rejects(store.authorized('/messages/pending')); assert.equal(store.session === null, status === 401); } }); test('completes local logout once the server ends the shift, even if token revocation fails', async () => { const store = sessionFixture(async path => { if (path === '/auth/logout') throw new Error('Нет связи'); return {}; }); store.session = { accessToken: 'token', refreshToken: 'refresh', user: { id: '11' }, shift: { id: '55' } }; await store.logout(); assert.equal(store.session, null); assert.equal(store.error, null); }); test('only visible incoming messages are read, and stale polls preserve their read status', async () => { const writes = []; const store = new LiveMessageStore('11', async (path, options) => { if (options?.method === 'POST') { writes.push([path, options.body]); return { updated: 1 }; } return { messages: options.query?.fromId ? [] : [incoming('101'), incoming('102')] }; }); try { store.start(); await tick(); await Promise.all([store.markVisibleRead(['101']), store.markVisibleRead(['101'])]); assert.equal(writes.filter(([path]) => path === '/messages/report/read').length, 1); assert.equal(store.messages.find(message => message.id === '102').receivers[0].status, 'delivered'); await store.refresh(); assert.equal(store.messages.find(message => message.id === '101').receivers[0].status, 'read'); } finally { store.stop(); } }); test('polling replaces an outgoing delivery indicator with the server read status', async () => { let status = 'delivered'; const store = new LiveMessageStore('11', async (path, options) => ({ messages: options.query?.fromId ? [{ ...incoming('100'), fromId: '11', receivers: [{ toId: '22', status }] }] : [] })); try { store.start(); await tick(); assert.equal(store.chats[0].deliveryStatus, 'delivered'); status = 'read'; await store.refresh(); assert.equal(store.chats[0].deliveryStatus, 'read'); } finally { store.stop(); } }); test('viewport receipts exclude offscreen bubbles and support messages taller than the viewport', () => { const { visibleMessageIds } = load('widgets/messenger/model/visibleMessages.ts'); const layouts = [{ id: 'above', y: 0, height: 80 }, { id: 'visible', y: 100, height: 60 }, { id: 'below', y: 300, height: 80 }]; assert.deepEqual(visibleMessageIds(layouts, 100, 120), ['visible']); assert.deepEqual(visibleMessageIds([{ id: 'long', y: 0, height: 1000 }], 400, 120), ['long']); assert.deepEqual(visibleMessageIds(layouts, 100, 0), []); }); test('activation is saved once and restored before email/password login', async () => { const state = { value: null }; const store = sessionFixture(async () => ({}), state); await store.init(); assert.equal(store.activation, null); await store.activateDevice('77', 'device-secret'); assert.deepEqual(state.value, { deviceId: '77', deviceSecret: 'device-secret' }); const restored = sessionFixture(async () => ({}), state); await restored.init(); assert.deepEqual({ ...restored.activation }, state.value); }); test('login requires saved activation and uses it without additional device fields', async () => { const calls = []; const state = { value: null }; const store = sessionFixture(async (path, options) => { calls.push([path, options]); if (path === '/auth/email/login') return { accessToken: 'access', refreshToken: 'refresh', user: { id: '11' }, ...(options.body.shiftId ? { shift: { id: '55', deviceId: '77' } } : {}) }; if (path === '/shifts/start') return { driverShift: { id: '55' } }; return {}; }, state); await store.login('driver@example.com', 'password'); assert.equal(calls.length, 0); await store.activateDevice('77', 'saved-secret'); await store.login('driver@example.com', 'password'); assert.deepEqual(calls.find(([path]) => path === '/shifts/start')[1].body, { userId: '11', deviceId: '77', deviceSecret: 'saved-secret' }); }); test('reset clears activation and the session, and the next launch requires activation', async () => { const state = { value: { deviceId: '77', deviceSecret: 'saved-secret' } }; const store = sessionFixture(async () => ({}), state); store.session = { accessToken: 'access', refreshToken: 'refresh', user: { id: '11' }, shift: { id: '55', deviceId: '77' } }; await store.resetDevice(); assert.equal(state.value, null); assert.equal(store.session, null); const restored = sessionFixture(async () => ({}), state); await restored.init(); assert.equal(restored.activation, null); }); test('a failed secure-store write does not activate the device', async () => { const state = { value: null, failSave: true }; const store = sessionFixture(async () => ({}), state); await store.activateDevice('77', 'secret'); assert.equal(store.activation, null); assert.match(store.error, /сохранить/); }); test('native device credentials use SecureStore rather than AsyncStorage', async () => { const calls = []; const memory = new Map(); const { deviceActivationStorage } = load('entities/session/api/deviceActivation.ts', { 'react-native': { Platform: { OS: 'android' } }, 'expo-secure-store': { WHEN_UNLOCKED_THIS_DEVICE_ONLY: 1, setItemAsync: async (key, value) => { calls.push('save'); memory.set(key, value); }, getItemAsync: async key => memory.get(key) ?? null, deleteItemAsync: async key => { calls.push('delete'); memory.delete(key); }, }, '@/shared/lib': { storage: new Proxy({}, { get() { throw new Error('Native secrets must not use AsyncStorage'); } }) }, '@/shared/config': { apiBaseUrl: 'https://irida.example' }, }); await deviceActivationStorage.save({ deviceId: '77', deviceSecret: 'secret' }); assert.deepEqual(await deviceActivationStorage.load(), { deviceId: '77', deviceSecret: 'secret' }); await deviceActivationStorage.clear(); assert.equal(await deviceActivationStorage.load(), null); assert.deepEqual(calls, ['save', 'delete']); }); test('reply mapping provides the quoted author and text instead of displaying an ID', () => { const original = { ...incoming('101'), message: 'Исходный текст' }; const reply = { ...incoming('102'), fromId: '11', replyId: '101' }; const mapped = mapping.mapLiveMessage(reply, '11', [original, reply]); assert.equal(mapped.replyToId, '101'); assert.equal(mapped.replyTo, 'Исходный текст'); assert.equal(mapped.replyToAuthor, 'Диспетчер #22'); }); test('native activation refuses a write that resolves without persisting the credentials', async () => { const { deviceActivationStorage } = load('entities/session/api/deviceActivation.ts', { 'react-native': { Platform: { OS: 'android' } }, 'expo-secure-store': { WHEN_UNLOCKED_THIS_DEVICE_ONLY: 1, setItemAsync: async () => {}, getItemAsync: async () => null, }, '@/shared/lib': { storage: {} }, '@/shared/config': { apiBaseUrl: 'https://irida.example' }, }); await assert.rejects(() => deviceActivationStorage.save({ deviceId: '77', deviceSecret: 'secret' }), /сохранение/); }); test('distinguishes device shift rejection from email login rejection', async () => { const store = sessionFixture(async (path) => { if (path === '/auth/email/login') return { accessToken: 'access', refreshToken: 'refresh', user: { id: '11' } }; if (path === '/shifts/start') throw new HttpError(401, 'invalid credentials'); return {}; }); await store.login('driver@example.com', 'password'); assert.equal(store.error, 'Открытие смены на устройстве 77: invalid credentials'); assert.equal(store.activation.deviceSecret, 'secret'); });