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:
@@ -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 }
|
||||
@@ -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,
|
||||
}),
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user