83 lines
5.0 KiB
JavaScript
83 lines
5.0 KiB
JavaScript
// Embedded admin UI with a fake API and fictional leads; never connects to a server.
|
||
import assert from 'node:assert/strict';
|
||
import { readFile } from 'node:fs/promises';
|
||
import vm from 'node:vm';
|
||
import { parseHTML } from 'linkedom';
|
||
const html = await readFile(new URL('../../internal/api/adminui/index.html', import.meta.url), 'utf8');
|
||
const source = await readFile(new URL('../../internal/api/adminui/leads.js', import.meta.url), 'utf8');
|
||
const { document } = parseHTML(html);
|
||
const $ = (selector) => document.querySelector(selector);
|
||
const select = $('#ld-status');
|
||
Object.defineProperty(select, 'options', { get: () => [...select.querySelectorAll('option')] });
|
||
function selectStatus(status) { for (const option of select.options) { if (option.value === status) option.setAttribute('selected', ''); else option.removeAttribute('selected'); } }
|
||
selectStatus('new');
|
||
const pending = [], notifications = [], timers = new Map();
|
||
let timerId = 0;
|
||
const context = vm.createContext({ document, $, URLSearchParams, AbortController, Date, Intl, console,
|
||
el: (tag, props = {}) => Object.assign(document.createElement(tag), props),
|
||
api: (path, options) => new Promise((resolve, reject) => pending.push({ path, options, resolve, reject })),
|
||
toast: (message, bad) => notifications.push({ message, bad }),
|
||
debounce: (fn) => fn, plural: () => 'заявок',
|
||
LEAD_STATUS: { new: ['Новая', 'blue'], done: ['Обработана', 'green'] },
|
||
setTimeout: (fn) => { const id = ++timerId; timers.set(id, fn); return id; },
|
||
clearTimeout: (id) => timers.delete(id),
|
||
});
|
||
vm.runInContext(source + '\nglobalThis.testLeads = Leads;', context);
|
||
const leads = context.testLeads;
|
||
const response = (name = 'Тестовая заявка') => ({ data: [{ id: 1, created_at: '2026-10-23T10:00:00+03:00', name, phone: '+79990000000', status: 'new', form_title: 'Тестовая форма', message: 'Вымышленные данные' }], meta: { total: 1, counts: { new: 1 }, today: 1 } });
|
||
leads.offset = 60;
|
||
const first = $('#ld-refresh').onclick();
|
||
assert.equal($('#ld-refresh').disabled, true, 'Refresh must visibly enter loading state');
|
||
assert.equal($('#ld-refresh').getAttribute('aria-busy'), 'true');
|
||
assert.match($('#ld-updated').textContent, /Обновляем/);
|
||
assert.equal(new URLSearchParams(pending[0].path.split('?')[1]).get('offset'), '0', 'Manual refresh returns to newest results');
|
||
assert.equal(pending[0].options.cache, 'no-store');
|
||
pending[0].resolve(response());
|
||
await first;
|
||
assert.match($('#ld-body').textContent, /Тестовая заявка/);
|
||
assert.equal($('#ld-refresh').disabled, false);
|
||
assert.match($('#ld-updated').textContent, /Обновлено/);
|
||
assert.ok(notifications.some((n) => n.message === 'Список заявок обновлён'));
|
||
assert.equal(timers.size, 0);
|
||
|
||
// Filters win over an older request even if a transport ignores cancellation.
|
||
const older = leads.load();
|
||
selectStatus('done');
|
||
const newer = leads.load();
|
||
assert.equal(pending[1].options.signal.aborted, true);
|
||
pending[2].resolve(response('Свежий ответ'));
|
||
await newer;
|
||
pending[1].resolve(response('УСТАРЕВШИЙ ответ'));
|
||
await older;
|
||
assert.match($('#ld-body').textContent, /Свежий ответ/);
|
||
assert.doesNotMatch($('#ld-body').textContent, /УСТАРЕВШИЙ/);
|
||
|
||
// Preserve filters, but reset pagination for an explicit refresh.
|
||
$('#ld-q').value = 'проверка'; $('#ld-from').value = '2026-10-01'; $('#ld-to').value = '2026-10-23';
|
||
leads.offset = 30;
|
||
const refresh = $('#ld-refresh').onclick();
|
||
const params = new URLSearchParams(pending[3].path.split('?')[1]);
|
||
assert.equal(params.get('status'), 'done'); assert.equal(params.get('q'), 'проверка');
|
||
assert.equal(params.get('from'), '2026-10-01'); assert.equal(params.get('to'), '2026-10-23');
|
||
pending[3].reject(new Error('Тестовая ошибка сервера'));
|
||
await refresh;
|
||
assert.equal($('#ld-refresh').disabled, false, 'Errors must release the button');
|
||
assert.match($('#ld-updated').textContent, /Не удалось/);
|
||
assert.match($('#ld-body').textContent, /Свежий ответ/, 'Failure must not erase the previous results');
|
||
assert.ok(notifications.some((n) => n.bad && n.message.includes('Тестовая ошибка')));
|
||
|
||
const retry = $('#ld-refresh').onclick();
|
||
pending.at(-1).resolve({ data: [], meta: { total: 0, counts: {} } });
|
||
await retry;
|
||
assert.match($('#ld-body').textContent, /Заявки не найдены/);
|
||
assert.equal($('#ld-refresh').disabled, false);
|
||
|
||
const timeout = $('#ld-refresh').onclick();
|
||
for (const fn of [...timers.values()]) fn();
|
||
pending.at(-1).reject(Object.assign(new Error('Aborted'), { name: 'AbortError' }));
|
||
await timeout;
|
||
assert.equal($('#ld-refresh').disabled, false, 'Timeout cannot leave refresh stuck');
|
||
assert.match($('#ld-updated').textContent, /время ожидания/i);
|
||
assert.equal(timers.size, 0);
|
||
console.log('PASS: actual refresh handler, loading/success/error/timeout feedback, no-store requests, first page, retained filters and stale-response protection; no live lead changes');
|