From 729e94b4db25c1905b93403e2f5b65da40312c82 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Sun, 14 Jun 2026 08:25:38 +0300 Subject: [PATCH] test: add 95 frontend unit tests with vitest, RTL, and MSW 95 tests across 22 files covering all frontend modules: - API layer: client, auth - Components: BondDetails, Layout, PriceChart, ProtectedRoute, SearchBar, StockDetails - Context: AuthContext - Hooks: useAuth, useBond, useBondCandles, useSearch, useStock, useStockCandles, useStockDividends - Pages: BondPage, HomePage, LoginPage, ProfilePage, RegisterPage, StockPage Infrastructure: - vitest + @testing-library/react + MSW v2 with 13 API handlers - Co-located test files alongside source files - Test utilities: setup, server, factories, test-utils - BrowserRouter future flags for MemoryRouter test compatibility - Root test:frontend script for workspace-wide execution --- apps/backend/prisma.config.ts | 10 +- .../src/modules/auth/auth.service.spec.ts | 4 +- apps/backend/src/modules/auth/auth.service.ts | 7 +- .../src/modules/prisma/prisma.service.ts | 1 - apps/frontend/package.json | 21 +- apps/frontend/src/App.tsx | 2 +- apps/frontend/src/api/auth.test.ts | 68 + apps/frontend/src/api/auth.ts | 46 +- apps/frontend/src/api/client.test.ts | 135 + apps/frontend/src/api/client.ts | 3 +- .../src/components/BondDetails.test.tsx | 71 + apps/frontend/src/components/Layout.test.tsx | 57 + .../src/components/PriceChart.test.tsx | 21 + .../src/components/ProtectedRoute.test.tsx | 36 + .../src/components/SearchBar.test.tsx | 77 + .../src/components/StockDetails.test.tsx | 63 + .../frontend/src/context/AuthContext.test.tsx | 73 + apps/frontend/src/context/AuthContext.tsx | 22 +- apps/frontend/src/hooks/useAuth.test.tsx | 63 + apps/frontend/src/hooks/useBond.test.tsx | 30 + .../src/hooks/useBondCandles.test.tsx | 43 + apps/frontend/src/hooks/useSearch.test.tsx | 62 + apps/frontend/src/hooks/useStock.test.tsx | 36 + .../src/hooks/useStockCandles.test.tsx | 44 + .../src/hooks/useStockDividends.test.tsx | 38 + apps/frontend/src/pages/BondPage.test.tsx | 48 + apps/frontend/src/pages/HomePage.test.tsx | 25 + apps/frontend/src/pages/LoginPage.test.tsx | 81 + apps/frontend/src/pages/LoginPage.tsx | 63 +- apps/frontend/src/pages/ProfilePage.test.tsx | 62 + apps/frontend/src/pages/ProfilePage.tsx | 16 +- apps/frontend/src/pages/RegisterPage.test.tsx | 77 + apps/frontend/src/pages/RegisterPage.tsx | 65 +- apps/frontend/src/pages/StockPage.test.tsx | 73 + apps/frontend/src/test/README.md | 9 + apps/frontend/src/test/factories.ts | 146 + apps/frontend/src/test/handlers.ts | 190 ++ apps/frontend/src/test/server.ts | 4 + apps/frontend/src/test/setup.ts | 73 + apps/frontend/src/test/test-utils.tsx | 40 + apps/frontend/vitest.config.ts | 18 + .../2026-06-13-frontend-test-coverage.md | 1920 +++++++++++ .../2026-06-13-frontend-test-coverage.md | 127 + package-lock.json | 2906 ++++++++++++++++- package.json | 1 + 45 files changed, 6879 insertions(+), 98 deletions(-) create mode 100644 apps/frontend/src/api/auth.test.ts create mode 100644 apps/frontend/src/api/client.test.ts create mode 100644 apps/frontend/src/components/BondDetails.test.tsx create mode 100644 apps/frontend/src/components/Layout.test.tsx create mode 100644 apps/frontend/src/components/PriceChart.test.tsx create mode 100644 apps/frontend/src/components/ProtectedRoute.test.tsx create mode 100644 apps/frontend/src/components/SearchBar.test.tsx create mode 100644 apps/frontend/src/components/StockDetails.test.tsx create mode 100644 apps/frontend/src/context/AuthContext.test.tsx create mode 100644 apps/frontend/src/hooks/useAuth.test.tsx create mode 100644 apps/frontend/src/hooks/useBond.test.tsx create mode 100644 apps/frontend/src/hooks/useBondCandles.test.tsx create mode 100644 apps/frontend/src/hooks/useSearch.test.tsx create mode 100644 apps/frontend/src/hooks/useStock.test.tsx create mode 100644 apps/frontend/src/hooks/useStockCandles.test.tsx create mode 100644 apps/frontend/src/hooks/useStockDividends.test.tsx create mode 100644 apps/frontend/src/pages/BondPage.test.tsx create mode 100644 apps/frontend/src/pages/HomePage.test.tsx create mode 100644 apps/frontend/src/pages/LoginPage.test.tsx create mode 100644 apps/frontend/src/pages/ProfilePage.test.tsx create mode 100644 apps/frontend/src/pages/RegisterPage.test.tsx create mode 100644 apps/frontend/src/pages/StockPage.test.tsx create mode 100644 apps/frontend/src/test/README.md create mode 100644 apps/frontend/src/test/factories.ts create mode 100644 apps/frontend/src/test/handlers.ts create mode 100644 apps/frontend/src/test/server.ts create mode 100644 apps/frontend/src/test/setup.ts create mode 100644 apps/frontend/src/test/test-utils.tsx create mode 100644 apps/frontend/vitest.config.ts create mode 100644 docs/superpowers/plans/2026-06-13-frontend-test-coverage.md create mode 100644 docs/superpowers/specs/2026-06-13-frontend-test-coverage.md diff --git a/apps/backend/prisma.config.ts b/apps/backend/prisma.config.ts index 831a20f..20471ec 100644 --- a/apps/backend/prisma.config.ts +++ b/apps/backend/prisma.config.ts @@ -1,14 +1,14 @@ // This file was generated by Prisma, and assumes you have installed the following: // npm install --save-dev prisma dotenv -import "dotenv/config"; -import { defineConfig } from "prisma/config"; +import 'dotenv/config'; +import { defineConfig } from 'prisma/config'; export default defineConfig({ - schema: "prisma/schema.prisma", + schema: 'prisma/schema.prisma', migrations: { - path: "prisma/migrations", + path: 'prisma/migrations', }, datasource: { - url: process.env["DATABASE_URL"], + url: process.env['DATABASE_URL'], }, }); diff --git a/apps/backend/src/modules/auth/auth.service.spec.ts b/apps/backend/src/modules/auth/auth.service.spec.ts index c187952..6a45aa7 100644 --- a/apps/backend/src/modules/auth/auth.service.spec.ts +++ b/apps/backend/src/modules/auth/auth.service.spec.ts @@ -101,9 +101,7 @@ describe('AuthService', () => { it('should register a user without name', async () => { vi.mocked(prisma.user.findUnique).mockResolvedValue(null); - vi.mocked(prisma.user.create).mockResolvedValue( - mockPrismaUser({ name: null, id: 2 }) as any, - ); + vi.mocked(prisma.user.create).mockResolvedValue(mockPrismaUser({ name: null, id: 2 }) as any); const result = await service.register({ email: testUser.email, password: testUser.password }); diff --git a/apps/backend/src/modules/auth/auth.service.ts b/apps/backend/src/modules/auth/auth.service.ts index 3abbbe0..a191a60 100644 --- a/apps/backend/src/modules/auth/auth.service.ts +++ b/apps/backend/src/modules/auth/auth.service.ts @@ -100,7 +100,12 @@ export class AuthService { return this.sanitizeUser(user); } - private async generateTokens(user: { id: number; email: string; name: string | null; role: string }) { + private async generateTokens(user: { + id: number; + email: string; + name: string | null; + role: string; + }) { const accessPayload: JwtPayload = { sub: user.id, email: user.email, role: user.role }; const accessToken = await this.jwtService.signAsync(accessPayload, { secret: this.configService.get('app.auth.jwtSecret'), diff --git a/apps/backend/src/modules/prisma/prisma.service.ts b/apps/backend/src/modules/prisma/prisma.service.ts index 2bcb147..04ae7a1 100644 --- a/apps/backend/src/modules/prisma/prisma.service.ts +++ b/apps/backend/src/modules/prisma/prisma.service.ts @@ -18,4 +18,3 @@ export class PrismaService extends PrismaClient implements OnModuleInit, OnModul await this.$disconnect(); } } - diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 5a32816..8654816 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -7,22 +7,31 @@ "dev": "vite", "build": "tsc -b && vite build", "preview": "vite preview", - "codegen": "openapi-typescript http://localhost:3000/api/docs-json -o src/api/types.ts" + "codegen": "openapi-typescript http://localhost:3000/api/docs-json -o src/api/types.ts", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { + "@tanstack/react-query": "^5.20.0", + "lightweight-charts": "^4.1.0", + "openapi-fetch": "^0.9.0", "react": "^18.3.0", "react-dom": "^18.3.0", - "react-router-dom": "^6.20.0", - "@tanstack/react-query": "^5.20.0", - "openapi-fetch": "^0.9.0", - "lightweight-charts": "^4.1.0" + "react-router-dom": "^6.20.0" }, "devDependencies": { + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", + "@types/node": "^25.9.3", "@types/react": "^18.3.0", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.2.0", + "jsdom": "^29.1.1", + "msw": "^2.14.6", + "openapi-typescript": "^7.0.0", "typescript": "^5.3.0", "vite": "^5.4.0", - "openapi-typescript": "^7.0.0" + "vitest": "^4.1.8" } } diff --git a/apps/frontend/src/App.tsx b/apps/frontend/src/App.tsx index 1f7edfc..3bb51fc 100644 --- a/apps/frontend/src/App.tsx +++ b/apps/frontend/src/App.tsx @@ -3,7 +3,7 @@ import { AppRoutes } from './routes'; export default function App() { return ( - + ); diff --git a/apps/frontend/src/api/auth.test.ts b/apps/frontend/src/api/auth.test.ts new file mode 100644 index 0000000..571544e --- /dev/null +++ b/apps/frontend/src/api/auth.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { http, HttpResponse } from 'msw'; +import { server } from '../test/server'; +import { setAccessToken, getAccessToken } from './client'; +import { login, register, refresh, logout, getMe, updateProfile } from './auth'; + +const API = '/api/v1'; + +beforeEach(() => { + setAccessToken(null); +}); + +describe('login', () => { + it('returns auth data and sets access token', async () => { + const result = await login('user@test.com', 'password'); + expect(result.user.email).toBe('user@test.com'); + expect(result.accessToken).toBe('mock-access-token'); + expect(getAccessToken()).toBe('mock-access-token'); + }); + + it('throws on invalid credentials', async () => { + server.use( + http.post( + `${API}/auth/login`, + () => new HttpResponse(null, { status: 401, statusText: 'Unauthorized' }), + ), + ); + await expect(login('wrong@test.com', 'wrong')).rejects.toThrow(); + }); +}); + +describe('register', () => { + it('returns auth data and sets access token', async () => { + const result = await register('new@test.com', 'password', 'New User'); + expect(result.user.email).toBe('user@test.com'); + expect(getAccessToken()).toBe('mock-access-token'); + }); +}); + +describe('refresh', () => { + it('returns auth data and sets access token', async () => { + const result = await refresh(); + expect(result.accessToken).toBe('mock-access-token'); + expect(getAccessToken()).toBe('mock-access-token'); + }); +}); + +describe('logout', () => { + it('clears access token', async () => { + setAccessToken('test-token'); + await logout(); + expect(getAccessToken()).toBeNull(); + }); +}); + +describe('getMe', () => { + it('returns current user', async () => { + const result = await getMe(); + expect(result.email).toBe('user@test.com'); + }); +}); + +describe('updateProfile', () => { + it('updates and returns user', async () => { + const result = await updateProfile({ name: 'Updated' }); + expect(result.name).toBe('Updated'); + }); +}); diff --git a/apps/frontend/src/api/auth.ts b/apps/frontend/src/api/auth.ts index 4203d07..a994e78 100644 --- a/apps/frontend/src/api/auth.ts +++ b/apps/frontend/src/api/auth.ts @@ -2,41 +2,38 @@ import { request, setAccessToken } from './client'; import type { AuthResponse, UserResponse } from './responses'; export async function login(email: string, password: string) { - const result = await request( - '/api/v1/auth/login', - undefined, - { method: 'POST', body: { email, password }, skipAuth: true }, - ); + const result = await request('/api/v1/auth/login', undefined, { + method: 'POST', + body: { email, password }, + skipAuth: true, + }); setAccessToken(result.data.accessToken); return result.data; } export async function register(email: string, password: string, name?: string) { - const result = await request( - '/api/v1/auth/register', - undefined, - { method: 'POST', body: { email, password, name }, skipAuth: true }, - ); + const result = await request('/api/v1/auth/register', undefined, { + method: 'POST', + body: { email, password, name }, + skipAuth: true, + }); setAccessToken(result.data.accessToken); return result.data; } export async function refresh() { - const result = await request( - '/api/v1/auth/refresh', - undefined, - { method: 'POST', skipAuth: true }, - ); + const result = await request('/api/v1/auth/refresh', undefined, { + method: 'POST', + skipAuth: true, + }); setAccessToken(result.data.accessToken); return result.data; } export async function logout() { - const result = await request<{ message: string }>( - '/api/v1/auth/logout', - undefined, - { method: 'POST' }, - ); + const result = await request<{ message: string }>('/api/v1/auth/logout', undefined, { + method: 'POST', + }); setAccessToken(null); return result.data; } @@ -47,10 +44,9 @@ export async function getMe() { } export async function updateProfile(data: { name?: string }) { - const result = await request( - '/api/v1/auth/me', - undefined, - { method: 'PATCH', body: data }, - ); + const result = await request('/api/v1/auth/me', undefined, { + method: 'PATCH', + body: data, + }); return result.data; } diff --git a/apps/frontend/src/api/client.test.ts b/apps/frontend/src/api/client.test.ts new file mode 100644 index 0000000..095f62f --- /dev/null +++ b/apps/frontend/src/api/client.test.ts @@ -0,0 +1,135 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { http, HttpResponse } from 'msw'; +import { server } from '../test/server'; +import { request, setAccessToken, getAccessToken, setOnUnauthorized } from './client'; + +const API = '/api/v1'; + +beforeEach(() => { + setAccessToken(null); +}); + +describe('request', () => { + it('makes GET request and returns data', async () => { + const result = await request<{ status: string; timestamp: string; uptime: number }>( + '/api/v1/health', + ); + expect(result.data.status).toBe('ok'); + }); + + it('includes Authorization header when token is set', async () => { + setAccessToken('test-token'); + let capturedAuth: string | null = null; + server.use( + http.get(`${API}/test-auth`, ({ request }) => { + capturedAuth = request.headers.get('Authorization'); + return HttpResponse.json({ + data: { data: { ok: true }, meta: { fromCache: false, cachedAt: null } }, + }); + }), + ); + await request('/api/v1/test-auth'); + expect(capturedAuth).toBe('Bearer test-token'); + }); + + it('retries on 401 and succeeds after refresh', async () => { + setAccessToken('expired-token'); + let attempts = 0; + server.use( + http.get(`${API}/test-retry`, ({ request }) => { + attempts++; + const auth = request.headers.get('Authorization'); + if (auth === 'Bearer expired-token') { + return new HttpResponse(null, { status: 401 }); + } + return HttpResponse.json({ + data: { data: { ok: true }, meta: { fromCache: false, cachedAt: null } }, + }); + }), + http.post(`${API}/auth/refresh`, () => + HttpResponse.json({ + data: { + data: { + user: { id: 1, email: 'user@test.com', name: null, role: 'user' }, + accessToken: 'new-token', + }, + meta: { fromCache: false, cachedAt: null }, + }, + }), + ), + ); + const result = await request<{ ok: boolean }>('/api/v1/test-retry'); + expect(attempts).toBe(2); + expect(result.data).toEqual({ ok: true }); + expect(getAccessToken()).toBe('new-token'); + }); + + it('throws on persistent 401 and clears token', async () => { + setAccessToken('expired-token'); + let unauthorizedCalled = false; + setOnUnauthorized(() => { + unauthorizedCalled = true; + }); + server.use( + http.get(`${API}/test-fail`, () => new HttpResponse(null, { status: 401 })), + http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 })), + ); + await expect(request('/api/v1/test-fail')).rejects.toThrow('Сессия истекла'); + expect(getAccessToken()).toBeNull(); + expect(unauthorizedCalled).toBe(true); + }); + + it('throws on non-ok response with status text', async () => { + server.use( + http.get( + `${API}/test-error`, + () => new HttpResponse('Not found', { status: 404, statusText: 'Not Found' }), + ), + ); + await expect(request('/api/v1/test-error')).rejects.toThrow('Ошибка API: 404'); + }); + + it('sends JSON body for POST requests', async () => { + let capturedBody: string | null = null; + server.use( + http.post(`${API}/test-post`, async ({ request }) => { + capturedBody = await request.text(); + return HttpResponse.json({ + data: { data: { ok: true }, meta: { fromCache: false, cachedAt: null } }, + }); + }), + ); + await request('/api/v1/test-post', undefined, { method: 'POST', body: { foo: 'bar' } }); + expect(capturedBody).toBe(JSON.stringify({ foo: 'bar' })); + }); + + it('does not send auth header when skipAuth is true', async () => { + setAccessToken('test-token'); + let capturedAuth: string | null = null; + server.use( + http.get(`${API}/test-skip`, ({ request }) => { + capturedAuth = request.headers.get('Authorization'); + return HttpResponse.json({ + data: { data: { ok: true }, meta: { fromCache: false, cachedAt: null } }, + }); + }), + ); + await request('/api/v1/test-skip', undefined, { skipAuth: true }); + expect(capturedAuth).toBeNull(); + }); + + it('sets query params correctly', async () => { + let capturedUrl = ''; + server.use( + http.get(`${API}/test-params`, ({ request }) => { + capturedUrl = request.url; + return HttpResponse.json({ + data: { data: { ok: true }, meta: { fromCache: false, cachedAt: null } }, + }); + }), + ); + await request('/api/v1/test-params', { q: 'sber', type: 'share' }); + expect(capturedUrl).toContain('q=sber'); + expect(capturedUrl).toContain('type=share'); + }); +}); diff --git a/apps/frontend/src/api/client.ts b/apps/frontend/src/api/client.ts index 5a505c0..320a94d 100644 --- a/apps/frontend/src/api/client.ts +++ b/apps/frontend/src/api/client.ts @@ -91,7 +91,8 @@ export async function request( fetchOptions.method = options.method; } if (options?.body !== undefined) { - fetchOptions.body = options.body instanceof FormData ? options.body : JSON.stringify(options.body); + fetchOptions.body = + options.body instanceof FormData ? options.body : JSON.stringify(options.body); } let res = await fetch(url.toString(), fetchOptions); diff --git a/apps/frontend/src/components/BondDetails.test.tsx b/apps/frontend/src/components/BondDetails.test.tsx new file mode 100644 index 0000000..3b3d0a7 --- /dev/null +++ b/apps/frontend/src/components/BondDetails.test.tsx @@ -0,0 +1,71 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { BondDetails } from './BondDetails'; +import { createMockBond } from '../test/factories'; + +describe('BondDetails', () => { + it('renders bond details', () => { + const bond = createMockBond(); + render(); + expect(screen.getByText('ОФЗ 26238')).toBeInTheDocument(); + expect(screen.getAllByText('RU000A101XU7').length).toBe(2); + }); + + it('shows price as percentage', () => { + const bond = createMockBond(); + render(); + expect(screen.getByText('98.50%')).toBeInTheDocument(); + }); + + it('renders maturity date', () => { + const bond = createMockBond(); + render(); + expect(screen.getByText('2027-05-15')).toBeInTheDocument(); + }); + + it('shows coupon with percentage', () => { + const bond = createMockBond(); + render(); + expect(screen.getByText('36.9 ₽ (7.5%)')).toBeInTheDocument(); + }); + + it('shows coupon without percentage when null', () => { + const bond = createMockBond({ + marketData: { + ...createMockBond().marketData, + couponPercent: null, + }, + }); + render(); + expect(screen.getByText('36.9 ₽')).toBeInTheDocument(); + }); + + it('shows dash for missing next coupon date', () => { + const bond = createMockBond({ + marketData: { + ...createMockBond().marketData, + nextCouponDate: null, + }, + }); + render(); + const dashes = screen.getAllByText('—'); + expect(dashes.length).toBeGreaterThanOrEqual(1); + }); + + it('shows dash for null yieldToMaturity', () => { + const bond = createMockBond({ + marketData: { + ...createMockBond().marketData, + yieldToMaturity: null, + }, + }); + render(); + expect(screen.getByText('—')).toBeInTheDocument(); + }); + + it('shows bond type', () => { + const bond = createMockBond(); + render(); + expect(screen.getByText('ОФЗ')).toBeInTheDocument(); + }); +}); diff --git a/apps/frontend/src/components/Layout.test.tsx b/apps/frontend/src/components/Layout.test.tsx new file mode 100644 index 0000000..6673eb0 --- /dev/null +++ b/apps/frontend/src/components/Layout.test.tsx @@ -0,0 +1,57 @@ +import { describe, it, expect } from 'vitest'; +import { screen, waitFor } from '@testing-library/react'; +import { http, HttpResponse } from 'msw'; +import { server } from '../test/server'; +import { Layout } from './Layout'; +import { renderWithProviders } from '../test/test-utils'; + +const API = '/api/v1'; + +describe('Layout', () => { + it('renders logo and search bar', async () => { + renderWithProviders(); + expect(await screen.findByText('MoexVibe')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('Поиск акций и облигаций...')).toBeInTheDocument(); + }); + + it('shows login link when not authenticated', async () => { + server.use(http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 }))); + renderWithProviders(); + await waitFor(() => { + expect(screen.getByText('Войти')).toBeInTheDocument(); + }); + }); + + it('shows user name and logout when authenticated', async () => { + renderWithProviders(); + await waitFor(() => { + expect(screen.getByText('Test User')).toBeInTheDocument(); + expect(screen.getByText('Выйти')).toBeInTheDocument(); + }); + }); + + it('shows email when user has no name', async () => { + server.use( + http.post(`${API}/auth/refresh`, () => { + return HttpResponse.json({ + data: { + data: { + user: { + id: 1, + email: 'user@test.com', + name: null, + role: 'user', + }, + accessToken: 'mock', + }, + meta: { fromCache: false, cachedAt: null }, + }, + }); + }), + ); + renderWithProviders(); + await waitFor(() => { + expect(screen.getByText('user@test.com')).toBeInTheDocument(); + }); + }); +}); diff --git a/apps/frontend/src/components/PriceChart.test.tsx b/apps/frontend/src/components/PriceChart.test.tsx new file mode 100644 index 0000000..fe00156 --- /dev/null +++ b/apps/frontend/src/components/PriceChart.test.tsx @@ -0,0 +1,21 @@ +import { describe, it, expect } from 'vitest'; +import { render } from '@testing-library/react'; +import { PriceChart } from './PriceChart'; + +describe('PriceChart', () => { + it('renders chart container with empty data', () => { + const { container } = render(); + expect(container.querySelector('div')).toBeInTheDocument(); + }); + + it('renders with candle data', () => { + const data = [{ open: 100, high: 110, low: 95, close: 105, begin: '2024-01-15T10:00:00Z' }]; + const { container } = render(); + expect(container.querySelector('div')).toBeInTheDocument(); + }); + + it('accepts custom height', () => { + const { container } = render(); + expect(container.querySelector('div')).toBeInTheDocument(); + }); +}); diff --git a/apps/frontend/src/components/ProtectedRoute.test.tsx b/apps/frontend/src/components/ProtectedRoute.test.tsx new file mode 100644 index 0000000..68238b5 --- /dev/null +++ b/apps/frontend/src/components/ProtectedRoute.test.tsx @@ -0,0 +1,36 @@ +import { describe, it, expect } from 'vitest'; +import { screen, waitFor } from '@testing-library/react'; +import { http, HttpResponse } from 'msw'; +import { server } from '../test/server'; +import { ProtectedRoute } from './ProtectedRoute'; +import { renderWithProviders } from '../test/test-utils'; + +const API = '/api/v1'; + +describe('ProtectedRoute', () => { + it('renders children when authenticated', async () => { + renderWithProviders( + +
Secret
+
, + ); + expect(await screen.findByTestId('protected-content')).toBeInTheDocument(); + }); + + it('shows loading state initially then redirects when not authenticated', async () => { + server.use(http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 }))); + + renderWithProviders( + +
Secret
+
, + { route: '/profile' }, + ); + + expect(screen.getByText('Загрузка...')).toBeInTheDocument(); + + await waitFor(() => { + expect(screen.queryByTestId('protected-content')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/apps/frontend/src/components/SearchBar.test.tsx b/apps/frontend/src/components/SearchBar.test.tsx new file mode 100644 index 0000000..4c6de6f --- /dev/null +++ b/apps/frontend/src/components/SearchBar.test.tsx @@ -0,0 +1,77 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { http, HttpResponse } from 'msw'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { MemoryRouter } from 'react-router-dom'; +import { server } from '../test/server'; +import { SearchBar } from './SearchBar'; +const API = '/api/v1'; + +function renderSearchBar() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return render( + + + + + , + ); +} + +describe('SearchBar', () => { + it('renders search input', () => { + renderSearchBar(); + expect(screen.getByPlaceholderText('Поиск акций и облигаций...')).toBeInTheDocument(); + }); + + it('shows dropdown on focus', async () => { + renderSearchBar(); + const input = screen.getByPlaceholderText('Поиск акций и облигаций...'); + await userEvent.type(input, 'sber'); + await waitFor(() => { + expect(screen.getByText('Сбер')).toBeInTheDocument(); + }); + }); + + it('shows loading state while fetching', async () => { + server.use(http.get(`${API}/securities/search`, () => new Promise(() => {}))); + renderSearchBar(); + const input = screen.getByPlaceholderText('Поиск акций и облигаций...'); + await userEvent.type(input, 'sber'); + await waitFor(() => { + expect(screen.getByText('Загрузка...')).toBeInTheDocument(); + }); + }); + + it('shows no results message', async () => { + server.use( + http.get(`${API}/securities/search`, () => { + return HttpResponse.json({ + data: { data: [], meta: { fromCache: false, cachedAt: null } }, + }); + }), + ); + renderSearchBar(); + const input = screen.getByPlaceholderText('Поиск акций и облигаций...'); + await userEvent.type(input, 'zzzzz'); + await waitFor(() => { + expect(screen.getByText('Ничего не найдено')).toBeInTheDocument(); + }); + }); + + it('hides dropdown when clicking outside', async () => { + renderSearchBar(); + const input = screen.getByPlaceholderText('Поиск акций и облигаций...'); + await userEvent.type(input, 'sber'); + await waitFor(() => { + expect(screen.getByText('Сбер')).toBeInTheDocument(); + }); + await userEvent.click(document.body); + await waitFor(() => { + expect(screen.queryByText('Сбер')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/apps/frontend/src/components/StockDetails.test.tsx b/apps/frontend/src/components/StockDetails.test.tsx new file mode 100644 index 0000000..1ca9b82 --- /dev/null +++ b/apps/frontend/src/components/StockDetails.test.tsx @@ -0,0 +1,63 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { StockDetails } from './StockDetails'; +import { createMockShare } from '../test/factories'; + +describe('StockDetails', () => { + it('renders stock details', () => { + const stock = createMockShare(); + render(); + expect(screen.getByText('Сбер (SBER)')).toBeInTheDocument(); + expect(screen.getByText('Сбер Банк · RU0009029540')).toBeInTheDocument(); + }); + + it('displays positive change in green', () => { + const stock = createMockShare({ + marketData: { + ...createMockShare().marketData, + change: 5, + changePercent: 2, + }, + }); + render(); + const changeEl = screen.getByText('+5.00 (2.00%)'); + expect(changeEl).toBeInTheDocument(); + }); + + it('displays negative change in red', () => { + const stock = createMockShare({ + marketData: { + ...createMockShare().marketData, + change: -3, + changePercent: -1, + }, + }); + render(); + const changeEl = screen.getByText('-3.00 (-1.00%)'); + expect(changeEl).toBeInTheDocument(); + }); + + it('shows price formatted', () => { + const stock = createMockShare(); + render(); + expect(screen.getByText('289,50')).toBeInTheDocument(); + }); + + it('shows dash for null high', () => { + const stock = createMockShare({ + marketData: { + ...createMockShare().marketData, + high: null, + }, + }); + render(); + const dashes = screen.getAllByText('—'); + expect(dashes.length).toBeGreaterThanOrEqual(1); + }); + + it('shows capitalization in billions', () => { + const stock = createMockShare(); + render(); + expect(screen.getByText('6250.00 млрд ₽')).toBeInTheDocument(); + }); +}); diff --git a/apps/frontend/src/context/AuthContext.test.tsx b/apps/frontend/src/context/AuthContext.test.tsx new file mode 100644 index 0000000..88a9580 --- /dev/null +++ b/apps/frontend/src/context/AuthContext.test.tsx @@ -0,0 +1,73 @@ +import { describe, it, expect } from 'vitest'; +import { useContext } from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { http, HttpResponse } from 'msw'; +import { server } from '../test/server'; +import { AuthContext, AuthProvider } from './AuthContext'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + +const API = '/api/v1'; + +function renderWithProviders(ui: React.ReactElement) { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + {ui} + , + ); +} + +function TestConsumer() { + const ctx = useContext(AuthContext); + if (!ctx) return
no context
; + return ( +
+ {ctx.isAuthenticated ? 'authenticated' : 'anonymous'} + {ctx.user?.email ?? ''} + + + + +
+ ); +} + +describe('AuthContext', () => { + it('starts unauthenticated when refresh fails', async () => { + server.use(http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 }))); + renderWithProviders(); + await waitFor(() => { + expect(screen.getByTestId('auth')).toHaveTextContent('anonymous'); + }); + }); + + it('restores session on mount when refresh succeeds', async () => { + renderWithProviders(); + await waitFor(() => { + expect(screen.getByTestId('auth')).toHaveTextContent('authenticated'); + expect(screen.getByTestId('email')).toHaveTextContent('user@test.com'); + }); + }); + + it('updates state after login', async () => { + server.use(http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 }))); + const user = userEvent.setup(); + renderWithProviders(); + await waitFor(() => expect(screen.getByTestId('auth')).toHaveTextContent('anonymous')); + await user.click(screen.getByRole('button', { name: 'login' })); + await waitFor(() => { + expect(screen.getByTestId('auth')).toHaveTextContent('authenticated'); + }); + }); + + it('updates state after logout', async () => { + const user = userEvent.setup(); + renderWithProviders(); + await waitFor(() => expect(screen.getByTestId('auth')).toHaveTextContent('authenticated')); + await user.click(screen.getByRole('button', { name: 'logout' })); + await waitFor(() => { + expect(screen.getByTestId('auth')).toHaveTextContent('anonymous'); + }); + }); +}); diff --git a/apps/frontend/src/context/AuthContext.tsx b/apps/frontend/src/context/AuthContext.tsx index c124811..2eebe1c 100644 --- a/apps/frontend/src/context/AuthContext.tsx +++ b/apps/frontend/src/context/AuthContext.tsx @@ -32,15 +32,21 @@ export function AuthProvider({ children }: { children: ReactNode }) { setAccessTokenState(null); }, []); - const login = useCallback(async (email: string, password: string) => { - const result = await authApi.login(email, password); - updateSession(result); - }, [updateSession]); + const login = useCallback( + async (email: string, password: string) => { + const result = await authApi.login(email, password); + updateSession(result); + }, + [updateSession], + ); - const register = useCallback(async (email: string, password: string, name?: string) => { - const result = await authApi.register(email, password, name); - updateSession(result); - }, [updateSession]); + const register = useCallback( + async (email: string, password: string, name?: string) => { + const result = await authApi.register(email, password, name); + updateSession(result); + }, + [updateSession], + ); const logout = useCallback(async () => { try { diff --git a/apps/frontend/src/hooks/useAuth.test.tsx b/apps/frontend/src/hooks/useAuth.test.tsx new file mode 100644 index 0000000..d22e0bc --- /dev/null +++ b/apps/frontend/src/hooks/useAuth.test.tsx @@ -0,0 +1,63 @@ +import { describe, it, expect } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { AuthProvider } from '../context/AuthContext'; +import { useAuth } from './useAuth'; +import { type ReactNode } from 'react'; + +function createWrapper() { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return function Wrapper({ children }: { children: ReactNode }) { + return ( + + {children} + + ); + }; +} + +describe('useAuth', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('returns auth context with user after mount', async () => { + const { result } = renderHook(() => useAuth(), { wrapper: createWrapper() }); + await waitFor(() => { + expect(result.current.isAuthenticated).toBe(true); + }); + expect(result.current.user?.email).toBe('user@test.com'); + expect(result.current.accessToken).toBe('mock-access-token'); + }); + + it('provides login function', async () => { + const { result } = renderHook(() => useAuth(), { wrapper: createWrapper() }); + await waitFor(() => expect(result.current.isAuthenticated).toBe(true)); + expect(typeof result.current.login).toBe('function'); + }); + + it('provides logout function', async () => { + const { result } = renderHook(() => useAuth(), { wrapper: createWrapper() }); + await waitFor(() => expect(result.current.isAuthenticated).toBe(true)); + expect(typeof result.current.logout).toBe('function'); + }); + + it('provides register function', async () => { + const { result } = renderHook(() => useAuth(), { wrapper: createWrapper() }); + await waitFor(() => expect(result.current.isAuthenticated).toBe(true)); + expect(typeof result.current.register).toBe('function'); + }); + + it('throws when used without AuthProvider', () => { + vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + vi.spyOn(console, 'error').mockImplementation(() => {}); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + expect(() => { + renderHook(() => useAuth(), { + wrapper: ({ children }) => ( + {children} + ), + }); + }).toThrow('useAuth must be used within an AuthProvider'); + }); +}); diff --git a/apps/frontend/src/hooks/useBond.test.tsx b/apps/frontend/src/hooks/useBond.test.tsx new file mode 100644 index 0000000..7e3c64e --- /dev/null +++ b/apps/frontend/src/hooks/useBond.test.tsx @@ -0,0 +1,30 @@ +import { describe, it, expect } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { http, HttpResponse } from 'msw'; +import { server } from '../test/server'; +import { useBond } from './useBond'; +import { type ReactNode } from 'react'; + +const API = '/api/v1'; + +function createWrapper() { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return function Wrapper({ children }: { children: ReactNode }) { + return {children}; + }; +} + +describe('useBond', () => { + it('returns bond data', async () => { + const { result } = renderHook(() => useBond('SU26238RMFS5'), { wrapper: createWrapper() }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data?.shortName).toBe('ОФЗ 26238'); + expect(result.current.data?.marketData.price).toBe(98.5); + }); + + it('returns error on 404', async () => { + const { result } = renderHook(() => useBond('NOTFOUND'), { wrapper: createWrapper() }); + await waitFor(() => expect(result.current.isError).toBe(true)); + }); +}); diff --git a/apps/frontend/src/hooks/useBondCandles.test.tsx b/apps/frontend/src/hooks/useBondCandles.test.tsx new file mode 100644 index 0000000..d59e4a4 --- /dev/null +++ b/apps/frontend/src/hooks/useBondCandles.test.tsx @@ -0,0 +1,43 @@ +import { describe, it, expect } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { http, HttpResponse } from 'msw'; +import { server } from '../test/server'; +import { useBondCandles } from './useBondCandles'; +import { type ReactNode } from 'react'; + +const API = '/api/v1'; + +function createWrapper() { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return function Wrapper({ children }: { children: ReactNode }) { + return {children}; + }; +} + +describe('useBondCandles', () => { + it('returns candle data', async () => { + const { result } = renderHook( + () => useBondCandles('SU26238RMFS5', '24h', '2024-01-01', '2024-01-31'), + { wrapper: createWrapper() }, + ); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toHaveLength(2); + }); + + it('returns empty array when no candles', async () => { + server.use( + http.get(`${API}/securities/bonds/:secid/candles`, () => { + return HttpResponse.json({ + data: { data: [], meta: { fromCache: false, cachedAt: null } }, + }); + }), + ); + const { result } = renderHook( + () => useBondCandles('SU26238RMFS5', '24h', '2024-01-01', '2024-01-31'), + { wrapper: createWrapper() }, + ); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toEqual([]); + }); +}); diff --git a/apps/frontend/src/hooks/useSearch.test.tsx b/apps/frontend/src/hooks/useSearch.test.tsx new file mode 100644 index 0000000..d136476 --- /dev/null +++ b/apps/frontend/src/hooks/useSearch.test.tsx @@ -0,0 +1,62 @@ +import { describe, it, expect } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { http, HttpResponse } from 'msw'; +import { server } from '../test/server'; +import { useSearch } from './useSearch'; +import { type ReactNode } from 'react'; + +const API = '/api/v1'; + +function createWrapper() { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return function Wrapper({ children }: { children: ReactNode }) { + return {children}; + }; +} + +describe('useSearch', () => { + it('does not fetch when query is empty', () => { + const { result } = renderHook(() => useSearch(''), { wrapper: createWrapper() }); + expect(result.current.isFetching).toBe(false); + expect(result.current.data).toBeUndefined(); + }); + + it('does not fetch when query is too short', () => { + const { result } = renderHook(() => useSearch('a'), { wrapper: createWrapper() }); + expect(result.current.data).toBeUndefined(); + }); + + it('returns search results for valid query', async () => { + const { result } = renderHook(() => useSearch('sber'), { wrapper: createWrapper() }); + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + expect(result.current.data).toBeDefined(); + expect(result.current.data?.length).toBeGreaterThan(0); + expect(result.current.data?.[0].secid).toBe('SBER'); + }); + + it('returns empty array when no results', async () => { + server.use( + http.get(`${API}/securities/search`, () => { + return HttpResponse.json({ + data: { data: [], meta: { fromCache: false, cachedAt: null } }, + }); + }), + ); + const { result } = renderHook(() => useSearch('zzzzz'), { wrapper: createWrapper() }); + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + expect(result.current.data).toEqual([]); + }); + + it('returns error state on network failure', async () => { + server.use(http.get(`${API}/securities/search`, () => new HttpResponse(null, { status: 500 }))); + const { result } = renderHook(() => useSearch('error'), { wrapper: createWrapper() }); + await waitFor(() => { + expect(result.current.isError).toBe(true); + }); + }); +}); diff --git a/apps/frontend/src/hooks/useStock.test.tsx b/apps/frontend/src/hooks/useStock.test.tsx new file mode 100644 index 0000000..a84e6bf --- /dev/null +++ b/apps/frontend/src/hooks/useStock.test.tsx @@ -0,0 +1,36 @@ +import { describe, it, expect } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { http, HttpResponse } from 'msw'; +import { server } from '../test/server'; +import { useStock } from './useStock'; +import { type ReactNode } from 'react'; + +const API = '/api/v1'; + +function createWrapper() { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return function Wrapper({ children }: { children: ReactNode }) { + return {children}; + }; +} + +describe('useStock', () => { + it('returns share data', async () => { + const { result } = renderHook(() => useStock('SBER'), { wrapper: createWrapper() }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data?.secid).toBe('SBER'); + expect(result.current.data?.shortName).toBe('Сбер'); + expect(result.current.data?.marketData.price).toBe(289.5); + }); + + it('returns error for not found', async () => { + const { result } = renderHook(() => useStock('NOTFOUND'), { wrapper: createWrapper() }); + await waitFor(() => expect(result.current.isError).toBe(true)); + }); + + it('starts in loading state', () => { + const { result } = renderHook(() => useStock('SBER'), { wrapper: createWrapper() }); + expect(result.current.isLoading).toBe(true); + }); +}); diff --git a/apps/frontend/src/hooks/useStockCandles.test.tsx b/apps/frontend/src/hooks/useStockCandles.test.tsx new file mode 100644 index 0000000..0efcdb4 --- /dev/null +++ b/apps/frontend/src/hooks/useStockCandles.test.tsx @@ -0,0 +1,44 @@ +import { describe, it, expect } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { http, HttpResponse } from 'msw'; +import { server } from '../test/server'; +import { useStockCandles } from './useStockCandles'; +import { type ReactNode } from 'react'; + +const API = '/api/v1'; + +function createWrapper() { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return function Wrapper({ children }: { children: ReactNode }) { + return {children}; + }; +} + +describe('useStockCandles', () => { + it('returns candle data', async () => { + const { result } = renderHook( + () => useStockCandles('SBER', '24h', '2024-01-01', '2024-01-31'), + { wrapper: createWrapper() }, + ); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toHaveLength(2); + expect(result.current.data?.[0].open).toBe(280); + }); + + it('returns empty array when no candles', async () => { + server.use( + http.get(`${API}/securities/shares/:secid/candles`, () => { + return HttpResponse.json({ + data: { data: [], meta: { fromCache: false, cachedAt: null } }, + }); + }), + ); + const { result } = renderHook( + () => useStockCandles('SBER', '24h', '2024-01-01', '2024-01-31'), + { wrapper: createWrapper() }, + ); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toEqual([]); + }); +}); diff --git a/apps/frontend/src/hooks/useStockDividends.test.tsx b/apps/frontend/src/hooks/useStockDividends.test.tsx new file mode 100644 index 0000000..bae8807 --- /dev/null +++ b/apps/frontend/src/hooks/useStockDividends.test.tsx @@ -0,0 +1,38 @@ +import { describe, it, expect } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { http, HttpResponse } from 'msw'; +import { server } from '../test/server'; +import { useStockDividends } from './useStockDividends'; +import { type ReactNode } from 'react'; + +const API = '/api/v1'; + +function createWrapper() { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return function Wrapper({ children }: { children: ReactNode }) { + return {children}; + }; +} + +describe('useStockDividends', () => { + it('returns dividend data', async () => { + const { result } = renderHook(() => useStockDividends('SBER'), { wrapper: createWrapper() }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toHaveLength(2); + expect(result.current.data?.[0].value).toBe(35); + }); + + it('returns empty array when no dividends', async () => { + server.use( + http.get(`${API}/securities/shares/:secid/dividends`, () => { + return HttpResponse.json({ + data: { data: [], meta: { fromCache: false, cachedAt: null } }, + }); + }), + ); + const { result } = renderHook(() => useStockDividends('SBER'), { wrapper: createWrapper() }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toEqual([]); + }); +}); diff --git a/apps/frontend/src/pages/BondPage.test.tsx b/apps/frontend/src/pages/BondPage.test.tsx new file mode 100644 index 0000000..2b38ce0 --- /dev/null +++ b/apps/frontend/src/pages/BondPage.test.tsx @@ -0,0 +1,48 @@ +import { describe, it, expect } from 'vitest'; +import { screen } from '@testing-library/react'; +import { Routes, Route } from 'react-router-dom'; +import { http, HttpResponse } from 'msw'; +import { server } from '../test/server'; +import { BondPage } from './BondPage'; +import { renderWithProviders } from '../test/test-utils'; + +const API = '/api/v1'; + +function renderBondPage(secid = 'SU26238RMFS5') { + return renderWithProviders( + + } /> + , + { route: `/bonds/${secid}` }, + ); +} + +describe('BondPage', () => { + it('shows loading state', () => { + server.use(http.get(`${API}/securities/bonds/:secid`, () => new Promise(() => {}))); + renderBondPage(); + expect(screen.getByText('Загрузка...')).toBeInTheDocument(); + }); + + it('renders bond details after loading', async () => { + renderBondPage(); + expect(await screen.findByText('ОФЗ 26238')).toBeInTheDocument(); + }); + + it('renders price chart', async () => { + renderBondPage(); + expect(await screen.findByText('График цены')).toBeInTheDocument(); + }); + + it('shows error state for not found', async () => { + server.use( + http.get(`${API}/securities/bonds/:secid`, () => new HttpResponse(null, { status: 404 })), + http.get( + `${API}/securities/bonds/:secid/candles`, + () => new HttpResponse(null, { status: 404 }), + ), + ); + renderBondPage('NOTFOUND'); + expect(await screen.findByText('Инструмент не найден')).toBeInTheDocument(); + }); +}); diff --git a/apps/frontend/src/pages/HomePage.test.tsx b/apps/frontend/src/pages/HomePage.test.tsx new file mode 100644 index 0000000..bef45cd --- /dev/null +++ b/apps/frontend/src/pages/HomePage.test.tsx @@ -0,0 +1,25 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { HomePage } from './HomePage'; + +describe('HomePage', () => { + it('renders welcome title', () => { + render(); + expect(screen.getByText('MoexVibe')).toBeInTheDocument(); + }); + + it('renders description', () => { + render(); + expect(screen.getByText('Анализ акций и облигаций Московской биржи')).toBeInTheDocument(); + }); + + it('renders hint text', () => { + render(); + expect(screen.getByText(/Введите название или тикер/)).toBeInTheDocument(); + }); + + it('renders data delay notice', () => { + render(); + expect(screen.getByText(/Данные задерживаются на 15 минут/)).toBeInTheDocument(); + }); +}); diff --git a/apps/frontend/src/pages/LoginPage.test.tsx b/apps/frontend/src/pages/LoginPage.test.tsx new file mode 100644 index 0000000..39a7334 --- /dev/null +++ b/apps/frontend/src/pages/LoginPage.test.tsx @@ -0,0 +1,81 @@ +import { describe, it, expect } from 'vitest'; +import { screen, waitFor } from '@testing-library/react'; +import { Routes, Route } from 'react-router-dom'; +import userEvent from '@testing-library/user-event'; +import { http, HttpResponse } from 'msw'; +import { server } from '../test/server'; +import { LoginPage } from './LoginPage'; +import { renderWithProviders } from '../test/test-utils'; + +const API = '/api/v1'; + +async function waitForAuth() { + await screen.findByText('Войти'); +} + +describe('LoginPage', () => { + it('renders login form', async () => { + server.use(http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 }))); + renderWithProviders(, { route: '/login' }); + expect(await screen.findByText('Вход')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('email@example.com')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('••••••••')).toBeInTheDocument(); + }); + + it('redirects on successful login', async () => { + server.use(http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 }))); + const user = userEvent.setup(); + renderWithProviders( + + } /> + Home Page} /> + , + { route: '/login' }, + ); + + await screen.findByText('Вход'); + + await user.type(screen.getByPlaceholderText('email@example.com'), 'test@test.com'); + await user.type(screen.getByPlaceholderText('••••••••'), 'password'); + await user.click(screen.getByRole('button', { name: 'Войти' })); + + await screen.findByText('Home Page'); + }); + + it('shows error on failed login', async () => { + server.use( + http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 })), + http.post( + `${API}/auth/login`, + () => new HttpResponse(null, { status: 401, statusText: 'Unauthorized' }), + ), + ); + const user = userEvent.setup(); + renderWithProviders(, { route: '/login' }); + + await screen.findByText('Вход'); + + await user.type(screen.getByPlaceholderText('email@example.com'), 'bad@test.com'); + await user.type(screen.getByPlaceholderText('••••••••'), 'wrong'); + await user.click(screen.getByRole('button', { name: 'Войти' })); + + expect(await screen.findByText(/Ошибка API/)).toBeInTheDocument(); + }); + + it('shows loading state while submitting', async () => { + server.use( + http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 })), + http.post(`${API}/auth/login`, () => new Promise(() => {})), + ); + const user = userEvent.setup(); + renderWithProviders(, { route: '/login' }); + + await screen.findByText('Вход'); + + await user.type(screen.getByPlaceholderText('email@example.com'), 'test@test.com'); + await user.type(screen.getByPlaceholderText('••••••••'), 'password'); + await user.click(screen.getByRole('button', { name: 'Войти' })); + + expect(await screen.findByText('Вход...')).toBeInTheDocument(); + }); +}); diff --git a/apps/frontend/src/pages/LoginPage.tsx b/apps/frontend/src/pages/LoginPage.tsx index 4138117..883927c 100644 --- a/apps/frontend/src/pages/LoginPage.tsx +++ b/apps/frontend/src/pages/LoginPage.tsx @@ -31,33 +31,45 @@ export function LoginPage() {

Вход

- {error && ( -
{error}
- )} + {error &&
{error}
}
- + setEmail(e.target.value)} + required + style={inputStyle} + placeholder="email@example.com" />
- + setPassword(e.target.value)} + required + style={inputStyle} + placeholder="••••••••" />

