93 lines
5.1 KiB
JavaScript
93 lines
5.1 KiB
JavaScript
// Regression for cross-menu data leaks. All catalog responses are fictional;
|
||
// no network calls or patient data are used.
|
||
import assert from 'node:assert/strict';
|
||
import { parseHTML } from 'linkedom';
|
||
import { createServer } from 'vite';
|
||
|
||
const { window } = parseHTML('<html><body><div id="root"></div></body></html>');
|
||
Object.assign(globalThis, { window, document: window.document, HTMLElement: window.HTMLElement, IS_REACT_ACT_ENVIRONMENT: true });
|
||
window.location = new URL('https://menu-test.invalid/');
|
||
document.oninput = null;
|
||
const { default: React, act } = await import('react');
|
||
const { createRoot } = await import('react-dom/client');
|
||
const { Simulate } = await import('react-dom/test-utils');
|
||
const { MemoryRouter } = await import('react-router-dom');
|
||
const vite = await createServer({ server: { middlewareMode: true, hmr: false }, appType: 'custom' });
|
||
const { default: Header } = await vite.ssrLoadModule('/src/components/Header.jsx');
|
||
const { api } = await vite.ssrLoadModule('/src/api.js');
|
||
const { ApiDataProvider } = await vite.ssrLoadModule('/src/context/ApiData.jsx');
|
||
const pending = [];
|
||
for (const [method, kind] of [['services', 'services'], ['specialties', 'doctors'], ['priceSections', 'prices']]) {
|
||
api[method] = ({ signal }) => new Promise((resolve, reject) => pending.push({ kind, signal, resolve, reject }));
|
||
}
|
||
const container = document.getElementById('root');
|
||
const root = createRoot(container);
|
||
const prices = [{ id: 1, name: 'Тестовая консультация', slug: 'test-price', count: 411 }];
|
||
const specialties = [{ id: 2, name: 'Тестовые педиатры', slug: 'test-kids', count: 3 }];
|
||
const services = [{ id: 3, name: 'Тестовая услуга', path: 'test-service', children: [] }];
|
||
const panel = () => container.querySelector('.mega');
|
||
const panelText = () => panel()?.textContent || '';
|
||
const links = () => [...(panel()?.querySelectorAll('.mega__item') || [])].map((link) => link.getAttribute('href'));
|
||
const trigger = (title) => [...container.querySelectorAll('.nav__item')].find((node) => node.textContent === title);
|
||
async function open(title, focus = false) {
|
||
await act(async () => Simulate[focus ? 'focus' : 'mouseEnter'](trigger(title)));
|
||
return pending.at(-1);
|
||
}
|
||
async function reply(request, data) { await act(async () => request.resolve({ data })); }
|
||
async function render(initial = {}) {
|
||
await act(async () => root.render(React.createElement(MemoryRouter, {},
|
||
React.createElement(ApiDataProvider, { value: initial }, React.createElement(Header)))));
|
||
}
|
||
|
||
try {
|
||
await render();
|
||
const priceRequest = await open('Цены');
|
||
await reply(priceRequest, prices);
|
||
assert.match(panelText(), /411 услуг/);
|
||
const doctorRequest = await open('Врачи');
|
||
assert.equal(panel().getAttribute('aria-label'), 'Меню «Врачи»');
|
||
assert.deepEqual(links(), [], 'Price sections must disappear immediately when switching to doctors');
|
||
assert.match(panelText(), /Загружаем/);
|
||
assert.equal(priceRequest.signal.aborted, true, 'Previous menu request is cancelled on switch');
|
||
await reply(doctorRequest, specialties);
|
||
assert.match(panelText(), /3 врача/);
|
||
assert.deepEqual(links(), ['/company/staff/test-kids/']);
|
||
assert.doesNotMatch(panelText(), /411|Тестовая консультация/);
|
||
|
||
const delayedPrice = await open('Цены');
|
||
assert.deepEqual(links(), [], 'Doctor specialties must not leak into the price menu');
|
||
const delayedServices = await open('Услуги');
|
||
const latestDoctor = await open('Врачи', true);
|
||
assert.equal(delayedPrice.signal.aborted, true);
|
||
assert.equal(delayedServices.signal.aborted, true);
|
||
await reply(latestDoctor, specialties);
|
||
// Deliberately resolve cancelled requests: even a transport ignoring AbortSignal
|
||
// must not overwrite a newly opened menu.
|
||
await reply(delayedPrice, prices);
|
||
await reply(delayedServices, services);
|
||
assert.deepEqual(links(), ['/company/staff/test-kids/']);
|
||
assert.match(panelText(), /3 врача/);
|
||
assert.doesNotMatch(panelText(), /411|Тестовая консультация|Тестовая услуга/);
|
||
|
||
const serviceRequest = await open('Услуги');
|
||
await reply(serviceRequest, services);
|
||
assert.deepEqual(links(), ['/services/test-service/']);
|
||
const failedPrice = await open('Цены');
|
||
await act(async () => failedPrice.reject(new Error('Fictional failure')));
|
||
assert.match(panelText(), /Не удалось загрузить список/);
|
||
assert.deepEqual(links(), [], 'An error must not leave links from another menu');
|
||
|
||
await act(async () => root.render(null));
|
||
await render({ specialties: { data: specialties, meta: {} } });
|
||
const initialPrices = await open('Цены');
|
||
await reply(initialPrices, prices);
|
||
const initialDoctors = await open('Врачи', true);
|
||
assert.deepEqual(links(), ['/company/staff/test-kids/'], 'SSR data belongs only to its own catalog');
|
||
assert.match(panelText(), /3 врача/);
|
||
await reply(initialDoctors, specialties);
|
||
console.log('PASS: hover/focus menu switching, delayed replies, cancellation, errors, and SSR data isolation');
|
||
} finally {
|
||
await act(async () => root.unmount());
|
||
await vite.close();
|
||
}
|