Files
iridaMobile/scripts/test-live-chat.mjs
2026-10-30 11:04:23 +03:00

314 lines
16 KiB
JavaScript

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');
});