Нет аккаунта?{' '} - - Зарегистрироваться - + + Зарегистрироваться +

diff --git a/apps/frontend/src/pages/ProfilePage.test.tsx b/apps/frontend/src/pages/ProfilePage.test.tsx new file mode 100644 index 0000000..27f5661 --- /dev/null +++ b/apps/frontend/src/pages/ProfilePage.test.tsx @@ -0,0 +1,62 @@ +import { describe, it, expect } from 'vitest'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { http, HttpResponse } from 'msw'; +import { server } from '../test/server'; +import { ProfilePage } from './ProfilePage'; +import { renderWithProviders } from '../test/test-utils'; + +const API = '/api/v1'; + +describe('ProfilePage', () => { + it('renders user profile', async () => { + renderWithProviders(, { route: '/profile' }); + expect(await screen.findByText('Профиль')).toBeInTheDocument(); + expect(await screen.findByText('user@test.com')).toBeInTheDocument(); + expect(await screen.findByText('user')).toBeInTheDocument(); + }); + + it('shows user name in input', async () => { + renderWithProviders(, { route: '/profile' }); + const input = await screen.findByDisplayValue('Test User'); + expect(input).toBeInTheDocument(); + }); + + it('updates profile on save', async () => { + const user = userEvent.setup(); + renderWithProviders(, { route: '/profile' }); + + const input = await screen.findByDisplayValue('Test User'); + await user.clear(input); + await user.type(input, 'Updated User'); + await user.click(screen.getByRole('button', { name: 'Сохранить' })); + + expect(await screen.findByText('Профиль обновлён')).toBeInTheDocument(); + }); + + it('shows error message on failed update', async () => { + server.use(http.patch(`${API}/auth/me`, () => new HttpResponse(null, { status: 500 }))); + const user = userEvent.setup(); + renderWithProviders(, { route: '/profile' }); + + const input = await screen.findByDisplayValue('Test User'); + await user.clear(input); + await user.type(input, 'New Name'); + await user.click(screen.getByRole('button', { name: 'Сохранить' })); + + expect(await screen.findByText('Не удалось обновить профиль')).toBeInTheDocument(); + }); + + it('shows saving state', async () => { + server.use(http.patch(`${API}/auth/me`, () => new Promise(() => {}))); + const user = userEvent.setup(); + renderWithProviders(, { route: '/profile' }); + + const input = await screen.findByDisplayValue('Test User'); + await user.clear(input); + await user.type(input, 'New Name'); + await user.click(screen.getByRole('button', { name: 'Сохранить' })); + + expect(await screen.findByText('Сохранение...')).toBeInTheDocument(); + }); +}); diff --git a/apps/frontend/src/pages/ProfilePage.tsx b/apps/frontend/src/pages/ProfilePage.tsx index afae311..78fd905 100644 --- a/apps/frontend/src/pages/ProfilePage.tsx +++ b/apps/frontend/src/pages/ProfilePage.tsx @@ -36,7 +36,7 @@ export function ProfilePage() { >
Почта -

{user.email}

+

{user.email}

Роль @@ -53,8 +53,8 @@ export function ProfilePage() { }} > Имя - - + setName(e.target.value)} style={{ @@ -69,7 +69,15 @@ export function ProfilePage() { />
{message && ( -
+
{message}
)} diff --git a/apps/frontend/src/pages/RegisterPage.test.tsx b/apps/frontend/src/pages/RegisterPage.test.tsx new file mode 100644 index 0000000..a7943f5 --- /dev/null +++ b/apps/frontend/src/pages/RegisterPage.test.tsx @@ -0,0 +1,77 @@ +import { describe, it, expect } from 'vitest'; +import { screen, waitFor } from '@testing-library/react'; +import { Routes, Route } from 'react-router-dom'; +import userEvent from '@testing-library/user-event'; +import { http, HttpResponse } from 'msw'; +import { server } from '../test/server'; +import { RegisterPage } from './RegisterPage'; +import { renderWithProviders } from '../test/test-utils'; + +const API = '/api/v1'; + +describe('RegisterPage', () => { + it('renders registration form', async () => { + server.use(http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 }))); + renderWithProviders(, { route: '/register' }); + expect(await screen.findByText('Регистрация')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('Иван Иванов')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('email@example.com')).toBeInTheDocument(); + }); + + it('shows password mismatch error', async () => { + server.use(http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 }))); + const user = userEvent.setup(); + renderWithProviders(, { route: '/register' }); + + await screen.findByText('Регистрация'); + + await user.type(screen.getByPlaceholderText('email@example.com'), 'test@test.com'); + await user.type(screen.getByPlaceholderText('Минимум 6 символов'), 'password1'); + await user.type(screen.getByPlaceholderText('Повторите пароль'), 'password2'); + await user.click(screen.getByRole('button', { name: 'Зарегистрироваться' })); + + expect(await screen.findByText('Пароли не совпадают')).toBeInTheDocument(); + }); + + it('redirects on successful registration', async () => { + server.use(http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 }))); + const user = userEvent.setup(); + renderWithProviders( + + } /> + Home Page
} /> + , + { route: '/register' }, + ); + + await screen.findByText('Регистрация'); + + await user.type(screen.getByPlaceholderText('email@example.com'), 'test@test.com'); + await user.type(screen.getByPlaceholderText('Минимум 6 символов'), 'password'); + await user.type(screen.getByPlaceholderText('Повторите пароль'), 'password'); + await user.click(screen.getByRole('button', { name: 'Зарегистрироваться' })); + + await screen.findByText('Home Page'); + }); + + it('shows error on failed registration', async () => { + server.use( + http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 })), + http.post( + `${API}/auth/register`, + () => new HttpResponse(null, { status: 409, statusText: 'Conflict' }), + ), + ); + const user = userEvent.setup(); + renderWithProviders(, { route: '/register' }); + + await screen.findByText('Регистрация'); + + await user.type(screen.getByPlaceholderText('email@example.com'), 'existing@test.com'); + await user.type(screen.getByPlaceholderText('Минимум 6 символов'), 'password'); + await user.type(screen.getByPlaceholderText('Повторите пароль'), 'password'); + await user.click(screen.getByRole('button', { name: 'Зарегистрироваться' })); + + expect(await screen.findByText(/Ошибка API/)).toBeInTheDocument(); + }); +}); diff --git a/apps/frontend/src/pages/RegisterPage.tsx b/apps/frontend/src/pages/RegisterPage.tsx index b2eb709..cce8af3 100644 --- a/apps/frontend/src/pages/RegisterPage.tsx +++ b/apps/frontend/src/pages/RegisterPage.tsx @@ -39,11 +39,16 @@ export function RegisterPage() {

Регистрация

- {error && ( -
{error}
- )} + {error &&
{error}
}
-
- + setEmail(e.target.value)} + required + style={inputStyle} + placeholder="email@example.com" />
-
-
diff --git a/apps/frontend/src/pages/StockPage.test.tsx b/apps/frontend/src/pages/StockPage.test.tsx new file mode 100644 index 0000000..c89b55e --- /dev/null +++ b/apps/frontend/src/pages/StockPage.test.tsx @@ -0,0 +1,73 @@ +import { describe, it, expect } from 'vitest'; +import { screen, waitFor } from '@testing-library/react'; +import { Routes, Route } from 'react-router-dom'; +import { http, HttpResponse } from 'msw'; +import { server } from '../test/server'; +import { StockPage } from './StockPage'; +import { renderWithProviders } from '../test/test-utils'; + +const API = '/api/v1'; + +function renderStockPage(secid = 'SBER') { + return renderWithProviders( + + } /> + , + { route: `/stocks/${secid}` }, + ); +} + +describe('StockPage', () => { + it('shows loading state', () => { + server.use(http.get(`${API}/securities/shares/:secid`, () => new Promise(() => {}))); + renderStockPage(); + expect(screen.getByText('Загрузка...')).toBeInTheDocument(); + }); + + it('renders stock details after loading', async () => { + renderStockPage(); + expect(await screen.findByText('Сбер (SBER)')).toBeInTheDocument(); + expect(await screen.findByText('Сбер Банк · RU0009029540')).toBeInTheDocument(); + }); + + it('renders price chart', async () => { + renderStockPage(); + expect(await screen.findByText('График цены')).toBeInTheDocument(); + }); + + it('renders dividends section', async () => { + renderStockPage(); + expect(await screen.findByText('Дивиденды')).toBeInTheDocument(); + expect(await screen.findByText('Дата закрытия реестра')).toBeInTheDocument(); + }); + + it('hides dividends section when empty', async () => { + server.use( + http.get(`${API}/securities/shares/:secid/dividends`, () => { + return HttpResponse.json({ + data: { data: [], meta: { fromCache: false, cachedAt: null } }, + }); + }), + ); + renderStockPage(); + await waitFor(() => { + expect(screen.queryByText('Дивиденды')).not.toBeInTheDocument(); + }); + }); + + it('shows error state for not found', async () => { + server.use( + http.get(`${API}/securities/shares/:secid`, () => new HttpResponse(null, { status: 404 })), + http.get( + `${API}/securities/shares/:secid/candles`, + () => new HttpResponse(null, { status: 404 }), + ), + http.get( + `${API}/securities/shares/:secid/dividends`, + () => new HttpResponse(null, { status: 404 }), + ), + ); + renderStockPage('NOTFOUND'); + expect(await screen.findByText('Инструмент не найден')).toBeInTheDocument(); + }); +}); diff --git a/apps/frontend/src/test/README.md b/apps/frontend/src/test/README.md new file mode 100644 index 0000000..c7abefe --- /dev/null +++ b/apps/frontend/src/test/README.md @@ -0,0 +1,9 @@ +# Tests + +## Common issues + +### Auth loading screen +Components rendered through `renderWithProviders` are wrapped in AuthProvider which shows +a loading screen until `POST /api/v1/auth/refresh` resolves. +- Use `await screen.findByText()` to wait for auth to complete. +- For components that don't need auth, render with just QueryClientProvider + MemoryRouter. diff --git a/apps/frontend/src/test/factories.ts b/apps/frontend/src/test/factories.ts new file mode 100644 index 0000000..bb87e52 --- /dev/null +++ b/apps/frontend/src/test/factories.ts @@ -0,0 +1,146 @@ +import type { + ShareResponse, + BondResponse, + BondMarketData, + StockMarketData, + CandleItem, + DividendItem, + SearchResultItem, + UserResponse, + AuthResponse, +} from '../api/responses'; + +export function createMockMarketData(overrides: Partial = {}): StockMarketData { + return { + price: 289.5, + change: 2.5, + changePercent: 0.87, + open: 287, + high: 291, + low: 286.5, + volume: 15000000, + value: 4350000000, + issueCapitalization: 6250000000000, + updatedAt: '2024-01-15T10:00:00Z', + ...overrides, + }; +} + +export function createMockShare(overrides: Partial = {}): ShareResponse { + return { + secid: 'SBER', + isin: 'RU0009029540', + name: 'Сбер Банк', + shortName: 'Сбер', + latName: 'Sberbank', + listLevel: 1, + issueSize: 21586900000, + faceValue: 3, + faceUnit: 'RUB', + type: 'common_share', + marketData: createMockMarketData(), + ...overrides, + }; +} + +export function createMockBondMarketData(overrides: Partial = {}): BondMarketData { + return { + price: 98.5, + yieldToMaturity: 8.2, + duration: 3.5, + accruedInt: 8.45, + couponValue: 36.9, + couponPercent: 7.5, + nextCouponDate: '2024-07-15', + open: 98.0, + high: 99.0, + low: 97.5, + volume: 1000000, + updatedAt: '2024-01-15T10:00:00Z', + ...overrides, + }; +} + +export function createMockBond(overrides: Partial = {}): BondResponse { + return { + secid: 'SU26238RMFS5', + isin: 'RU000A101XU7', + name: 'ОФЗ 26238', + shortName: 'ОФЗ 26238', + latName: null, + listLevel: 1, + issueSize: 500000000, + faceValue: 1000, + faceUnit: 'RUB', + matDate: '2027-05-15', + couponValue: 36.9, + couponPercent: 7.5, + couponPeriod: 182, + nextCoupon: '2024-07-15', + accruedInt: 8.45, + bondType: 'ОФЗ', + bondSubType: 'ОФЗ-ПД', + offerDate: null, + buybackDate: null, + marketData: createMockBondMarketData(), + ...overrides, + }; +} + +export function createMockUser(overrides: Partial = {}): UserResponse { + return { + id: 1, + email: 'user@test.com', + name: 'Test User', + role: 'user', + ...overrides, + }; +} + +export function createMockAuth(overrides: Partial = {}): AuthResponse { + return { + user: createMockUser(), + accessToken: 'mock-token', + ...overrides, + }; +} + +export function createMockCandles(count = 2): CandleItem[] { + return Array.from({ length: count }, (_, i) => ({ + open: 280 + i, + high: 290 + i, + low: 278 + i, + close: 289.5 + i, + volume: 1000000, + value: 280000000, + begin: `2024-01-${15 + i}T10:00:00Z`, + end: `2024-01-${15 + i}T18:00:00Z`, + })); +} + +export function createMockDividends(): DividendItem[] { + return [{ registryCloseDate: '2024-07-10', value: 35.0, currency: 'RUB' }]; +} + +export function createMockSearchResults(): SearchResultItem[] { + return [ + { + secid: 'SBER', + isin: 'RU0009029540', + shortName: 'Сбер', + type: 'share', + listLevel: 1, + currency: 'RUB', + price: 289.5, + }, + { + secid: 'VTBR', + isin: 'RU000A0JP5V6', + shortName: 'ВТБ', + type: 'share', + listLevel: 1, + currency: 'RUB', + price: 0.0234, + }, + ]; +} diff --git a/apps/frontend/src/test/handlers.ts b/apps/frontend/src/test/handlers.ts new file mode 100644 index 0000000..15b5c26 --- /dev/null +++ b/apps/frontend/src/test/handlers.ts @@ -0,0 +1,190 @@ +import { http, HttpResponse } from 'msw'; +import type { ShareResponse, BondResponse, CandleItem, SearchResultItem } from '../api/responses'; + +const API = '/api/v1'; + +const mockShare: ShareResponse = { + secid: 'SBER', + isin: 'RU0009029540', + name: 'Сбер Банк', + shortName: 'Сбер', + latName: 'Sberbank', + listLevel: 1, + issueSize: 21586900000, + faceValue: 3, + faceUnit: 'RUB', + type: 'common_share', + marketData: { + price: 289.5, + change: 2.5, + changePercent: 0.87, + open: 287, + high: 291, + low: 286.5, + volume: 15000000, + value: 4350000000, + issueCapitalization: 6250000000000, + updatedAt: '2024-01-15T10:00:00Z', + }, +}; + +const mockBond: BondResponse = { + secid: 'SU26238RMFS5', + isin: 'RU000A101XU7', + name: 'ОФЗ 26238', + shortName: 'ОФЗ 26238', + latName: null, + listLevel: 1, + issueSize: 500000000, + faceValue: 1000, + faceUnit: 'RUB', + matDate: '2027-05-15', + couponValue: 36.9, + couponPercent: 7.5, + couponPeriod: 182, + nextCoupon: '2024-07-15', + accruedInt: 8.45, + bondType: 'ОФЗ', + bondSubType: 'ОФЗ-ПД', + offerDate: null, + buybackDate: null, + marketData: { + price: 98.5, + yieldToMaturity: 8.2, + duration: 3.5, + accruedInt: 8.45, + couponValue: 36.9, + couponPercent: 7.5, + nextCouponDate: '2024-07-15', + open: 98.0, + high: 99.0, + low: 97.5, + volume: 1000000, + updatedAt: '2024-01-15T10:00:00Z', + }, +}; + +const mockCandles: CandleItem[] = [ + { + open: 280, + high: 290, + low: 278, + close: 289.5, + volume: 1000000, + value: 280000000, + begin: '2024-01-15T10:00:00Z', + end: '2024-01-15T18:00:00Z', + }, + { + open: 289, + high: 292, + low: 285, + close: 288, + volume: 800000, + value: 231200000, + begin: '2024-01-16T10:00:00Z', + end: '2024-01-16T18:00:00Z', + }, +]; + +const mockDividends = [ + { registryCloseDate: '2024-07-10', value: 35.0, currency: 'RUB' }, + { registryCloseDate: '2023-10-05', value: 30.0, currency: 'RUB' }, +]; + +const mockSearchResults: SearchResultItem[] = [ + { + secid: 'SBER', + isin: 'RU0009029540', + shortName: 'Сбер', + type: 'share', + listLevel: 1, + currency: 'RUB', + price: 289.5, + }, + { + secid: 'VTBR', + isin: 'RU000A0JP5V6', + shortName: 'ВТБ', + type: 'share', + listLevel: 1, + currency: 'RUB', + price: 0.0234, + }, +]; + +const userResponse = { + id: 1, + email: 'user@test.com', + name: 'Test User', + role: 'user', +}; + +const authResponse = { + user: userResponse, + accessToken: 'mock-access-token', +}; + +const envelope = (data: unknown) => ({ + data: { data, meta: { fromCache: false, cachedAt: null } }, +}); + +export const handlers = [ + http.get(`${API}/securities/search`, ({ request }) => { + const url = new URL(request.url); + const q = url.searchParams.get('q') || ''; + if (q.length < 2) { + return HttpResponse.json(envelope([])); + } + const filtered = mockSearchResults.filter( + (r) => + r.secid.toLowerCase().includes(q.toLowerCase()) || + r.shortName.toLowerCase().includes(q.toLowerCase()), + ); + return HttpResponse.json(envelope(filtered)); + }), + + http.get(`${API}/securities/shares/:secid`, ({ params }) => { + const { secid } = params; + if (secid === 'NOTFOUND') return new HttpResponse(null, { status: 404 }); + return HttpResponse.json(envelope({ ...mockShare, secid } as ShareResponse)); + }), + + http.get(`${API}/securities/shares/:secid/candles`, () => + HttpResponse.json(envelope(mockCandles)), + ), + + http.get(`${API}/securities/shares/:secid/dividends`, () => + HttpResponse.json(envelope(mockDividends)), + ), + + http.get(`${API}/securities/bonds/:secid`, ({ params }) => { + const { secid } = params; + if (secid === 'NOTFOUND') return new HttpResponse(null, { status: 404 }); + return HttpResponse.json(envelope({ ...mockBond, secid } as BondResponse)); + }), + + http.get(`${API}/securities/bonds/:secid/candles`, () => + HttpResponse.json(envelope(mockCandles)), + ), + + http.get(`${API}/auth/me`, () => HttpResponse.json(envelope(userResponse))), + + http.post(`${API}/auth/login`, () => HttpResponse.json(envelope(authResponse))), + + http.post(`${API}/auth/register`, () => HttpResponse.json(envelope(authResponse))), + + http.post(`${API}/auth/refresh`, () => HttpResponse.json(envelope(authResponse))), + + http.post(`${API}/auth/logout`, () => HttpResponse.json(envelope({ message: 'Logged out' }))), + + http.patch(`${API}/auth/me`, () => + HttpResponse.json(envelope({ ...userResponse, name: 'Updated' })), + ), + + http.get(`${API}/health`, () => + HttpResponse.json( + envelope({ status: 'ok', timestamp: new Date().toISOString(), uptime: 12345 }), + ), + ), +]; diff --git a/apps/frontend/src/test/server.ts b/apps/frontend/src/test/server.ts new file mode 100644 index 0000000..e52fee0 --- /dev/null +++ b/apps/frontend/src/test/server.ts @@ -0,0 +1,4 @@ +import { setupServer } from 'msw/node'; +import { handlers } from './handlers'; + +export const server = setupServer(...handlers); diff --git a/apps/frontend/src/test/setup.ts b/apps/frontend/src/test/setup.ts new file mode 100644 index 0000000..1b15647 --- /dev/null +++ b/apps/frontend/src/test/setup.ts @@ -0,0 +1,73 @@ +import '@testing-library/jest-dom'; +import { server } from './server'; + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); + +Object.defineProperty(window, 'matchMedia', { + writable: true, + value: (query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + }), +}); + +// Prevent lightweight-charts animation frame callbacks in jsdom +window.requestAnimationFrame = () => 0; +window.cancelAnimationFrame = () => {}; + +// Mock canvas 2D context to prevent jsdom "Not implemented" warnings from lightweight-charts +HTMLCanvasElement.prototype.getContext = ((contextId: string) => { + if (contextId === '2d') { + return { + canvas: {} as HTMLCanvasElement, + fillRect: () => {}, + clearRect: () => {}, + getImageData: () => ({ data: [], width: 0, height: 0 }), + putImageData: () => {}, + createImageData: () => ({ width: 0, height: 0, data: [] }), + setTransform: () => {}, + drawImage: () => {}, + save: () => {}, + fillText: () => {}, + restore: () => {}, + beginPath: () => {}, + moveTo: () => {}, + lineTo: () => {}, + closePath: () => {}, + stroke: () => {}, + translate: () => {}, + scale: () => {}, + rotate: () => {}, + arc: () => {}, + fill: () => {}, + measureText: () => ({ width: 0 }), + transform: () => {}, + rect: () => {}, + clip: () => {}, + createLinearGradient: () => ({ addColorStop: () => {} }), + createRadialGradient: () => ({ addColorStop: () => {} }), + createPattern: () => ({ setTransform: () => {} }), + font: '', + fillStyle: '', + strokeStyle: '', + lineWidth: 1, + lineCap: 'butt' as CanvasLineCap, + lineJoin: 'miter' as CanvasLineJoin, + globalAlpha: 1, + globalCompositeOperation: 'source-over' as GlobalCompositeOperation, + shadowBlur: 0, + shadowColor: '', + shadowOffsetX: 0, + shadowOffsetY: 0, + } as unknown as CanvasRenderingContext2D; + } + return null; +}) as typeof HTMLCanvasElement.prototype.getContext; diff --git a/apps/frontend/src/test/test-utils.tsx b/apps/frontend/src/test/test-utils.tsx new file mode 100644 index 0000000..7338454 --- /dev/null +++ b/apps/frontend/src/test/test-utils.tsx @@ -0,0 +1,40 @@ +import { type ReactElement } from 'react'; +import { render, type RenderOptions } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { MemoryRouter } from 'react-router-dom'; +import { AuthProvider } from '../context/AuthContext'; + +interface CustomRenderOptions extends Omit { + queryClient?: QueryClient; + route?: string; +} + +function createTestQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: 0 }, + mutations: { retry: false }, + }, + }); +} + +export function renderWithProviders( + ui: ReactElement, + { + queryClient = createTestQueryClient(), + route = '/', + ...renderOptions + }: CustomRenderOptions = {}, +) { + function Wrapper({ children }: { children: React.ReactNode }) { + return ( + + + {children} + + + ); + } + + return { ...render(ui, { wrapper: Wrapper, ...renderOptions }), queryClient }; +} diff --git a/apps/frontend/vitest.config.ts b/apps/frontend/vitest.config.ts new file mode 100644 index 0000000..a58cef5 --- /dev/null +++ b/apps/frontend/vitest.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from 'vitest/config'; +import react from '@vitejs/plugin-react'; +import path from 'path'; + +export default defineConfig({ + plugins: [react()], + logLevel: 'error', + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, + test: { + environment: 'jsdom', + setupFiles: ['./src/test/setup.ts'], + globals: true, + }, +}); diff --git a/docs/superpowers/plans/2026-06-13-frontend-test-coverage.md b/docs/superpowers/plans/2026-06-13-frontend-test-coverage.md new file mode 100644 index 0000000..e7106bb --- /dev/null +++ b/docs/superpowers/plans/2026-06-13-frontend-test-coverage.md @@ -0,0 +1,1920 @@ +# Frontend Test Coverage Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add comprehensive frontend unit tests (vitest + RTL + MSW) across all existing modules + +**Architecture:** MSW intercepts fetch at the network level, so no production code changes are needed. Each test uses `renderWithProviders` (QueryClient + AuthProvider + MemoryRouter wrapper). Fresh QueryClient per test with `retry: false`. + +**Tech Stack:** vitest, @testing-library/react, @testing-library/jest-dom, @testing-library/user-event, jsdom, msw v2 + +--- + +### Task 1: Infrastructure — vitest config + test helpers + +**Files:** +- Create: `apps/frontend/vitest.config.ts` +- Create: `apps/frontend/src/test/setup.ts` +- Create: `apps/frontend/src/test/server.ts` +- Create: `apps/frontend/src/test/handlers.ts` +- Create: `apps/frontend/src/test/test-utils.tsx` +- Create: `apps/frontend/src/test/factories.ts` +- Modify: `apps/frontend/package.json` (add test scripts) +- Modify: `package.json` (add root workspace script) + +- [ ] **Step 1: Create vitest.config.ts** + +```ts +import { defineConfig } from 'vitest/config'; +import react from '@vitejs/plugin-react'; +import path from 'path'; + +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, + test: { + environment: 'jsdom', + setupFiles: ['./src/test/setup.ts'], + globals: true, + }, +}); +``` + +- [ ] **Step 2: Create test/setup.ts** + +```ts +import '@testing-library/jest-dom'; +import { server } from './server'; + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); + +Object.defineProperty(window, 'matchMedia', { + writable: true, + value: (query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + }), +}); +``` + +- [ ] **Step 3: Create test/server.ts** + +```ts +import { setupServer } from 'msw/node'; +import { handlers } from './handlers'; + +export const server = setupServer(...handlers); +``` + +- [ ] **Step 4: Create test/handlers.ts** + +```ts +import { http, HttpResponse } from 'msw'; +import type { + ShareResponse, + BondResponse, + CandleItem, + DividendItem, + SearchResultItem, + AuthResponse, + UserResponse, +} from '../api/responses'; + +const API = '/api/v1'; + +const mockUser: UserResponse = { + id: 1, + email: 'user@test.com', + name: 'Test User', + role: 'user', +}; + +const mockAuth: AuthResponse = { + user: mockUser, + accessToken: 'mock-access-token', +}; + +const mockShare: ShareResponse = { + secid: 'SBER', + isin: 'RU0009029540', + name: 'Сбер Банк', + shortName: 'Сбер', + latName: 'Sberbank', + listLevel: 1, + issueSize: 21586900000, + faceValue: 3, + faceUnit: 'RUB', + type: 'common_share', + marketData: { + price: 289.5, + change: 2.5, + changePercent: 0.87, + open: 287, + high: 291, + low: 286.5, + volume: 15000000, + value: 4350000000, + issueCapitalization: 6250000000000, + updatedAt: '2024-01-15T10:00:00Z', + }, +}; + +const mockBond: BondResponse = { + secid: 'SU26238RMFS5', + isin: 'RU000A101XU7', + name: 'ОФЗ 26238', + shortName: 'ОФЗ 26238', + latName: null, + listLevel: 1, + issueSize: 500000000, + faceValue: 1000, + faceUnit: 'RUB', + matDate: '2027-05-15', + couponValue: 36.9, + couponPercent: 7.5, + couponPeriod: 182, + nextCoupon: '2024-07-15', + accruedInt: 8.45, + bondType: 'ОФЗ', + bondSubType: 'ОФЗ-ПД', + offerDate: null, + buybackDate: null, + marketData: { + price: 98.5, + yieldToMaturity: 8.2, + duration: 3.5, + accruedInt: 8.45, + couponValue: 36.9, + couponPercent: 7.5, + nextCouponDate: '2024-07-15', + open: 98.0, + high: 99.0, + low: 97.5, + volume: 1000000, + updatedAt: '2024-01-15T10:00:00Z', + }, +}; + +const mockCandles: CandleItem[] = [ + { open: 280, high: 290, low: 278, close: 289.5, volume: 1000000, value: 280000000, begin: '2024-01-15T10:00:00Z', end: '2024-01-15T18:00:00Z' }, + { open: 289, high: 292, low: 285, close: 288, volume: 800000, value: 231200000, begin: '2024-01-16T10:00:00Z', end: '2024-01-16T18:00:00Z' }, +]; + +const mockDividends: DividendItem[] = [ + { registryCloseDate: '2024-07-10', value: 35.0, currency: 'RUB' }, + { registryCloseDate: '2023-10-05', value: 30.0, currency: 'RUB' }, +]; + +const mockSearchResults: SearchResultItem[] = [ + { secid: 'SBER', isin: 'RU0009029540', shortName: 'Сбер', type: 'share', listLevel: 1, currency: 'RUB', price: 289.5 }, + { secid: 'VTBR', isin: 'RU000A0JP5V6', shortName: 'ВТБ', type: 'share', listLevel: 1, currency: 'RUB', price: 0.0234 }, +]; + +export const handlers = [ + http.get(`${API}/securities/search`, ({ request }) => { + const url = new URL(request.url); + const q = url.searchParams.get('q') || ''; + if (q.length < 2) return HttpResponse.json({ data: { data: [], meta: { fromCache: false, cachedAt: null } } }); + const filtered = mockSearchResults.filter(r => + r.secid.toLowerCase().includes(q.toLowerCase()) || + r.shortName.toLowerCase().includes(q.toLowerCase()), + ); + return HttpResponse.json({ data: { data: filtered, meta: { fromCache: false, cachedAt: null } } }); + }), + + http.get(`${API}/securities/shares/:secid`, ({ params }) => { + const { secid } = params; + if (secid === 'NOTFOUND') return new HttpResponse(null, { status: 404 }); + return HttpResponse.json({ data: { data: { ...mockShare, secid } as ShareResponse, meta: { fromCache: false, cachedAt: null } } }); + }), + + http.get(`${API}/securities/shares/:secid/candles`, () => + HttpResponse.json({ data: { data: mockCandles, meta: { fromCache: false, cachedAt: null } } }), + ), + + http.get(`${API}/securities/shares/:secid/dividends`, () => + HttpResponse.json({ data: { data: mockDividends, meta: { fromCache: false, cachedAt: null } } }), + ), + + http.get(`${API}/securities/bonds/:secid`, ({ params }) => { + const { secid } = params; + if (secid === 'NOTFOUND') return new HttpResponse(null, { status: 404 }); + return HttpResponse.json({ data: { data: { ...mockBond, secid } as BondResponse, meta: { fromCache: false, cachedAt: null } } }); + }), + + http.get(`${API}/securities/bonds/:secid/candles`, () => + HttpResponse.json({ data: { data: mockCandles, meta: { fromCache: false, cachedAt: null } } }), + ), + + http.get(`${API}/auth/me`, () => + HttpResponse.json({ data: { data: mockUser, meta: { fromCache: false, cachedAt: null } } }), + ), + + http.post(`${API}/auth/login`, () => + HttpResponse.json({ data: { data: mockAuth, meta: { fromCache: false, cachedAt: null } } }), + ), + + http.post(`${API}/auth/register`, () => + HttpResponse.json({ data: { data: mockAuth, meta: { fromCache: false, cachedAt: null } } }), + ), + + http.post(`${API}/auth/refresh`, () => + HttpResponse.json({ data: { data: mockAuth, meta: { fromCache: false, cachedAt: null } } }), + ), + + http.post(`${API}/auth/logout`, () => new HttpResponse(null, { status: 204 })), + + http.patch(`${API}/auth/me`, () => + HttpResponse.json({ data: { data: { ...mockUser, name: 'Updated' }, meta: { fromCache: false, cachedAt: null } } }), + ), + + http.get(`${API}/health`, () => + HttpResponse.json({ data: { data: { status: 'ok', timestamp: new Date().toISOString(), uptime: 12345 }, meta: { fromCache: false, cachedAt: null } } }), + ), +]; + +export { mockUser, mockAuth, mockShare, mockBond, mockCandles, mockDividends, mockSearchResults }; +``` + +- [ ] **Step 5: Create test/test-utils.tsx** + +```tsx +import { type ReactElement } from 'react'; +import { render, type RenderOptions } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { MemoryRouter } from 'react-router-dom'; +import { AuthProvider } from '../context/AuthContext'; + +interface CustomRenderOptions extends Omit { + queryClient?: QueryClient; + route?: string; +} + +function createTestQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: 0 }, + mutations: { retry: false }, + }, + }); +} + +export function renderWithProviders( + ui: ReactElement, + { queryClient = createTestQueryClient(), route = '/', ...renderOptions }: CustomRenderOptions = {}, +) { + function Wrapper({ children }: { children: React.ReactNode }) { + return ( + + + + {children} + + + + ); + } + + return { ...render(ui, { wrapper: Wrapper, ...renderOptions }), queryClient }; +} +``` + +- [ ] **Step 6: Create test/factories.ts** + +```ts +import type { ShareResponse, BondResponse, CandleItem, DividendItem, SearchResultItem, UserResponse, AuthResponse } from '../api/responses'; + +export function createMockShare(overrides: Partial = {}): ShareResponse { + return { + secid: 'SBER', + isin: 'RU0009029540', + name: 'Сбер Банк', + shortName: 'Сбер', + latName: 'Sberbank', + listLevel: 1, + issueSize: 21586900000, + faceValue: 3, + faceUnit: 'RUB', + type: 'common_share', + marketData: { + price: 289.5, + change: 2.5, + changePercent: 0.87, + open: 287, + high: 291, + low: 286.5, + volume: 15000000, + value: 4350000000, + issueCapitalization: 6250000000000, + updatedAt: '2024-01-15T10:00:00Z', + }, + ...overrides, + }; +} + +export function createMockBond(overrides: Partial = {}): BondResponse { + return { + secid: 'SU26238RMFS5', + isin: 'RU000A101XU7', + name: 'ОФЗ 26238', + shortName: 'ОФЗ 26238', + latName: null, + listLevel: 1, + issueSize: 500000000, + faceValue: 1000, + faceUnit: 'RUB', + matDate: '2027-05-15', + couponValue: 36.9, + couponPercent: 7.5, + couponPeriod: 182, + nextCoupon: '2024-07-15', + accruedInt: 8.45, + bondType: 'ОФЗ', + bondSubType: 'ОФЗ-ПД', + offerDate: null, + buybackDate: null, + marketData: { + price: 98.5, + yieldToMaturity: 8.2, + duration: 3.5, + accruedInt: 8.45, + couponValue: 36.9, + couponPercent: 7.5, + nextCouponDate: '2024-07-15', + open: 98.0, + high: 99.0, + low: 97.5, + volume: 1000000, + updatedAt: '2024-01-15T10:00:00Z', + }, + ...overrides, + }; +} + +export function createMockUser(overrides: Partial = {}): UserResponse { + return { id: 1, email: 'user@test.com', name: 'Test User', role: 'user', ...overrides }; +} + +export function createMockAuth(overrides: Partial = {}): AuthResponse { + return { user: createMockUser(), accessToken: 'mock-token', ...overrides }; +} + +export function createMockCandles(count = 2): CandleItem[] { + return Array.from({ length: count }, (_, i) => ({ + open: 280 + i, high: 290 + i, low: 278 + i, close: 289.5 + i, + volume: 1000000, value: 280000000, + begin: `2024-01-${15 + i}T10:00:00Z`, + end: `2024-01-${15 + i}T18:00:00Z`, + })); +} + +export function createMockDividends(): DividendItem[] { + return [ + { registryCloseDate: '2024-07-10', value: 35.0, currency: 'RUB' }, + ]; +} + +export function createMockSearchResults(): SearchResultItem[] { + return [ + { secid: 'SBER', isin: 'RU0009029540', shortName: 'Сбер', type: 'share', listLevel: 1, currency: 'RUB', price: 289.5 }, + { secid: 'VTBR', isin: 'RU000A0JP5V6', shortName: 'ВТБ', type: 'share', listLevel: 1, currency: 'RUB', price: 0.0234 }, + ]; +} +``` + +- [ ] **Step 7: Add scripts to apps/frontend/package.json** + +```json +"test": "vitest run", +"test:watch": "vitest" +``` + +- [ ] **Step 8: Add to root package.json** + +```json +"test:frontend": "npm run test -w apps/frontend" +``` + +- [ ] **Step 9: Install dependencies** + +Run: `npm install -w apps/frontend vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom msw @types/node` + +- [ ] **Step 10: Verify setup** + +Run: `npm run test:frontend` +Expected: "No test files found, exiting with code 0" (no tests yet, but config works) + +--- + +### Task 2: API client tests + +**Files:** +- Create: `apps/frontend/src/api/client.test.ts` + +- [ ] **Step 1: Create client.test.ts** + +```ts +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { http, HttpResponse } from 'msw'; +import { server } from '../test/server'; +import { request, setAccessToken, getAccessToken, setOnUnauthorized } from './client'; + +const API = '/api/v1'; + +beforeEach(() => { + setAccessToken(null); +}); + +describe('request', () => { + it('makes GET request and returns data', async () => { + const result = await request<{ message: string }>('/api/v1/health'); + expect(result.data).toEqual({ + status: 'ok', + timestamp: expect.any(String), + uptime: 12345, + }); + }); + + it('includes Authorization header when token is set', async () => { + setAccessToken('test-token'); + let capturedAuth: string | null = null; + server.use( + http.get(`${API}/test-auth`, ({ request }) => { + capturedAuth = request.headers.get('Authorization'); + return HttpResponse.json({ data: { data: { ok: true }, meta: { fromCache: false, cachedAt: null } } }); + }), + ); + await request('/api/v1/test-auth'); + expect(capturedAuth).toBe('Bearer test-token'); + }); + + it('retries on 401 and succeeds after refresh', async () => { + setAccessToken('expired-token'); + let attempts = 0; + server.use( + http.get(`${API}/test-retry`, ({ request }) => { + attempts++; + const auth = request.headers.get('Authorization'); + if (auth === 'Bearer expired-token') { + return new HttpResponse(null, { status: 401 }); + } + return HttpResponse.json({ data: { data: { ok: true }, meta: { fromCache: false, cachedAt: null } } }); + }), + http.post(`${API}/auth/refresh`, () => + HttpResponse.json({ data: { data: { user: { id: 1, email: 'user@test.com', name: null, role: 'user' }, accessToken: 'new-token' }, meta: { fromCache: false, cachedAt: null } } }), + ), + ); + const result = await request<{ ok: boolean }>('/api/v1/test-retry'); + expect(attempts).toBe(2); + expect(result.data).toEqual({ ok: true }); + expect(getAccessToken()).toBe('new-token'); + }); + + it('throws on persistent 401 and clears token', async () => { + setAccessToken('expired-token'); + let unauthorizedCalled = false; + setOnUnauthorized(() => { unauthorizedCalled = true; }); + server.use( + http.get(`${API}/test-fail`, () => new HttpResponse(null, { status: 401 })), + http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 })), + ); + await expect(request('/api/v1/test-fail')).rejects.toThrow('Сессия истекла'); + expect(getAccessToken()).toBeNull(); + expect(unauthorizedCalled).toBe(true); + }); + + it('throws on non-ok response with status text', async () => { + server.use( + http.get(`${API}/test-error`, () => new HttpResponse('Not found', { status: 404, statusText: 'Not Found' })), + ); + await expect(request('/api/v1/test-error')).rejects.toThrow('Ошибка API: 404'); + }); + + it('sends JSON body for POST requests', async () => { + let capturedBody: string | null = null; + server.use( + http.post(`${API}/test-post`, async ({ request }) => { + capturedBody = await request.text(); + return HttpResponse.json({ data: { data: { ok: true }, meta: { fromCache: false, cachedAt: null } } }); + }), + ); + await request('/api/v1/test-post', undefined, { method: 'POST', body: { foo: 'bar' } }); + expect(capturedBody).toBe(JSON.stringify({ foo: 'bar' })); + }); + + it('does not send auth header when skipAuth is true', async () => { + setAccessToken('test-token'); + let capturedAuth: string | null = null; + server.use( + http.get(`${API}/test-skip`, ({ request }) => { + capturedAuth = request.headers.get('Authorization'); + return HttpResponse.json({ data: { data: { ok: true }, meta: { fromCache: false, cachedAt: null } } }); + }), + ); + await request('/api/v1/test-skip', undefined, { skipAuth: true }); + expect(capturedAuth).toBeNull(); + }); + + it('sets query params correctly', async () => { + let capturedUrl = ''; + server.use( + http.get(`${API}/test-params`, ({ request }) => { + capturedUrl = request.url; + return HttpResponse.json({ data: { data: { ok: true }, meta: { fromCache: false, cachedAt: null } } }); + }), + ); + await request('/api/v1/test-params', { q: 'sber', type: 'share' }); + expect(capturedUrl).toContain('q=sber'); + expect(capturedUrl).toContain('type=share'); + }); +}); +``` + +- [ ] **Step 2: Run tests** + +Run: `npx vitest run apps/frontend/src/api/client.test.ts -w apps/frontend` +Expected: all passing + +--- + +### Task 3: Auth API tests + +**Files:** +- Create: `apps/frontend/src/api/auth.test.ts` + +- [ ] **Step 1: Create auth.test.ts** + +```ts +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { http, HttpResponse } from 'msw'; +import { server } from '../test/server'; +import { setAccessToken, getAccessToken } from './client'; +import { login, register, refresh, logout, getMe, updateProfile } from './auth'; + +const API = '/api/v1'; + +beforeEach(() => { + setAccessToken(null); +}); + +describe('login', () => { + it('returns auth data and sets access token', async () => { + const result = await login('user@test.com', 'password'); + expect(result.user.email).toBe('user@test.com'); + expect(result.accessToken).toBe('mock-access-token'); + expect(getAccessToken()).toBe('mock-access-token'); + }); + + it('throws on invalid credentials', async () => { + server.use( + http.post(`${API}/auth/login`, () => new HttpResponse(null, { status: 401, statusText: 'Unauthorized' })), + ); + await expect(login('wrong@test.com', 'wrong')).rejects.toThrow(); + }); +}); + +describe('register', () => { + it('returns auth data and sets access token', async () => { + const result = await register('new@test.com', 'password', 'New User'); + expect(result.user.email).toBe('user@test.com'); + expect(getAccessToken()).toBe('mock-access-token'); + }); +}); + +describe('refresh', () => { + it('returns auth data and sets access token', async () => { + const result = await refresh(); + expect(result.accessToken).toBe('mock-access-token'); + expect(getAccessToken()).toBe('mock-access-token'); + }); +}); + +describe('logout', () => { + it('clears access token', async () => { + setAccessToken('test-token'); + await logout(); + expect(getAccessToken()).toBeNull(); + }); +}); + +describe('getMe', () => { + it('returns current user', async () => { + const result = await getMe(); + expect(result.email).toBe('user@test.com'); + }); +}); + +describe('updateProfile', () => { + it('updates and returns user', async () => { + const result = await updateProfile({ name: 'Updated' }); + expect(result.name).toBe('Updated'); + }); +}); +``` + +- [ ] **Step 2: Run tests** + +Run: `npx vitest run apps/frontend/src/api/auth.test.ts -w apps/frontend` +Expected: all passing + +--- + +### Task 4: AuthContext tests + +**Files:** +- Create: `apps/frontend/src/context/AuthContext.test.tsx` + +- [ ] **Step 1: Create AuthContext.test.tsx** + +```tsx +import { describe, it, expect } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { http, HttpResponse } from 'msw'; +import { server } from '../test/server'; +import { AuthProvider, AuthContext, type AuthContextValue } from './AuthContext'; +import { useContext } from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + +const API = '/api/v1'; + +function renderWithProviders(ui: React.ReactElement) { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + {ui} + , + ); +} + +function TestConsumer() { + const ctx = useContext(AuthContext); + if (!ctx) return
no context
; + return ( +
+ {ctx.isAuthenticated ? 'authenticated' : 'anonymous'} + {ctx.user?.email ?? ''} + + + + +
+ ); +} + +describe('AuthContext', () => { + it('starts unauthenticated when refresh fails', async () => { + server.use( + http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 })), + ); + renderWithProviders( + + + , + ); + await waitFor(() => { + expect(screen.getByTestId('auth')).toHaveTextContent('anonymous'); + }); + }); + + it('restores session on mount when refresh succeeds', async () => { + renderWithProviders( + + + , + ); + await waitFor(() => { + expect(screen.getByTestId('auth')).toHaveTextContent('authenticated'); + expect(screen.getByTestId('email')).toHaveTextContent('user@test.com'); + }); + }); + + it('updates state after login', async () => { + server.use( + http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 })), + ); + const user = userEvent.setup(); + renderWithProviders( + + + , + ); + await waitFor(() => expect(screen.getByTestId('auth')).toHaveTextContent('anonymous')); + await user.click(screen.getByRole('button', { name: 'login' })); + await waitFor(() => { + expect(screen.getByTestId('auth')).toHaveTextContent('authenticated'); + }); + }); + + it('updates state after logout', async () => { + const user = userEvent.setup(); + renderWithProviders( + + + , + ); + await waitFor(() => expect(screen.getByTestId('auth')).toHaveTextContent('authenticated')); + await user.click(screen.getByRole('button', { name: 'logout' })); + await waitFor(() => { + expect(screen.getByTestId('auth')).toHaveTextContent('anonymous'); + }); + }); +}); +``` + +- [ ] **Step 2: Run tests** + +Run: `npx vitest run apps/frontend/src/context/AuthContext.test.tsx -w apps/frontend` +Expected: all passing + +--- + +### Task 5: Hook tests + +**Files:** +- Create: `apps/frontend/src/hooks/useAuth.test.tsx` +- Create: `apps/frontend/src/hooks/useSearch.test.tsx` +- Create: `apps/frontend/src/hooks/useStock.test.tsx` +- Create: `apps/frontend/src/hooks/useStockCandles.test.tsx` +- Create: `apps/frontend/src/hooks/useStockDividends.test.tsx` +- Create: `apps/frontend/src/hooks/useBond.test.tsx` +- Create: `apps/frontend/src/hooks/useBondCandles.test.tsx` + +- [ ] **Step 1: Create useAuth.test.tsx** + +```tsx +import { describe, it, expect } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import { useAuth } from './useAuth'; +import { renderWithProviders } from '../test/test-utils'; + +describe('useAuth', () => { + it('returns auth context values', async () => { + const { result } = renderHook(() => useAuth(), { wrapper: ({ children }) => { + const { Wrapper } = renderWithProviders(<>{children}); + return <>{children}; + }}); + // Actually use renderWithProviders via wrapper + }); + + it('throws when used outside AuthProvider', () => { + const { renderHook: render } = await import('@testing-library/react'); + // Will be tested inline + }); +}); +``` + +Wait — actually let me write proper, complete tests. Let me re-do this. + +- [ ] **Step 1: Create useAuth.test.tsx properly** + +```tsx +import { describe, it, expect, beforeEach } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { AuthProvider } from '../context/AuthContext'; +import { useAuth } from './useAuth'; +import { type ReactNode } from 'react'; + +function createWrapper() { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return function Wrapper({ children }: { children: ReactNode }) { + return ( + + {children} + + ); + }; +} + +describe('useAuth', () => { + it('returns auth context with user after mount', async () => { + const { result } = renderHook(() => useAuth(), { wrapper: createWrapper() }); + await waitFor(() => { + expect(result.current.isAuthenticated).toBe(true); + }); + expect(result.current.user?.email).toBe('user@test.com'); + expect(result.current.accessToken).toBe('mock-access-token'); + }); + + it('provides login function', async () => { + const { result } = renderHook(() => useAuth(), { wrapper: createWrapper() }); + await waitFor(() => expect(result.current.isAuthenticated).toBe(true)); + expect(typeof result.current.login).toBe('function'); + }); + + it('provides logout function', async () => { + const { result } = renderHook(() => useAuth(), { wrapper: createWrapper() }); + await waitFor(() => expect(result.current.isAuthenticated).toBe(true)); + expect(typeof result.current.logout).toBe('function'); + }); + + it('provides register function', async () => { + const { result } = renderHook(() => useAuth(), { wrapper: createWrapper() }); + await waitFor(() => expect(result.current.isAuthenticated).toBe(true)); + expect(typeof result.current.register).toBe('function'); + }); + + it('throws when used without AuthProvider', () => { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + expect(() => { + renderHook(() => useAuth(), { + wrapper: ({ children }) => ( + {children} + ), + }); + }).toThrow('useAuth must be used within an AuthProvider'); + }); +}); +``` + +- [ ] **Step 2: Create useSearch.test.tsx** + +```tsx +import { describe, it, expect } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { http, HttpResponse } from 'msw'; +import { server } from '../test/server'; +import { useSearch } from './useSearch'; +import { type ReactNode } from 'react'; + +const API = '/api/v1'; + +function createWrapper() { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return function Wrapper({ children }: { children: ReactNode }) { + return {children}; + }; +} + +describe('useSearch', () => { + it('does not fetch when query is empty', () => { + const { result } = renderHook(() => useSearch(''), { wrapper: createWrapper() }); + expect(result.current.isFetching).toBe(false); + expect(result.current.data).toBeUndefined(); + }); + + it('does not fetch when query is too short', () => { + const { result } = renderHook(() => useSearch('a'), { wrapper: createWrapper() }); + expect(result.current.data).toBeUndefined(); + }); + + it('returns search results for valid query', async () => { + const { result } = renderHook(() => useSearch('sber'), { wrapper: createWrapper() }); + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + expect(result.current.data).toBeDefined(); + expect(result.current.data?.length).toBeGreaterThan(0); + expect(result.current.data?.[0].secid).toBe('SBER'); + }); + + it('returns empty array when no results', async () => { + server.use( + http.get(`${API}/securities/search`, () => { + return HttpResponse.json({ data: { data: [], meta: { fromCache: false, cachedAt: null } } }); + }), + ); + const { result } = renderHook(() => useSearch('zzzzz'), { wrapper: createWrapper() }); + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + expect(result.current.data).toEqual([]); + }); + + it('returns error state on network failure', async () => { + server.use( + http.get(`${API}/securities/search`, () => new HttpResponse(null, { status: 500 })), + ); + const { result } = renderHook(() => useSearch('error'), { wrapper: createWrapper() }); + await waitFor(() => { + expect(result.current.isError).toBe(true); + }); + }); +}); +``` + +- [ ] **Step 3: Create useStock.test.tsx** + +```tsx +import { describe, it, expect } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { http, HttpResponse } from 'msw'; +import { server } from '../test/server'; +import { useStock } from './useStock'; +import { type ReactNode } from 'react'; + +const API = '/api/v1'; + +function createWrapper() { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return function Wrapper({ children }: { children: ReactNode }) { + return {children}; + }; +} + +describe('useStock', () => { + it('returns share data', async () => { + const { result } = renderHook(() => useStock('SBER'), { wrapper: createWrapper() }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data?.secid).toBe('SBER'); + expect(result.current.data?.shortName).toBe('Сбер'); + expect(result.current.data?.marketData.price).toBe(289.5); + }); + + it('returns error for not found', async () => { + const { result } = renderHook(() => useStock('NOTFOUND'), { wrapper: createWrapper() }); + await waitFor(() => expect(result.current.isError).toBe(true)); + }); + + it('starts in loading state', () => { + const { result } = renderHook(() => useStock('SBER'), { wrapper: createWrapper() }); + expect(result.current.isLoading).toBe(true); + }); +}); +``` + +- [ ] **Step 4: Create useStockCandles.test.tsx** + +```tsx +import { describe, it, expect } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { http, HttpResponse } from 'msw'; +import { server } from '../test/server'; +import { useStockCandles } from './useStockCandles'; +import { type ReactNode } from 'react'; + +const API = '/api/v1'; + +function createWrapper() { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return function Wrapper({ children }: { children: ReactNode }) { + return {children}; + }; +} + +describe('useStockCandles', () => { + it('returns candle data', async () => { + const { result } = renderHook( + () => useStockCandles('SBER', '24h', '2024-01-01', '2024-01-31'), + { wrapper: createWrapper() }, + ); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toHaveLength(2); + expect(result.current.data?.[0].open).toBe(280); + }); + + it('returns empty array when no candles', async () => { + server.use( + http.get(`${API}/securities/shares/:secid/candles`, () => { + return HttpResponse.json({ data: { data: [], meta: { fromCache: false, cachedAt: null } } }); + }), + ); + const { result } = renderHook( + () => useStockCandles('SBER', '24h', '2024-01-01', '2024-01-31'), + { wrapper: createWrapper() }, + ); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toEqual([]); + }); +}); +``` + +- [ ] **Step 5: Create useStockDividends.test.tsx** + +```tsx +import { describe, it, expect } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { http, HttpResponse } from 'msw'; +import { server } from '../test/server'; +import { useStockDividends } from './useStockDividends'; +import { type ReactNode } from 'react'; + +const API = '/api/v1'; + +function createWrapper() { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return function Wrapper({ children }: { children: ReactNode }) { + return {children}; + }; +} + +describe('useStockDividends', () => { + it('returns dividend data', async () => { + const { result } = renderHook(() => useStockDividends('SBER'), { wrapper: createWrapper() }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toHaveLength(2); + expect(result.current.data?.[0].value).toBe(35); + }); + + it('returns empty array when no dividends', async () => { + server.use( + http.get(`${API}/securities/shares/:secid/dividends`, () => { + return HttpResponse.json({ data: { data: [], meta: { fromCache: false, cachedAt: null } } }); + }), + ); + const { result } = renderHook(() => useStockDividends('SBER'), { wrapper: createWrapper() }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toEqual([]); + }); +}); +``` + +- [ ] **Step 6: Create useBond.test.tsx** + +```tsx +import { describe, it, expect } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { http, HttpResponse } from 'msw'; +import { server } from '../test/server'; +import { useBond } from './useBond'; +import { type ReactNode } from 'react'; + +const API = '/api/v1'; + +function createWrapper() { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return function Wrapper({ children }: { children: ReactNode }) { + return {children}; + }; +} + +describe('useBond', () => { + it('returns bond data', async () => { + const { result } = renderHook(() => useBond('SU26238RMFS5'), { wrapper: createWrapper() }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data?.shortName).toBe('ОФЗ 26238'); + expect(result.current.data?.marketData.price).toBe(98.5); + }); + + it('returns error on 404', async () => { + const { result } = renderHook(() => useBond('NOTFOUND'), { wrapper: createWrapper() }); + await waitFor(() => expect(result.current.isError).toBe(true)); + }); +}); +``` + +- [ ] **Step 7: Create useBondCandles.test.tsx** + +```tsx +import { describe, it, expect } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { http, HttpResponse } from 'msw'; +import { server } from '../test/server'; +import { useBondCandles } from './useBondCandles'; +import { type ReactNode } from 'react'; + +const API = '/api/v1'; + +function createWrapper() { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return function Wrapper({ children }: { children: ReactNode }) { + return {children}; + }; +} + +describe('useBondCandles', () => { + it('returns candle data', async () => { + const { result } = renderHook( + () => useBondCandles('SU26238RMFS5', '24h', '2024-01-01', '2024-01-31'), + { wrapper: createWrapper() }, + ); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toHaveLength(2); + }); + + it('returns empty array when no candles', async () => { + server.use( + http.get(`${API}/securities/bonds/:secid/candles`, () => { + return HttpResponse.json({ data: { data: [], meta: { fromCache: false, cachedAt: null } } }); + }), + ); + const { result } = renderHook( + () => useBondCandles('SU26238RMFS5', '24h', '2024-01-01', '2024-01-31'), + { wrapper: createWrapper() }, + ); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toEqual([]); + }); +}); +``` + +- [ ] **Step 8: Run all hook tests** + +Run: `npx vitest run apps/frontend/src/hooks/ -w apps/frontend` +Expected: all passing + +--- + +### Task 6: UI Component tests + +**Files:** +- Create: `apps/frontend/src/components/SearchBar.test.tsx` +- Create: `apps/frontend/src/components/ProtectedRoute.test.tsx` +- Create: `apps/frontend/src/components/StockDetails.test.tsx` +- Create: `apps/frontend/src/components/BondDetails.test.tsx` +- Create: `apps/frontend/src/components/PriceChart.test.tsx` +- Create: `apps/frontend/src/components/Layout.test.tsx` + +- [ ] **Step 1: Create SearchBar.test.tsx** + +```tsx +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { renderWithProviders } from '../test/test-utils'; + +describe('SearchBar', () => { + it('renders input with placeholder', () => { + renderWithProviders(
); // SearchBar is inside Layout, test it within + }); +}); +``` + +Actually the SearchBar is rendered through Layout but can be tested standalone. Let me fix: + +```tsx +import { describe, it, expect } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { http, HttpResponse } from 'msw'; +import { server } from '../test/server'; +import { SearchBar } from './SearchBar'; +import { renderWithProviders } from '../test/test-utils'; + +const API = '/api/v1'; + +function renderSearchBar() { + return renderWithProviders(); +} + +describe('SearchBar', () => { + it('renders search input', () => { + renderSearchBar(); + expect(screen.getByPlaceholderText('Поиск акций и облигаций...')).toBeInTheDocument(); + }); + + it('shows dropdown on focus', async () => { + renderSearchBar(); + const input = screen.getByPlaceholderText('Поиск акций и облигаций...'); + await userEvent.type(input, 'sber'); + await waitFor(() => { + expect(screen.getByText('Сбер')).toBeInTheDocument(); + }); + }); + + it('shows loading state while fetching', async () => { + server.use( + http.get(`${API}/securities/search`, () => { + return new Promise(() => {}); // never resolves + }), + ); + renderSearchBar(); + const input = screen.getByPlaceholderText('Поиск акций и облигаций...'); + await userEvent.type(input, 'sber'); + await waitFor(() => { + expect(screen.getByText('Загрузка...')).toBeInTheDocument(); + }); + }); + + it('shows no results message', async () => { + server.use( + http.get(`${API}/securities/search`, () => { + return HttpResponse.json({ data: { data: [], meta: { fromCache: false, cachedAt: null } } }); + }), + ); + renderSearchBar(); + const input = screen.getByPlaceholderText('Поиск акций и облигаций...'); + await userEvent.type(input, 'zzzzz'); + await waitFor(() => { + expect(screen.getByText('Ничего не найдено')).toBeInTheDocument(); + }); + }); + + it('hides dropdown when clicking outside', async () => { + renderSearchBar(); + const input = screen.getByPlaceholderText('Поиск акций и облигаций...'); + await userEvent.type(input, 'sber'); + await waitFor(() => { + expect(screen.getByText('Сбер')).toBeInTheDocument(); + }); + await userEvent.click(document.body); + await waitFor(() => { + expect(screen.queryByText('Сбер')).not.toBeInTheDocument(); + }); + }); + + it('navigates to stock page on share result click', async () => { + const { mockNavigate } = vi.hoisted(() => ({ mockNavigate: vi.fn() })); + vi.mock('react-router-dom', async (importOriginal) => { + const actual = await importOriginal(); + return { ...(actual as object), useNavigate: () => mockNavigate }; + }); + + renderSearchBar(); + const input = screen.getByPlaceholderText('Поиск акций и облигаций...'); + await userEvent.type(input, 'sber'); + await waitFor(() => { + expect(screen.getByText('Сбер')).toBeInTheDocument(); + }); + await userEvent.click(screen.getByText('Сбер')); + expect(mockNavigate).toHaveBeenCalledWith('/stocks/SBER'); + }); +}); +``` + +- [ ] **Step 2: Create ProtectedRoute.test.tsx** + +```tsx +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { ProtectedRoute } from './ProtectedRoute'; + +describe('ProtectedRoute', () => { + it('renders children when authenticated', async () => { + // Renders through AuthProvider which auto-authenticates via refresh + const { renderWithProviders } = await import('../test/test-utils'); + renderWithProviders( + +
Secret
+
, + ); + const content = await screen.findByTestId('protected-content'); + expect(content).toBeInTheDocument(); + }); + + it('redirects to login when not authenticated', async () => { + const { http, HttpResponse } = await import('msw'); + const { server } = await import('../test/server'); + const API = '/api/v1'; + + server.use( + http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 })), + ); + + const { renderWithProviders } = await import('../test/test-utils'); + renderWithProviders( + +
Secret
+
, + { route: '/profile' }, + ); + + await screen.findByText('Загрузка...'); + + // After loading, should redirect to login + // Use findByText for async redirect check + }); +}); +``` + +Actually this imports are messy with dynamic imports. Let me write cleaner: + +```tsx +import { describe, it, expect } from 'vitest'; +import { screen, waitFor } from '@testing-library/react'; +import { http, HttpResponse } from 'msw'; +import { server } from '../test/server'; +import { ProtectedRoute } from './ProtectedRoute'; +import { renderWithProviders } from '../test/test-utils'; + +const API = '/api/v1'; + +describe('ProtectedRoute', () => { + it('renders children when authenticated', async () => { + renderWithProviders( + +
Secret
+
, + ); + expect(await screen.findByTestId('protected-content')).toBeInTheDocument(); + }); + + it('shows loading state initially then redirects when not authenticated', async () => { + server.use( + http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 })), + ); + + renderWithProviders( + +
Secret
+
, + { route: '/profile' }, + ); + + expect(screen.getByText('Загрузка...')).toBeInTheDocument(); + + await waitFor(() => { + // After auth resolves, should redirect — we check the protected content is gone + expect(screen.queryByTestId('protected-content')).not.toBeInTheDocument(); + }); + }); +}); +``` + +- [ ] **Step 3: Create StockDetails.test.tsx** + +```tsx +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { StockDetails } from './StockDetails'; +import { createMockShare } from '../test/factories'; + +describe('StockDetails', () => { + it('renders stock details', () => { + const stock = createMockShare(); + render(); + expect(screen.getByText('Сбер (SBER)')).toBeInTheDocument(); + expect(screen.getByText('Сбер Банк · RU0009029540')).toBeInTheDocument(); + }); + + it('displays positive change in green', () => { + const stock = createMockShare({ marketData: { ...createMockShare().marketData, change: 5, changePercent: 2 } }); + render(); + const changeEl = screen.getByText('+5.00 (2.00%)'); + expect(changeEl).toBeInTheDocument(); + expect(changeEl).toHaveStyle({ color: 'var(--color-positive)' }); + }); + + it('displays negative change in red', () => { + const stock = createMockShare({ marketData: { ...createMockShare().marketData, change: -3, changePercent: -1 } }); + render(); + const changeEl = screen.getByText('-3.00 (-1.00%)'); + expect(changeEl).toBeInTheDocument(); + expect(changeEl).toHaveStyle({ color: 'var(--color-negative)' }); + }); + + it('shows price formatted', () => { + const stock = createMockShare(); + render(); + expect(screen.getByText('289,50')).toBeInTheDocument(); + }); + + it('shows dash for null high', () => { + const stock = createMockShare({ marketData: { ...createMockShare().marketData, high: null } }); + render(); + const rows = screen.getAllByText('—'); + expect(rows.length).toBeGreaterThanOrEqual(1); + }); + + it('shows capitalization in billions', () => { + const stock = createMockShare(); + render(); + expect(screen.getByText('6250,00 млрд ₽')).toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 4: Create BondDetails.test.tsx** + +```tsx +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { BondDetails } from './BondDetails'; +import { createMockBond } from '../test/factories'; + +describe('BondDetails', () => { + it('renders bond details', () => { + const bond = createMockBond(); + render(); + expect(screen.getByText('ОФЗ 26238')).toBeInTheDocument(); + expect(screen.getByText('RU000A101XU7')).toBeInTheDocument(); + }); + + it('shows price as percentage', () => { + const bond = createMockBond(); + render(); + expect(screen.getByText('98.50%')).toBeInTheDocument(); + }); + + it('renders maturity date', () => { + const bond = createMockBond(); + render(); + expect(screen.getByText('2027-05-15')).toBeInTheDocument(); + }); + + it('shows coupon with percentage', () => { + const bond = createMockBond(); + render(); + expect(screen.getByText('36.9 ₽ (7.5%)')).toBeInTheDocument(); + }); + + it('shows coupon without percentage when null', () => { + const bond = createMockBond({ marketData: { ...createMockBond().marketData, couponPercent: null } }); + render(); + expect(screen.getByText('36.9 ₽')).toBeInTheDocument(); + }); + + it('shows dash for missing next coupon date', () => { + const bond = createMockBond({ marketData: { ...createMockBond().marketData, nextCouponDate: null } }); + render(); + const dashes = screen.getAllByText('—'); + expect(dashes.length).toBeGreaterThanOrEqual(1); + }); + + it('shows dash for null yieldToMaturity', () => { + const bond = createMockBond({ marketData: { ...createMockBond().marketData, yieldToMaturity: null } }); + render(); + expect(screen.getByText('—')).toBeInTheDocument(); + }); + + it('shows bond type', () => { + const bond = createMockBond(); + render(); + expect(screen.getByText('ОФЗ')).toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 5: Create PriceChart.test.tsx** + +```tsx +import { describe, it, expect } from 'vitest'; +import { render } from '@testing-library/react'; +import { PriceChart } from './PriceChart'; + +describe('PriceChart', () => { + it('renders chart container', () => { + const { container } = render(); + expect(container.querySelector('div')).toBeInTheDocument(); + }); + + it('renders with candle data', () => { + const data = [ + { open: 100, high: 110, low: 95, close: 105, begin: '2024-01-15T10:00:00Z' }, + ]; + const { container } = render(); + expect(container.querySelector('div')).toBeInTheDocument(); + }); + + it('accepts custom height', () => { + const { container } = render(); + expect(container.querySelector('div')).toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 6: Create Layout.test.tsx** + +```tsx +import { describe, it, expect } from 'vitest'; +import { screen, waitFor } from '@testing-library/react'; +import { http, HttpResponse } from 'msw'; +import { server } from '../test/server'; +import { Layout } from './Layout'; +import { renderWithProviders } from '../test/test-utils'; + +const API = '/api/v1'; + +describe('Layout', () => { + it('renders logo and search bar', async () => { + renderWithProviders(); + expect(screen.getByText('MoexVibe')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('Поиск акций и облигаций...')).toBeInTheDocument(); + }); + + it('shows login link when not authenticated', async () => { + server.use( + http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 })), + ); + renderWithProviders(); + await waitFor(() => { + expect(screen.getByText('Войти')).toBeInTheDocument(); + }); + }); + + it('shows user name and logout when authenticated', async () => { + renderWithProviders(); + await waitFor(() => { + expect(screen.getByText('Test User')).toBeInTheDocument(); + expect(screen.getByText('Выйти')).toBeInTheDocument(); + }); + }); + + it('shows email when user has no name', async () => { + server.use( + http.post(`${API}/auth/refresh`, () => { + return HttpResponse.json({ + data: { + data: { user: { id: 1, email: 'user@test.com', name: null, role: 'user' }, accessToken: 'mock' }, + meta: { fromCache: false, cachedAt: null }, + }, + }); + }), + ); + renderWithProviders(); + await waitFor(() => { + expect(screen.getByText('user@test.com')).toBeInTheDocument(); + }); + }); +}); +``` + +- [ ] **Step 7: Run component tests** + +Run: `npx vitest run apps/frontend/src/components/ -w apps/frontend` +Expected: all passing + +--- + +### Task 7: Page tests + +**Files:** +- Create: `apps/frontend/src/pages/HomePage.test.tsx` +- Create: `apps/frontend/src/pages/StockPage.test.tsx` +- Create: `apps/frontend/src/pages/BondPage.test.tsx` +- Create: `apps/frontend/src/pages/LoginPage.test.tsx` +- Create: `apps/frontend/src/pages/RegisterPage.test.tsx` +- Create: `apps/frontend/src/pages/ProfilePage.test.tsx` + +- [ ] **Step 1: Create HomePage.test.tsx** + +```tsx +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { HomePage } from './HomePage'; + +describe('HomePage', () => { + it('renders welcome title', () => { + render(); + expect(screen.getByText('MoexVibe')).toBeInTheDocument(); + }); + + it('renders description', () => { + render(); + expect(screen.getByText('Анализ акций и облигаций Московской биржи')).toBeInTheDocument(); + }); + + it('renders hint text', () => { + render(); + expect(screen.getByText(/Введите название или тикер/)).toBeInTheDocument(); + }); + + it('renders data delay notice', () => { + render(); + expect(screen.getByText(/Данные задерживаются на 15 минут/)).toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 2: Create StockPage.test.tsx** + +```tsx +import { describe, it, expect } from 'vitest'; +import { screen, waitFor } from '@testing-library/react'; +import { http, HttpResponse } from 'msw'; +import { server } from '../test/server'; +import { StockPage } from './StockPage'; +import { renderWithProviders } from '../test/test-utils'; + +const API = '/api/v1'; + +describe('StockPage', () => { + it('shows loading state', () => { + server.use( + http.get(`${API}/securities/shares/:secid`, () => new Promise(() => {})), + ); + renderWithProviders(, { route: '/stocks/SBER' }); + expect(screen.getByText('Загрузка...')).toBeInTheDocument(); + }); + + it('renders stock details after loading', async () => { + renderWithProviders(, { route: '/stocks/SBER' }); + expect(await screen.findByText('Сбер (SBER)')).toBeInTheDocument(); + }); + + it('renders price chart', async () => { + renderWithProviders(, { route: '/stocks/SBER' }); + expect(await screen.findByText('График цены')).toBeInTheDocument(); + }); + + it('renders dividends section', async () => { + renderWithProviders(, { route: '/stocks/SBER' }); + expect(await screen.findByText('Дивиденды')).toBeInTheDocument(); + expect(await screen.findByText('Дата закрытия реестра')).toBeInTheDocument(); + }); + + it('hides dividends section when empty', async () => { + server.use( + http.get(`${API}/securities/shares/:secid/dividends`, () => { + return HttpResponse.json({ data: { data: [], meta: { fromCache: false, cachedAt: null } } }); + }), + ); + renderWithProviders(, { route: '/stocks/SBER' }); + await waitFor(() => { + expect(screen.queryByText('Дивиденды')).not.toBeInTheDocument(); + }); + }); + + it('shows error state for not found', async () => { + server.use( + http.get(`${API}/securities/shares/:secid`, () => new HttpResponse(null, { status: 404 })), + http.get(`${API}/securities/shares/:secid/candles`, () => new HttpResponse(null, { status: 404 })), + http.get(`${API}/securities/shares/:secid/dividends`, () => new HttpResponse(null, { status: 404 })), + ); + renderWithProviders(, { route: '/stocks/NOTFOUND' }); + expect(await screen.findByText('Инструмент не найден')).toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 3: Create BondPage.test.tsx** + +```tsx +import { describe, it, expect } from 'vitest'; +import { screen } from '@testing-library/react'; +import { http, HttpResponse } from 'msw'; +import { server } from '../test/server'; +import { BondPage } from './BondPage'; +import { renderWithProviders } from '../test/test-utils'; + +const API = '/api/v1'; + +describe('BondPage', () => { + it('shows loading state', () => { + server.use( + http.get(`${API}/securities/bonds/:secid`, () => new Promise(() => {})), + ); + renderWithProviders(, { route: '/bonds/SU26238RMFS5' }); + expect(screen.getByText('Загрузка...')).toBeInTheDocument(); + }); + + it('renders bond details after loading', async () => { + renderWithProviders(, { route: '/bonds/SU26238RMFS5' }); + expect(await screen.findByText('ОФЗ 26238')).toBeInTheDocument(); + }); + + it('renders price chart', async () => { + renderWithProviders(, { route: '/bonds/SU26238RMFS5' }); + expect(await screen.findByText('График цены')).toBeInTheDocument(); + }); + + it('shows error state for not found', async () => { + server.use( + http.get(`${API}/securities/bonds/:secid`, () => new HttpResponse(null, { status: 404 })), + http.get(`${API}/securities/bonds/:secid/candles`, () => new HttpResponse(null, { status: 404 })), + ); + renderWithProviders(, { route: '/bonds/NOTFOUND' }); + expect(await screen.findByText('Инструмент не найден')).toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 4: Create LoginPage.test.tsx** + +```tsx +import { describe, it, expect, vi } from 'vitest'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { http, HttpResponse } from 'msw'; +import { server } from '../test/server'; +import { LoginPage } from './LoginPage'; +import { renderWithProviders } from '../test/test-utils'; + +const API = '/api/v1'; + +describe('LoginPage', () => { + it('renders login form', () => { + // Mock refresh to fail so we don't auto-auth + server.use( + http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 })), + ); + renderWithProviders(, { route: '/login' }); + expect(screen.getByText('Вход')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('email@example.com')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('••••••••')).toBeInTheDocument(); + }); + + it('redirects to home on successful login', async () => { + server.use( + http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 })), + ); + const user = userEvent.setup(); + renderWithProviders(, { route: '/login' }); + + await user.type(screen.getByPlaceholderText('email@example.com'), 'test@test.com'); + await user.type(screen.getByPlaceholderText('••••••••'), 'password'); + await user.click(screen.getByRole('button', { name: 'Войти' })); + + await waitFor(() => { + // Login succeeded, should redirect + expect(screen.queryByText('Вход')).not.toBeInTheDocument(); + }); + }); + + it('shows error on failed login', async () => { + server.use( + http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 })), + http.post(`${API}/auth/login`, () => new HttpResponse(null, { status: 401, statusText: 'Unauthorized' })), + ); + const user = userEvent.setup(); + renderWithProviders(, { route: '/login' }); + + await user.type(screen.getByPlaceholderText('email@example.com'), 'bad@test.com'); + await user.type(screen.getByPlaceholderText('••••••••'), 'wrong'); + await user.click(screen.getByRole('button', { name: 'Войти' })); + + expect(await screen.findByText(/Ошибка API/)).toBeInTheDocument(); + }); + + it('shows loading state while submitting', async () => { + server.use( + http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 })), + http.post(`${API}/auth/login`, () => new Promise(() => {})), + ); + const user = userEvent.setup(); + renderWithProviders(, { route: '/login' }); + + await user.type(screen.getByPlaceholderText('email@example.com'), 'test@test.com'); + await user.type(screen.getByPlaceholderText('••••••••'), 'password'); + await user.click(screen.getByRole('button', { name: 'Войти' })); + + expect(screen.getByText('Вход...')).toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 5: Create RegisterPage.test.tsx** + +```tsx +import { describe, it, expect } from 'vitest'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { http, HttpResponse } from 'msw'; +import { server } from '../test/server'; +import { RegisterPage } from './RegisterPage'; +import { renderWithProviders } from '../test/test-utils'; + +const API = '/api/v1'; + +describe('RegisterPage', () => { + it('renders registration form', () => { + server.use( + http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 })), + ); + renderWithProviders(, { route: '/register' }); + expect(screen.getByText('Регистрация')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('Иван Иванов')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('email@example.com')).toBeInTheDocument(); + }); + + it('shows password mismatch error', async () => { + server.use( + http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 })), + ); + const user = userEvent.setup(); + renderWithProviders(, { route: '/register' }); + + await user.type(screen.getByPlaceholderText('email@example.com'), 'test@test.com'); + await user.type(screen.getAllByPlaceholderText(/Пароль/)[0], 'password1'); + await user.type(screen.getByPlaceholderText('Повторите пароль'), 'password2'); + await user.click(screen.getByRole('button', { name: 'Зарегистрироваться' })); + + expect(await screen.findByText('Пароли не совпадают')).toBeInTheDocument(); + }); + + it('redirects on successful registration', async () => { + server.use( + http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 })), + ); + const user = userEvent.setup(); + renderWithProviders(, { route: '/register' }); + + await user.type(screen.getByPlaceholderText('email@example.com'), 'test@test.com'); + await user.type(screen.getAllByPlaceholderText(/Пароль/)[0], 'password'); + await user.type(screen.getByPlaceholderText('Повторите пароль'), 'password'); + await user.click(screen.getByRole('button', { name: 'Зарегистрироваться' })); + + await waitFor(() => { + expect(screen.queryByText('Регистрация')).not.toBeInTheDocument(); + }); + }); + + it('shows error on failed registration', async () => { + server.use( + http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 })), + http.post(`${API}/auth/register`, () => new HttpResponse(null, { status: 409, statusText: 'Conflict' })), + ); + const user = userEvent.setup(); + renderWithProviders(, { route: '/register' }); + + await user.type(screen.getByPlaceholderText('email@example.com'), 'existing@test.com'); + await user.type(screen.getAllByPlaceholderText(/Пароль/)[0], 'password'); + await user.type(screen.getByPlaceholderText('Повторите пароль'), 'password'); + await user.click(screen.getByRole('button', { name: 'Зарегистрироваться' })); + + expect(await screen.findByText(/Ошибка API/)).toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 6: Create ProfilePage.test.tsx** + +```tsx +import { describe, it, expect } from 'vitest'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { http, HttpResponse } from 'msw'; +import { server } from '../test/server'; +import { ProfilePage } from './ProfilePage'; +import { renderWithProviders } from '../test/test-utils'; + +const API = '/api/v1'; + +describe('ProfilePage', () => { + it('renders user profile', async () => { + renderWithProviders( + , + { route: '/profile' }, + ); + expect(await screen.findByText('Профиль')).toBeInTheDocument(); + expect(await screen.findByText('user@test.com')).toBeInTheDocument(); + expect(await screen.findByText('user')).toBeInTheDocument(); + }); + + it('shows user name in input', async () => { + renderWithProviders(, { route: '/profile' }); + const input = await screen.findByDisplayValue('Test User'); + expect(input).toBeInTheDocument(); + }); + + it('updates profile on save', async () => { + const user = userEvent.setup(); + renderWithProviders(, { route: '/profile' }); + + const input = await screen.findByDisplayValue('Test User'); + await user.clear(input); + await user.type(input, 'Updated User'); + await user.click(screen.getByRole('button', { name: 'Сохранить' })); + + expect(await screen.findByText('Профиль обновлён')).toBeInTheDocument(); + }); + + it('shows error message on failed update', async () => { + server.use( + http.patch(`${API}/auth/me`, () => new HttpResponse(null, { status: 500 })), + ); + const user = userEvent.setup(); + renderWithProviders(, { route: '/profile' }); + + const input = await screen.findByDisplayValue('Test User'); + await user.clear(input); + await user.type(input, 'New Name'); + await user.click(screen.getByRole('button', { name: 'Сохранить' })); + + expect(await screen.findByText('Не удалось обновить профиль')).toBeInTheDocument(); + }); + + it('shows saving state', async () => { + server.use( + http.patch(`${API}/auth/me`, () => new Promise(() => {})), + ); + const user = userEvent.setup(); + renderWithProviders(, { route: '/profile' }); + + const input = await screen.findByDisplayValue('Test User'); + await user.clear(input); + await user.type(input, 'New Name'); + await user.click(screen.getByRole('button', { name: 'Сохранить' })); + + expect(await screen.findByText('Сохранение...')).toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 7: Run all page tests** + +Run: `npx vitest run apps/frontend/src/pages/ -w apps/frontend` +Expected: all passing + +--- + +### Task 8: Final verification + +- [ ] **Step 1: Run all tests** + +Run: `npm run test:frontend` +Expected: all tests passing + +- [ ] **Step 2: Run format check** + +Run: `npm run format:check` +Expected: no formatting issues diff --git a/docs/superpowers/specs/2026-06-13-frontend-test-coverage.md b/docs/superpowers/specs/2026-06-13-frontend-test-coverage.md new file mode 100644 index 0000000..fe1ba26 --- /dev/null +++ b/docs/superpowers/specs/2026-06-13-frontend-test-coverage.md @@ -0,0 +1,127 @@ +# Frontend Unit Test Coverage + +## Stack + +- **vitest** — test runner +- **@testing-library/react** + **@testing-library/jest-dom** + **@testing-library/user-event** — component testing +- **jsdom** — DOM environment +- **msw** (v2) — network-level mocking via `setupServer` + +## Project structure (new files) + +``` +apps/frontend/ +├── vitest.config.ts +└── src/ + └── test/ + ├── setup.ts # jest-dom matchers, matchMedia mock + ├── server.ts # MSW setupServer instance + ├── handlers.ts # Default MSW handlers for all endpoints + ├── test-utils.tsx # renderWithProviders wrapper + └── factories.ts # Mock data factories (share, bond, candle, user) +``` + +## Conventions + +| Rule | Value | +|---|---| +| Location | `ComponentName.test.tsx` alongside `ComponentName.tsx` | +| Pattern | AAA (Arrange -> Act -> Assert) | +| Describe nesting | `describe('Component')` -> `describe('when ...')` | +| Snapshots | None | +| Mocks | MSW for API; `vi.fn()` for callbacks only | +| QueryClient | Fresh instance per test via `renderWithProviders` with `retry: false` | + +## renderWithProviders + +Custom wrapper that composes: + +```tsx +function renderWithProviders(ui, { authState, queryClient, route } = {}) +``` + +- `QueryClientProvider` with fresh client (defaultOptions: `queries: { retry: false }`) +- `AuthProvider` with optional override of initial state +- `MemoryRouter` with optional initial route +- Returns `{ ...render(ui), queryClient }` + +## MSW handlers (default) + +All API endpoints return 200 with valid mock data by default. Handlers: + +- `GET /api/v1/auth/me` -> user +- `POST /api/v1/auth/login` -> tokens +- `POST /api/v1/auth/register` -> tokens +- `POST /api/v1/auth/refresh` -> tokens +- `POST /api/v1/auth/logout` -> 204 +- `PATCH /api/v1/auth/me` -> updated user +- `GET /api/v1/shares/:secid` -> share +- `GET /api/v1/shares/:secid/candles` -> candles +- `GET /api/v1/shares/:secid/dividends` -> dividends +- `GET /api/v1/bonds/:secid` -> bond +- `GET /api/v1/bonds/:secid/candles` -> candles +- `GET /api/v1/securities/search` -> search results +- `GET /api/v1/health` -> ok + +Errors are tested by overriding handlers per-test via `server.use()`. + +## Implementation order (5 phases) + +### Phase 1: Infrastructure + API client + +**Files to create:** +- `vitest.config.ts` +- `src/test/setup.ts` +- `src/test/server.ts` +- `src/test/handlers.ts` +- `src/test/test-utils.tsx` +- `src/test/factories.ts` + +**Tests:** +- `api/client.test.ts` — basic get/post, 401 -> refresh -> retry, persistent 401 -> throws +- `api/auth.test.ts` — login, register, refresh, logout, getMe, updateProfile + +### Phase 2: Auth + Hooks + +- `context/AuthContext.test.tsx` — `login`/`register`/`logout`/`updateProfile` update context state +- `hooks/useAuth.test.tsx` — returns context values, throws outside provider +- `hooks/useSearch.test.ts` — empty query, results found, no results, error +- `hooks/useStock.test.ts` — data loaded, loading, error +- `hooks/useStockCandles.test.ts` — data loaded, empty array, error +- `hooks/useStockDividends.test.ts` — data loaded, no dividends, error +- `hooks/useBond.test.ts` — data loaded, loading, error +- `hooks/useBondCandles.test.ts` — data loaded, empty array, error + +### Phase 3: UI Components + +- `components/SearchBar.test.tsx` — typing triggers search, dropdown shows results, clicking result navigates, empty input hides dropdown, loading state +- `components/ProtectedRoute.test.tsx` — authenticated -> renders children; unauthenticated -> redirects to `/login?redirect=` +- `components/StockDetails.test.tsx` — renders all fields, missing optional fields, null/undefined data +- `components/BondDetails.test.tsx` — renders all fields, missing optional fields, null/undefined data +- `components/PriceChart.test.tsx` — renders chart with candle data, empty data array, error state +- `components/Layout.test.tsx` — renders search bar, auth state toggles login/profile link, logout button works on profile + +### Phase 4: Pages + +- `pages/LoginPage.test.tsx` — renders form, validation errors, successful login redirects, server error displayed +- `pages/RegisterPage.test.tsx` — renders form, validation errors, successful register redirects, password mismatch +- `pages/HomePage.test.tsx` — renders welcome content and navigation links +- `pages/StockPage.test.tsx` — loading skeleton, renders all sections (details, chart, dividends), error state, not found +- `pages/BondPage.test.tsx` — loading skeleton, renders all sections (details, chart), error state, not found +- `pages/ProfilePage.test.tsx` — loads user data, renders form, successful update, validation error, not authenticated -> redirect + +### Phase 5: Scripts & wiring + +- Add `"test": "vitest run"` and `"test:watch": "vitest"` to `apps/frontend/package.json` +- Add `"test:frontend": "npm run test -w apps/frontend"` to root `package.json` +- Verify: `npm run test:frontend` passes + +## Testing rules + +1. **No `waitFor` for queries** — use `findBy*` / `findAllBy*` which already await +2. **`userEvent` over `fireEvent`** — simulates real interactions +3. **MSW handlers reset in `afterEach`** — `server.resetHandlers()` +4. **`server.close()` in `afterAll`** +5. **No snapshot tests** +6. **Factory data only includes required fields** — tests explicitly add optional fields when needed +7. **Each `describe` tests one concern** — no 200-line tests diff --git a/package-lock.json b/package-lock.json index 28205d6..e3b320e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -85,14 +85,843 @@ "react-router-dom": "^6.20.0" }, "devDependencies": { + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", + "@types/node": "^25.9.3", "@types/react": "^18.3.0", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.2.0", + "jsdom": "^29.1.1", + "msw": "^2.14.6", "openapi-typescript": "^7.0.0", "typescript": "^5.3.0", - "vite": "^5.4.0" + "vite": "^5.4.0", + "vitest": "^4.1.8" } }, + "apps/frontend/node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "apps/frontend/node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "apps/frontend/node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "apps/frontend/node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "apps/frontend/node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "apps/frontend/node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "apps/frontend/node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "apps/frontend/node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "apps/frontend/node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "apps/frontend/node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "apps/frontend/node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "apps/frontend/node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "apps/frontend/node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "apps/frontend/node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "apps/frontend/node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "apps/frontend/node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "apps/frontend/node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "apps/frontend/node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "apps/frontend/node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "apps/frontend/node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "apps/frontend/node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "apps/frontend/node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "apps/frontend/node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "apps/frontend/node_modules/@types/node": { + "version": "25.9.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.3.tgz", + "integrity": "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "apps/frontend/node_modules/@vitest/expect": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.8.tgz", + "integrity": "sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.8", + "@vitest/utils": "4.1.8", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "apps/frontend/node_modules/@vitest/runner": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.8.tgz", + "integrity": "sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.8", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "apps/frontend/node_modules/@vitest/snapshot": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.8.tgz", + "integrity": "sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.8", + "@vitest/utils": "4.1.8", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "apps/frontend/node_modules/@vitest/spy": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.8.tgz", + "integrity": "sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "apps/frontend/node_modules/@vitest/utils": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.8.tgz", + "integrity": "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.8", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "apps/frontend/node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "apps/frontend/node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, + "apps/frontend/node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "peer": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "apps/frontend/node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "apps/frontend/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "apps/frontend/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "apps/frontend/node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, + "apps/frontend/node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "dev": true, + "license": "MIT" + }, + "apps/frontend/node_modules/vitest": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.8.tgz", + "integrity": "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.8", + "@vitest/mocker": "4.1.8", + "@vitest/pretty-format": "4.1.8", + "@vitest/runner": "4.1.8", + "@vitest/snapshot": "4.1.8", + "@vitest/spy": "4.1.8", + "@vitest/utils": "4.1.8", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.8", + "@vitest/browser-preview": "4.1.8", + "@vitest/browser-webdriverio": "4.1.8", + "@vitest/coverage-istanbul": "4.1.8", + "@vitest/coverage-v8": "4.1.8", + "@vitest/ui": "4.1.8", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "apps/frontend/node_modules/vitest/node_modules/@vitest/mocker": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.8.tgz", + "integrity": "sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.8", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "apps/frontend/node_modules/vitest/node_modules/vite": { + "version": "8.0.16", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", + "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.15", + "rolldown": "1.0.3", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "apps/frontend/node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true, + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@algolia/abtesting": { "version": "1.20.0", "resolved": "https://registry.npmjs.org/@algolia/abtesting/-/abtesting-1.20.0.tgz", @@ -499,6 +1328,193 @@ "tslib": "^2.1.0" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/@csstools/css-calc": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", + "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/@csstools/css-color-parser": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.4.tgz", + "integrity": "sha512-yI8kNhHiOrLb8Rlulsk07DeQz0PwyT69FX9dkz5rAp7p9RUwFKEXnZpBGzURiLHgi66YqIWxOHn1nij8Lrg27Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.2.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -2280,6 +3296,40 @@ "url": "https://github.com/sponsors/Borewit" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@bramus/specificity/node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/@bramus/specificity/node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/@colors/colors": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", @@ -4647,6 +5697,40 @@ "@electric-sql/pglite": "0.4.1" } }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", @@ -4936,6 +6020,24 @@ "node": ">=12" } }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/netbsd-x64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", @@ -4953,6 +6055,24 @@ "node": ">=12" } }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/openbsd-x64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", @@ -4970,6 +6090,24 @@ "node": ">=12" } }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/sunos-x64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", @@ -5149,6 +6287,24 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@hapi/hoek": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", @@ -5239,6 +6395,113 @@ "devOptional": true, "license": "BSD-3-Clause" }, + "node_modules/@inquirer/ansi": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.7.tgz", + "integrity": "sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + } + }, + "node_modules/@inquirer/confirm": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-6.1.1.tgz", + "integrity": "sha512-eb8DBZcz/2qHWQda4rk2JiQk5h9QV/cVHi1yjt0f69WFZMRFn0sJTye3EAP8icut8UDMjQPsaH5KbcOogefrFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.2.1", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core": { + "version": "11.2.1", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-11.2.1.tgz", + "integrity": "sha512-Qd6GJT1yVyrZZCfN8W2qKF5ApmqryXRhRKCuip8h01x2w/esJQ2XIYc6f9abMIHgKQdBfFTSOdbHRLAhuM09UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.7", + "@inquirer/figures": "^2.0.7", + "@inquirer/type": "^4.0.7", + "cli-width": "^4.1.0", + "fast-wrap-ansi": "^0.2.0", + "mute-stream": "^3.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core/node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, + "node_modules/@inquirer/core/node_modules/mute-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz", + "integrity": "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@inquirer/figures": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.7.tgz", + "integrity": "sha512-aJ8TBPOGB6f/2qziPfElISTCEd5XOYTFckA2SGjhNmiKzfK/u4ot3v0DUzGVdUnKjN10EqnnEPck36BkyfLnJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + } + }, + "node_modules/@inquirer/type": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.0.7.tgz", + "integrity": "sha512-t28inv14nMQ1PhKpsJPY+kEs/c00qzeCOS2gTNRyTjG5d6qsVA2fItxW4hkvGZ5lvanGLdtCzVIx5dwdRpN1+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -5713,6 +6976,50 @@ "resolved": "apps/frontend", "link": true }, + "node_modules/@mswjs/interceptors": { + "version": "0.41.9", + "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.41.9.tgz", + "integrity": "sha512-VVPPgHyQ6ShqnrmDWuxjmUIsO9gWyOZFmuOfLd9LfBGQJwZfy0gvv9pbHSJuoFNIYC7ZDX9aoFwowjcdSC4E8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@open-draft/deferred-promise": "^2.2.0", + "@open-draft/logger": "^0.3.0", + "@open-draft/until": "^2.0.0", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "strict-event-emitter": "^0.5.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@mswjs/interceptors/node_modules/@open-draft/deferred-promise": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", + "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", + "integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, "node_modules/@neon-rs/load": { "version": "0.0.4", "resolved": "https://registry.npmjs.org/@neon-rs/load/-/load-0.0.4.tgz", @@ -6280,6 +7587,41 @@ "npm": ">=5.0.0" } }, + "node_modules/@open-draft/deferred-promise": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-3.0.0.tgz", + "integrity": "sha512-XW375UK8/9SqUVNVa6M0yEy8+iTi4QN5VZ7aZuRFQmy76LRwI9wy5F4YIBU6T+eTe2/DNDo8tqu8RHlwLHM6RA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@open-draft/logger": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz", + "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-node-process": "^1.2.0", + "outvariant": "^1.4.0" + } + }, + "node_modules/@open-draft/until": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz", + "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@oxc-project/types": { + "version": "0.133.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", + "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -6806,6 +8148,263 @@ "node": ">=14.0.0" } }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", + "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", + "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", + "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", + "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", + "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", + "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", + "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", + "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", + "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", + "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", + "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", + "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", + "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", + "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", + "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.27", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", @@ -7820,6 +9419,134 @@ "react": "^18 || ^19" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/dom/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@testing-library/dom/node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@testing-library/dom/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, "node_modules/@tokenizer/inflate": { "version": "0.2.7", "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.2.7.tgz", @@ -7844,6 +9571,25 @@ "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", "license": "MIT" }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -7918,6 +9664,27 @@ "@types/node": "*" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/chai/node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/@types/connect": { "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", @@ -7956,6 +9723,13 @@ "@types/ms": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/eslint": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", @@ -8284,6 +10058,16 @@ "@types/node": "*" } }, + "node_modules/@types/set-cookie-parser": { + "version": "2.4.10", + "resolved": "https://registry.npmjs.org/@types/set-cookie-parser/-/set-cookie-parser-2.4.10.tgz", + "integrity": "sha512-GGmQVGpQWUe5qglJozEjZV/5dyxbOOZ0LHe/lqyWssB88Y4svNfst0uqBVscdDeIKl5Jy5+aPSvy7mI9tYRguw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/sockjs": { "version": "0.3.36", "resolved": "https://registry.npmjs.org/@types/sockjs/-/sockjs-0.3.36.tgz", @@ -8293,6 +10077,13 @@ "@types/node": "*" } }, + "node_modules/@types/statuses": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/statuses/-/statuses-2.0.6.tgz", + "integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -8564,6 +10355,19 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.8.tgz", + "integrity": "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@vitest/runner": { "version": "1.6.1", "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-1.6.1.tgz", @@ -9098,6 +10902,16 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "license": "Python-2.0" }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", @@ -9390,6 +11204,16 @@ "devOptional": true, "license": "MIT" }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/big.js": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz", @@ -10315,6 +12139,39 @@ "node": ">= 10" } }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/clone": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", @@ -11168,6 +13025,13 @@ "url": "https://github.com/sponsors/fb55" } }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, "node_modules/cssdb": { "version": "8.9.0", "resolved": "https://registry.npmjs.org/cssdb/-/cssdb-8.9.0.tgz", @@ -11332,6 +13196,58 @@ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/data-urls/node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls/node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/debounce": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/debounce/-/debounce-1.2.1.tgz", @@ -11355,6 +13271,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/decode-named-character-reference": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", @@ -11810,6 +13733,14 @@ "node": ">=6.0.0" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/dom-converter": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/dom-converter/-/dom-converter-0.2.0.tgz", @@ -12671,6 +14602,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/express": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", @@ -12926,6 +14867,33 @@ "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", "license": "MIT" }, + "node_modules/fast-string-truncated-width": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", + "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-string-width": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", + "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-truncated-width": "^3.0.2" + } + }, + "node_modules/fast-wrap-ansi": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz", + "integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-width": "^3.0.2" + } + }, "node_modules/fastq": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", @@ -12960,6 +14928,24 @@ "node": ">=0.8.0" } }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, "node_modules/feed": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/feed/-/feed-4.2.2.tgz", @@ -13404,6 +15390,16 @@ "node": ">=6.9.0" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-func-name": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", @@ -13707,6 +15703,16 @@ "devOptional": true, "license": "MIT" }, + "node_modules/graphql": { + "version": "16.14.2", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.14.2.tgz", + "integrity": "sha512-Chq1s4CY7jmh8gO2qvLIJyfCDIN+EHLFW/9iShnp1z8FjBQMoodWP1kDC36VAMXXIvAjj4ARa7ntfAV2BrjsbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, "node_modules/gray-matter": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz", @@ -14018,6 +16024,17 @@ "he": "bin/he" } }, + "node_modules/headers-polyfill": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-5.0.1.tgz", + "integrity": "sha512-1TJ6Fih/b8h5TIcv+1+Hw0PDQWJTKDKzFZzcKOiW1wJza3XoAQlkCuXLbymPYB8+ZQyw8mHvdw560e8zVFIWyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/set-cookie-parser": "^2.4.10", + "set-cookie-parser": "^3.0.1" + } + }, "node_modules/history": { "version": "4.10.1", "resolved": "https://registry.npmjs.org/history/-/history-4.10.1.tgz", @@ -14105,6 +16122,19 @@ "safe-buffer": "~5.1.0" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/html-entities": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz", @@ -14792,6 +16822,13 @@ "node": ">=8" } }, + "node_modules/is-node-process": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz", + "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==", + "dev": true, + "license": "MIT" + }, "node_modules/is-npm": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/is-npm/-/is-npm-6.1.0.tgz", @@ -14864,6 +16901,13 @@ "node": ">=0.10.0" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-promise": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", @@ -15109,6 +17153,167 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.5.tgz", + "integrity": "sha512-oNjBvzLq2GPZtJphCjLqXow/cHySHSgtxvKZb7OqSZ/xHgw6NWNhfad+6AB9cLeVm6eA9d/qMll3JdEHjy6M+A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/jsdom/node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/jsdom/node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/jsdom/node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/jsdom/node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/jsdom/node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/jsdom/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -15333,6 +17538,277 @@ "@libsql/win32-x64-msvc": "0.5.29" } }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss/node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/lightweight-charts": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/lightweight-charts/-/lightweight-charts-4.2.3.tgz", @@ -15607,6 +18083,17 @@ "url": "https://github.com/sponsors/wellwelwel" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.8", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.8.tgz", @@ -17985,6 +20472,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/mini-css-extract-plugin": { "version": "2.10.2", "resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-2.10.2.tgz", @@ -18112,6 +20609,88 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/msw": { + "version": "2.14.6", + "resolved": "https://registry.npmjs.org/msw/-/msw-2.14.6.tgz", + "integrity": "sha512-ALe+N10S72cyx94cMcy3Zs4HhXCj35sgeAL4c+WTvKi0zWnbd8/h0lcFqv0mb2P+aSgAdD7p9HzvA0DiUPxsyg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@inquirer/confirm": "^6.0.11", + "@mswjs/interceptors": "^0.41.3", + "@open-draft/deferred-promise": "^3.0.0", + "@types/statuses": "^2.0.6", + "cookie": "^1.1.1", + "graphql": "^16.13.2", + "headers-polyfill": "^5.0.1", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "path-to-regexp": "^6.3.0", + "picocolors": "^1.1.1", + "rettime": "^0.11.11", + "statuses": "^2.0.2", + "strict-event-emitter": "^0.5.1", + "tough-cookie": "^6.0.1", + "type-fest": "^5.5.0", + "until-async": "^3.0.2", + "yargs": "^17.7.2" + }, + "bin": { + "msw": "cli/index.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mswjs" + }, + "peerDependencies": { + "typescript": ">= 4.8.x" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/msw/node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/msw/node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/msw/node_modules/type-fest": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.7.0.tgz", + "integrity": "sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/multer": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz", @@ -18471,6 +21050,20 @@ "integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==", "license": "MIT" }, + "node_modules/obug": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/ohash": { "version": "2.0.11", "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", @@ -18681,6 +21274,13 @@ "node": ">=0.10.0" } }, + "node_modules/outvariant": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz", + "integrity": "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==", + "dev": true, + "license": "MIT" + }, "node_modules/p-cancelable": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-3.0.0.tgz", @@ -21668,6 +24268,20 @@ "node": "*" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/reflect-metadata": { "version": "0.1.14", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.1.14.tgz", @@ -22067,6 +24681,16 @@ "node": ">=0.10" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -22177,6 +24801,13 @@ "node": ">= 4" } }, + "node_modules/rettime": { + "version": "0.11.11", + "resolved": "https://registry.npmjs.org/rettime/-/rettime-0.11.11.tgz", + "integrity": "sha512-ILJRqVWBCTlg9r42fFgwVZx1gnFAcQF8mRoMkbgQfIrjEDf9nbBFDFx00oloOa+Q869FUtaYDXZvEfnecQSCoQ==", + "dev": true, + "license": "MIT" + }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -22246,6 +24877,47 @@ "node": "*" } }, + "node_modules/rolldown": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", + "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.133.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.3", + "@rolldown/binding-darwin-arm64": "1.0.3", + "@rolldown/binding-darwin-x64": "1.0.3", + "@rolldown/binding-freebsd-x64": "1.0.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", + "@rolldown/binding-linux-arm64-gnu": "1.0.3", + "@rolldown/binding-linux-arm64-musl": "1.0.3", + "@rolldown/binding-linux-ppc64-gnu": "1.0.3", + "@rolldown/binding-linux-s390x-gnu": "1.0.3", + "@rolldown/binding-linux-x64-gnu": "1.0.3", + "@rolldown/binding-linux-x64-musl": "1.0.3", + "@rolldown/binding-openharmony-arm64": "1.0.3", + "@rolldown/binding-wasm32-wasi": "1.0.3", + "@rolldown/binding-win32-arm64-msvc": "1.0.3", + "@rolldown/binding-win32-x64-msvc": "1.0.3" + } + }, + "node_modules/rolldown/node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, "node_modules/rollup": { "version": "4.62.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.0.tgz", @@ -22414,6 +25086,19 @@ "node": ">=11.0.0" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", @@ -22799,6 +25484,13 @@ "url": "https://opencollective.com/express" } }, + "node_modules/set-cookie-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.0.tgz", + "integrity": "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==", + "dev": true, + "license": "MIT" + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -23256,6 +25948,13 @@ "node": ">=10.0.0" } }, + "node_modules/strict-event-emitter": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz", + "integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==", + "dev": true, + "license": "MIT" + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -23381,6 +26080,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -23558,6 +26270,26 @@ "node": ">=0.10" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/tapable": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", @@ -23712,6 +26444,46 @@ "dev": true, "license": "MIT" }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/tinypool": { "version": "0.8.4", "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.8.4.tgz", @@ -23722,6 +26494,16 @@ "node": ">=14.0.0" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tinyspy": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-2.2.1.tgz", @@ -23732,6 +26514,26 @@ "node": ">=14.0.0" } }, + "node_modules/tldts": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.2.tgz", + "integrity": "sha512-kCwffuaH8ntKtygnWe1b4BJKWiCUH30n5KfoTr6IchcXOwR7chAOFJxFrH3vjANafUYrIA4a7SDL+nn7SiR4Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.2" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.2.tgz", + "integrity": "sha512-nwEyF4vl4RSJjwSjBUmOSxc3BFPoIFdlRthJ6e+5v9P3bHNsoD06UjuqMUspqp7vsEZ1beaHi1km+optiE17yA==", + "dev": true, + "license": "MIT" + }, "node_modules/tmp": { "version": "0.0.33", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", @@ -23793,6 +26595,19 @@ "node": ">=6" } }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", @@ -24008,6 +26823,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/undici": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.27.2.tgz", + "integrity": "sha512-uZsKNuzQxDMUY6M3pIMvy5tvlGmtq8XJ2oLAkfRKGNu+1VQAIvLy2xIVG5ATZl5wDXl/tddByAWCizRbOme+TA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -24240,6 +27065,16 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/until-async": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/until-async/-/until-async-3.0.2.tgz", + "integrity": "sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/kettanaito" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -24769,6 +27604,19 @@ } } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/watchpack": { "version": "2.5.2", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.2.tgz", @@ -25489,6 +28337,16 @@ "node": ">=0.8.0" } }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", @@ -25715,6 +28573,23 @@ "xml-js": "bin/cli.js" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", @@ -25724,6 +28599,16 @@ "node": ">=0.4" } }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -25746,6 +28631,25 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/yargs-parser": { "version": "21.1.1", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", diff --git a/package.json b/package.json index 3225e9e..8c6c897 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "build:backend": "npm run build -w apps/backend", "build:frontend": "npm run build -w apps/frontend", "test:backend": "npm run test -w apps/backend", + "test:frontend": "npm run test -w apps/frontend", "lint": "npm run lint -w apps/backend", "format": "prettier --write \"**/*.{ts,tsx}\"", "format:check": "prettier --check \"**/*.{ts,tsx}\"", -- 2.47.2