Netpulse_SasS/web/TESTING.md
byrsapty ca143a616b
Some checks failed
CI / hygiene (push) Successful in 8s
CI / web (push) Successful in 59s
CI / server (push) Failing after 3m27s
CI / agent (push) Successful in 3m3s
Білінг, SLA, вбудовані правила, пісочниця установника, тести сторінок
П'ять паралельних задач. Найцінніше в них — не можливості, а знайдене.

0069 БІЛІНГ. Аудит 0009 показав, що перевірка ліміту не спрацювала б
жодного разу: isPlanLimit шукала слово «ліміт», а тригер писав
"device limit reached" англійською. Перше ж досягнення стелі дало б
клієнту 500 замість пояснення. Плюс три діри: тригер лише на INSERT
(стеля в 15 обходилась за чотири дії через архів), max_maps/max_agents/
max_users не перевіряло ніщо — тобто рівно те, чим відрізняються плани,
і license_keys була закрита політикою tenant_isolation з 0011, хоча
tenant_id там NULLABLE навмисно: головний сценарій self-hosted був
недосяжний.

Після закінчення ліцензії не вимикається нічого — замерзає лише ріст.
Моніторинг, що перестав моніторити через несплачений рахунок, це
аварія в мережі клієнта, спричинена нами.

0070 SLA. Джерелом обрано ts.icmp_1h, а не device_status_history:
остання не вміє сказати «ми не знали» — перехід пишеться лише при
зміні стану, тож доба мовчання зонда виглядає як доба роботи. Час
розкладено на чотири частини, і «немає даних» не додається ні до чого;
замість вибору між двома брехнями звіт каже, яку частку періоду він
бачив. Закритий період тримає тригер, а не домовленість у Go.

0071 ВІДПОВІДНІСТЬ. 20 правил, кожне прив'язане до родини: об'єднаний
вираз, що покриває Cisco й не покриває MikroTik, дав би «0 порушень» і
сховав сліпу пляму. Вендор не входить у перелік, доки для нього немає
зразка конфігу в тесті. TestBuiltinRulesAreNotAlwaysGreen вимагає, щоб
у кожного правила був конфіг, де воно спрацювало, І де ні.

ПІСОЧНИЦЯ УСТАНОВНИКА — та сама установка в ізольованому проєкті
compose. Знайшла дві справжні вади з трьох спроб:
  * healthcheck бази ходив unix-сокетом, а споживачі по TCP. При
    первинній ініціалізації Postgres слухає лише сокет — compose
    вважав базу здоровою, migrate отримував connection refused. На
    створеній базі цієї фази немає, тож вада чекала на першого клієнта;
  * у білому переліку модулів API не було traps і filecfg — зонд із
    приймачем трапів неможливо було зареєструвати взагалі.

ТЕСТИ СТОРІНОК: 137 → 252. Мережевий шар, права доступу, незворотні
дії, фільтри з адресного рядка. Підмінюється лише fetch і WebSocket —
api/client.ts працює справжній.
2026-08-27 21:17:23 +03:00

23 KiB
Raw Permalink Blame History

Тести вебу

npm test              # vitest run — один прогін
npm run test:watch    # у режимі спостереження
npm run check         # типи + тести + збірка
sh ../scripts/check.sh web   # те саме, але тим самим шляхом, що й CI

Раннер — vitest, а не jest: проєкт на Vite, і vitest.config.ts зроблено через mergeConfig(viteConfig, …). Тобто тести проходять ТУ САМУ трансформацію, що й збірка. З jest вийшло б два різні конвеєри, і розбіжність між ними знаходили б не тестом, а на стенді.

Середовище — jsdom. Тести лежать у src/test/.


Головне про цей файл

Сьогодні на проєкті сталося ось що: перед зміною ролі БД прогнали ретельний тест ізоляції, він був зелений — і пропустив поломку, бо перевіряв «чи не видно чужого», а зламалось «чи видно своє».

Зелена перевірка доводить рівно те, що вона перевіряє. Тому нижче спершу йде перелік того, чого тести НЕ покривають. Він важливіший за перелік покритого: доки він тут і чесний, зелений прогін не можна прийняти за доказ справності.


Чого тести НЕ покривають

Мапа: перетягування вузлів і все, що робить d3-drag

MapCanvas тягне вузли через React Flow, а той — через d3-drag. d3-drag не запускається синтетичними подіями jsdom: він читає pointerId, PointerEvent, elementFromPoint і матрицю перетворення SVG, а jsdom нічого з цього не рахує. Наслідок практичний: тесту на перетягування немає й не буде без справжнього браузера.

