feat(admin-ui): RequireAuth gate + RTL component тесты

RequireAuth: гейтит весь UI по аутентификации. Anonymous юзер вместо
красного "AxiosError 401" получает immediate signinRedirect на Keycloak;
authenticated юзер с истёкшим токеном получает silent renew → fallback на
full redirect. Guards против infinite loop:
- auth.isLoading / activeNavigator — bootstrap или signin/signout уже идёт
- ?error= в URL — вернулись с провалом из Keycloak, показываем сообщение
- auth.error без isAuthenticated — Keycloak недоступен, показываем сообщение

main.tsx: RequireAuth обёрнут вокруг Router/Query, чтобы tan-stack-router и
React Query не делали запросов до auth (иначе 401-каскад на старте).

TokenSync: 401 interceptor теперь редиректит и anonymous (раньше срабатывал
только для authenticated с истёкшим токеном), с теми же guards.

RTL component тесты — 22 теста на 3 ключевых компонента:
- SchemaBuilder (7): empty state, add/remove/reorder properties
- PropertyEditor (10): kind change, required/unique toggle, kind-specific
  поля (string→pattern, integer→min/max, enum→values, array_string→uniqueItems),
  move up/down disabled на boundaries
- SchemaDrivenForm (5): identity tab с required, переключение на extra,
  isPending → save disabled, cancel callback, serverError → Alert

Setup:
- vite.config.ts: test block (jsdom env, setup file, css off)
- src/test/setup.ts: jest-dom matchers, cleanup, HTMLCanvasElement.getContext
  stub (lottie-web из @nstart/ui крашит в jsdom без canvas)
- src/test/render.tsx: renderWithI18n helper, форсит ru-RU
  (LanguageDetector в node по умолчанию даёт en-US)

Deps: +@testing-library/react +@testing-library/jest-dom
+@testing-library/user-event +jsdom
This commit is contained in:
Zimin A.N.
2026-05-06 00:11:07 +03:00
parent 01cca3a6f4
commit ab6f6cb860
11 changed files with 1098 additions and 11 deletions
+19
View File
@@ -0,0 +1,19 @@
import { render, type RenderOptions } from '@testing-library/react'
import { I18nextProvider } from 'react-i18next'
import type { ReactElement, ReactNode } from 'react'
import i18n from '@/i18n'
// LanguageDetector в jsdom по умолчанию даёт en-US (через navigator.language).
// Для тестов фиксируем ru-RU чтобы не возиться с двумя наборами ассертов.
if (i18n.language !== 'ru-RU') {
void i18n.changeLanguage('ru-RU')
}
const Wrapper = ({ children }: { children: ReactNode }) => (
<I18nextProvider i18n={i18n}>{children}</I18nextProvider>
)
export const renderWithI18n = (ui: ReactElement, opts?: Omit<RenderOptions, 'wrapper'>) =>
render(ui, { wrapper: Wrapper, ...opts })
export { i18n }
+62
View File
@@ -0,0 +1,62 @@
import '@testing-library/jest-dom/vitest'
import { afterEach, vi } from 'vitest'
import { cleanup } from '@testing-library/react'
// @nstart/ui тянет lottie-web (анимация brand-стикеров) prebuilt; lottie при
// импорте инициализирует CanvasRenderingContext, в jsdom его нет → крах
// "Cannot set properties of null (setting fillStyle)". vi.mock не ловит т.к.
// lottie уже вшит в bundle. Стабим прямо HTMLCanvasElement.getContext —
// возвращаем no-op 2D-context, lottie доволен и init не падает.
if (typeof HTMLCanvasElement !== 'undefined' && !HTMLCanvasElement.prototype.getContext) {
// безопасная проверка для любой среды
}
HTMLCanvasElement.prototype.getContext = (() => {
const noop = () => {}
const ctx = new Proxy(
{},
{
get: (target: Record<string, unknown>, prop: string) => {
if (prop in target) return target[prop]
if (
prop === 'measureText'
) {
return () => ({ width: 0 })
}
if (prop === 'getImageData') {
return () => ({ data: new Uint8ClampedArray(4) })
}
if (prop === 'createImageData') {
return () => ({ data: new Uint8ClampedArray(4) })
}
return noop
},
set: (target: Record<string, unknown>, prop: string, value: unknown) => {
target[prop] = value
return true
},
},
)
return () => ctx as unknown as CanvasRenderingContext2D
}) () as never
// нет требований к unused vi импорту
void vi
afterEach(() => {
cleanup()
})
if (!('matchMedia' in window)) {
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: (q: string) => ({
matches: false,
media: q,
addEventListener: () => {},
removeEventListener: () => {},
addListener: () => {},
removeListener: () => {},
dispatchEvent: () => false,
onchange: null,
}),
})
}