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

264 lines
23 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Тести вебу
```bash
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`): інакше
падатиме не той тест, що зламався.