fix(navigation): isolate data when switching dropdown menus
This commit is contained in:
parent
58c99d6286
commit
a0ba86bba3
92
web/scripts/test-menu-switch.mjs
Normal file
92
web/scripts/test-menu-switch.mjs
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
// 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();
|
||||||
|
}
|
||||||
@ -115,7 +115,9 @@ export default function Header() {
|
|||||||
|
|
||||||
{openMenu && (
|
{openMenu && (
|
||||||
<div className="header__mega">
|
<div className="header__mega">
|
||||||
<MegaMenu menu={openMenu} onNavigate={() => setOpenMenu(null)} />
|
{/* Каталоги имеют разную структуру: при смене меню сбрасываем
|
||||||
|
данные и отменяем запрос предыдущего, не меняя лишь заголовок. */}
|
||||||
|
<MegaMenu key={openMenu} menu={openMenu} onNavigate={() => setOpenMenu(null)} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user