Що саме через це лишається неперевіреним:

  • запис нової позиції вузла після перетягування (MapCanvas.tsx, порівняння Math.round(x/y) з попереднім і рішення «слати чи ні»);
  • групове перетягування виділених вузлів;
  • малювання нового звʼязку мишею від порту до порту;
  • вибір бока підключення при перетягуванні (autoSides перевірено як функція, але не перевірено, що полотно взагалі її кличе й із тими координатами).

Усе, що вимагає справжнього рушія

  • Мінікарта конфігу (Minimap.tsx) — перевірено лише buildShape. Саме малювання йде в <canvas>, а jsdom не має 2D-контексту: getContext('2d') повертає null, і всі три шари мовчки нічого не роблять. Тобто розкладка й вигляд мінікарти не перевірені ніяк: помилка в yOfRow, rowAtY, у межах вікна прокрутки або в колонці щільності позначок пройде зеленою.
  • Точка підпису на кривій (pointsOnPath у mapStyle.ts) — код сам повертає [] під jsdom, бо SVGPathElement.getTotalLength там не реалізовано. Гілка з реальними вимірами не виконується жодного разу.
  • Ширина символа (useCharWidth) і будь-яка залежність від getBoundingClientRect: у jsdom усі розміри нульові. Віртуальна прокрутка ConfigViewer перевірена лише в арифметичній частині (rowAt, uniformOffsets), не в тій, що читає реальні розміри.
  • Медіазапити Tailwind. DataTable малює кожен рядок ДВІЧІ — таблицею для десктопа й карткою для телефона, — а ховає зайве CSS. У тесті видно обидві копії; який саме варіант побачить людина на 375 px, тест не знає.
  • ResizeObserver підмінено заглушкою в src/test/setup.ts. Логіка, що залежить від зміни розміру, не спрацьовує.

Мережа й живі дані

Мережевий шар тепер покритий (див. «Що покрито»), але покритий він проти підставного fetch, а не проти сервера. Тому лишається непокритим:

  • Жодного тесту зі справжнім сервером. Підставні відповіді написані за server/API.md і за обробниками server/internal/httpapi/, і звірка ця зроблена очима, один раз. Ніщо не тримає її актуальною: якщо сервер перейменує поле, тести лишаться зеленими на старій формі. Розбіжність між types.ts і реальною відповіддю API тут не ловиться в принципі — так само, як і раніше.
  • Справжній WebSocket не бере участі. Перевірено логіку LiveConnection проти підставного сокета: рукостискання, підпротоколи, поведінка проксі, bufferedAmount, порядок подій у реальному браузері — ні.
  • Черга подій під навантаженням. Що буде, коли сервер надішле тисячі повідомлень за секунду, тест не знає: усі перевірки — на одиницях подій.
  • Ротація refresh-токена в часі. Перевірено, що обмін ОДИН на сплеск запитів; що буде, коли access-токен протухне рівно між fetch і читанням тіла, — не перевірено.
  • Режим кіоска (VITE_API_TOKEN). staticTokenPresent читається з import.meta.env під час імпорту модуля, тому в тестах він завжди порожній: жодна перевірка цієї гілки не виконується (див. «знайдене» у звіті — там про неї є що сказати).

Сторінки

Покрито п’ять сторінок із двадцяти семи (AuditPage, DevicesPage, TeamPage, RolesPage, AlertsPage) і два вікна незворотних дій. Решта двадцять дві не покриті нічим: MapPage, DevicePage, ConfigsPage, CommandsPage, RulesPage, TemplatesPage, TrapsPage, StoragePage, QueuesPage, MetricsPage, DashboardPage, GroupsPage, CredentialsPage, ProfilesPage, CompliancePage, MirrorPage, ChannelsPage, EscalationsPage, AgentsPage, ServerFilesPage, ProfilePage, TvPage. Для них не перевірено ні права, ні форми, ні фільтри.

Окремо про те, чого немає навіть на покритих сторінках:

  • Фільтри в адресі перевірені лише в AuditPage. DevicesPage тримає свої фільтри в стані компонента, а не в адресі, — тобто відфільтрований перелік хостів колезі не перешлеш. Це не поломка тесту, це властивість продукту, і тест її не покриває, бо покривати нічого.
  • DeviceFilterPanel і фільтри метрик — перевірено лише чисті функції (filters.test.ts), не зв’язку «панель → запит».
  • Створення хоста, шаблона, правила, каналу — форми не покриті. Перевірено лише те, що кнопка створення з’являється за правом.
  • Порядок і кількість запитів при монтуванні сторінки перевірено лише там, де це саме предмет тесту (зонди на DevicesPage). Зайвий запит на решті сторінок пройде зеленим.

Права доступу

Перевірено пари «є право / немає права» для хостів, користувачів, ролей і алертів. Не перевірено:

  • звуження групами доступу (writable, access: read|write на мапі): тести працюють із хостами, у яких writable: true;
  • розбіжність між тим, що ховає клієнт, і тим, що відхиляє сервер. Клієнтський session.can() і серверний requirePerm() — два різні переліки, і ніщо не звіряє їх між собою. Право, яке сервер уже вимагає, а клієнт ще ні (або навпаки), пройде зеленим по обидва боки;
  • право, що змінилось під час роботи вкладки. Сервер перечитує членство при кожній ротації токена, клієнт — при session.set. Що бачить людина в проміжку, не перевірено.

Доступність і клавіатура

  • Модальне вікно не переносить фокус усередину й не тримає його там. Тесту на це немає, бо й поведінки немає (див. «знайдене» нижче). Перевірено лише закриття: Esc, клік повз панель, хрестик.
  • Порядок обходу Tab, читачі з екрана, контраст — не перевірено ніяк.

Дрібне, але варте згадки

  • download() (збереження конфігу файлом) — не покрито: URL.createObjectURL у jsdom існує, але завантаження не відбувається.
  • Часові пояси: RollbackDialog.fmtDate і AuditPage.fmt* користуються toLocaleString('uk-UA'), результат залежить від пояса машини. Тестів немає саме тому — вони були б зелені локально й червоні в CI.
  • MetricChart.build (розрахунок меж осей, накопичення, розриви) — не покрито. Покрито лише fmtValue і seriesLabel.

Знайдене, але не закріплене тестом

Правило 3 нижче забороняє закріплювати ваду зеленим expect. Це знайдено під час написання тестів мережевого шару й сторінок; тестів на це навмисно немає — або вони були б червоні, або зафіксували б неправильну поведінку.

  1. «Будь-який машинний токен» у журналі аудиту їде не тим параметром. AuditPage кладе значення - у filter.tokens, звідки api.audit() надсилає token=-. Сервер шукає - серед actor (httpapi/audit.go, гілка f.AnyToken), а token кладе в ActorTokenIDsі той іде в SQL як actor_token_id = ANY(...) по стовпцю uuid (migrations/0001_core.sql:215). Тобто вибір цієї позначки дає не «рядки без людини», а помилку розбору UUID. Показово: коментар у types.ts над AuditFilter.actors описує ПРАВИЛЬНУ поведінку — - мав лежати серед actors.

  2. Режим кіоска нікому нічого не показує. При заданому VITE_API_TOKEN App пропускає відновлення сесії, тож session.me() лишається null назавжди — а session.can() без me повертає false на будь-яке право. Наслідок: бічне меню порожнє, а кожен маршрут показує «Розділ недоступний». api.me() у клієнті є, але не викликається звідки-небудь жодного разу.

  3. api.logout() кидає помилку в порожнечу. AppShell кличе його як void api.logout(); сесія чиститься в finally, але відмова сервера стає необробленим відхиленням промісу.

Тестів на п.1 і п.2 немає навмисно: вони були б червоні, а червоний тест у check.sh зупиняє роботу всім.


Що покрито

Чиста логіка

Файл тесту Що саме
format.test.ts plural, ago, humanInterval, fmtBytes, formatBps, fmtValue, fmtBps, uniq — межі одиниць, знак, дати з майбутнього
linediff.test.ts splitLines, diffSegs, buildDiff, wordDiff
configview.test.ts rowAt, uniformOffsets, maxLen, gutterWidth, safeName, buildShape
map.test.ts autoSides, autoLabelPositions, labelCandidates, estimateLabelBox, edgeState
labelLayout.test.ts жадібна розкладка підписів: зсув, невміщення, пріоритет, масштаб, звільнення місця
filters.test.ts matchesMetricQuery, matchesMetricFilter, defaultsFromSchema

Найважливіше тут — порівняння конфігів. Воно перевіряється не прикладами, а інваріантами на 500 випадкових парах із детермінованим генератором: із ділянок має точно відновлюватись і стара версія, і нова; нумерація обох колонок має йти без пропусків і повторів; текст у рядку має відповідати своєму номеру. Приклади ловлять те, про що встиг подумати автор тесту; інваріант ловить те, про що не подумав ніхто. Окремо перевірено розрахунковий випадок Myers — один змінений рядок серед 30 000.

Поведінка компонентів

Файл тесту Що саме
modal.test.tsx Esc (у т.ч. коли фокус у полі), клік повз панель, виділення тексту за край вікна НЕ закриває, зняття слухача при розмонтуванні, role=dialog/aria-modal
confirm.test.tsx подвійний клік не шле другий запит, помилка сервера НЕ закриває вікно, Esc = скасувати, підпис кнопки
datatable.test.tsx порожній стан, подвійна відмальовка рядків, hideOnMobile, Toggle

Мережевий шар

Файл тесту Що саме
apiclient.test.ts заголовок Bearer, credentials: same-origin, 204 без тіла, розбір {"error":{code,message}}, isConflict/isPlanLimit/isForbidden і навпаки, тихий обмін на 401 token_expired, відсутність обміну на no_session і 403, один обмін на десять паралельних запитів, restore() один на завантаження, logout чистить сесію навіть при відмові сервера, session.can, meFromLogin
ws.test.ts адреса й токен у підпротоколі, читання токена заново після реконекту, connecting/online/offline, затримка 1→2→4→8→15 с зі стелею й скиданням, stop() зупиняє реконект і забуває підписку, повторна підписка на мапу після кожного розриву, пошкоджене повідомлення, слухач, що впав
hooks.test.tsx злиття сплеску подій в один перечит, перечит після паузи, чужі повідомлення ігноруються, розмонтування скасовує заплановане, useAlerts без alerts:read не питає нічого

Найважливіше тут — негативні половини. «Обмін відбувся» нічого не варте без «на no_session не відбувся»; «реконект стався» — без «після stop() не стався». Саме асиметрична перевірка й пропустила зламаний вхід у тесті ізоляції RLS.

Сторінки

Файл тесту Що саме
routing.test.tsx неавторизований на закритій адресі бачить вхід і сторінка даних не питає; після входу відкривається та сама адреса; Guard без права не малює сторінку й не робить запиту; домівка залежить від ролі; пункт меню без права відсутній; вихід повертає до входу й розриває сокет
pagepermissions.test.tsx пари «є право / немає права» для хостів, користувачів, ролей, алертів; себе й власника прибрати не можна; право, якого немає в тебе, не можна віддати ролі; зміна ролі шле лише те, що змінилось; порожня правка запиту не робить; подвійний клік не шле двох PATCH; 409 shared_user лишає вікно з набраним
auditfilter.test.tsx фільтр із посилання й той самий фільтр, обраний руками, дають однаковий запит і однакову адресу; типовий період в адресу не пишеться; скидання чистить і адресу, і запит; гортання йде у вікні, яке зібрав сервер
destructive.test.tsx видалення хоста: набір звіряється з сервером, повне видалення заблоковане до підтвердження втрати, без зібраного галочки немає, типово — повне, підпис кнопки називає режим, подвійний клік не дублює запит, помилка не закриває вікно; відкат конфігу: план, непідтримуваний профіль, ручні рядки, plan_hash, збережена причина після відмови

Підставні відповіді написані не «схоже», а за формою сервера: помилка — з writeError() (httpapi/server.go), вхід і refresh — з API.md, масові дії над хостами — з devices_bulk.go і store.BulkDeviceTarget, журнал — з handleListAudit. Спільні підпори лежать у src/test/support.ts — щоб розбіжність із сервером правилась в одному місці й ламала всі тести одразу.

Обрано саме ці два вікна не випадково: Modal — єдине місце, де самовільне закриття зʼїдає набране в довгій формі, а ConfirmDialog — останній екран перед незворотною дією, і його дві тихі поломки (другий запит на видалення; закриття після невдачі, яке читається як «виконано») коштують найдорожче.


Правила для нових тестів

  1. Перевіряй те, що ламається, а не те, що легко перевірити. Відсоток покриття тут не рахується навмисно: він винагороджує тести на гетери.
  2. Назва тесту — це твердження про поведінку, а не про виклик функції. «нумерація обох колонок іде без пропусків і без повторів», а не «buildDiff works».
  3. Не закріплюй ваду зеленим тестом. Якщо поведінка неправильна — місце їй у розділі «знайдене» звіту або в цьому файлі, а не в expect. Виняток — коли поточне правило треба зафіксувати свідомо (як сортування uniq кодами символів); тоді це має бути сказано в коментарі.
  4. Випадкові дані — лише з детермінованим генератором. Тест, що падає раз на сто прогонів і не відтворюється, гірший за відсутність тесту: у нього перестають вірити й починають перезапускати CI, доки не позеленіє.
  5. Модульний стан прибирай за собою (див. labelLayout.test.ts): інакше падатиме не той тест, що зламався.