diff --git a/apps/frontend/src/App.tsx b/apps/frontend/src/App.tsx index 3bb51fc..4de4bf4 100644 --- a/apps/frontend/src/App.tsx +++ b/apps/frontend/src/App.tsx @@ -1,10 +1 @@ -import { BrowserRouter } from 'react-router-dom'; -import { AppRoutes } from './routes'; - -export default function App() { - return ( - - - - ); -} +export { default } from './app/App'; diff --git a/apps/frontend/src/api/auth.ts b/apps/frontend/src/api/auth.ts index a994e78..3619a97 100644 --- a/apps/frontend/src/api/auth.ts +++ b/apps/frontend/src/api/auth.ts @@ -1,52 +1,8 @@ -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, - }); - 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, - }); - setAccessToken(result.data.accessToken); - return result.data; -} - -export async function refresh() { - 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', - }); - setAccessToken(null); - return result.data; -} - -export async function getMe() { - const result = await request('/api/v1/auth/me'); - return result.data; -} - -export async function updateProfile(data: { name?: string }) { - const result = await request('/api/v1/auth/me', undefined, { - method: 'PATCH', - body: data, - }); - return result.data; -} +export { + login, + register, + refresh, + logout, + getMe, + updateProfile, +} from '../entities/session/api/sessionApi'; diff --git a/apps/frontend/src/app/App.tsx b/apps/frontend/src/app/App.tsx new file mode 100644 index 0000000..4c86746 --- /dev/null +++ b/apps/frontend/src/app/App.tsx @@ -0,0 +1,10 @@ +import { BrowserRouter } from 'react-router-dom'; +import { AppRoutes } from './routing/AppRoutes'; + +export default function App() { + return ( + + + + ); +} diff --git a/apps/frontend/src/app/index.ts b/apps/frontend/src/app/index.ts new file mode 100644 index 0000000..c866729 --- /dev/null +++ b/apps/frontend/src/app/index.ts @@ -0,0 +1 @@ +export { default as App } from './App'; diff --git a/apps/frontend/src/app/layouts/AppLayout.tsx b/apps/frontend/src/app/layouts/AppLayout.tsx new file mode 100644 index 0000000..0a4918a --- /dev/null +++ b/apps/frontend/src/app/layouts/AppLayout.tsx @@ -0,0 +1,142 @@ +import { Outlet, Link, useNavigate } from 'react-router-dom'; +import { SearchBar } from '@/components/SearchBar'; +import { useSession } from '@/entities/session/model/useSession'; + +export function AppLayout() { + const { isAuthenticated, user, logout } = useSession(); + const navigate = useNavigate(); + + async function handleLogout() { + await logout(); + navigate('/'); + } + + return ( +
+
+ + MoexVibe + +
+ +
+ + Портфели + + + Брокер + + + Скринер + + +
+ {isAuthenticated ? ( + <> + + {user?.name || user?.email} + + + + ) : ( + + Войти + + )} +
+
+
+ +
+
+ ); +} diff --git a/apps/frontend/src/app/layouts/index.ts b/apps/frontend/src/app/layouts/index.ts new file mode 100644 index 0000000..763c036 --- /dev/null +++ b/apps/frontend/src/app/layouts/index.ts @@ -0,0 +1 @@ +export { AppLayout } from './AppLayout'; diff --git a/apps/frontend/src/app/providers/AppProviders.tsx b/apps/frontend/src/app/providers/AppProviders.tsx new file mode 100644 index 0000000..222bc49 --- /dev/null +++ b/apps/frontend/src/app/providers/AppProviders.tsx @@ -0,0 +1,21 @@ +import { type ReactNode } from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { SessionProvider } from './SessionProvider'; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: 2, + staleTime: 900_000, + refetchOnWindowFocus: false, + }, + }, +}); + +export function AppProviders({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} diff --git a/apps/frontend/src/app/providers/SessionProvider.test.tsx b/apps/frontend/src/app/providers/SessionProvider.test.tsx new file mode 100644 index 0000000..f7750ce --- /dev/null +++ b/apps/frontend/src/app/providers/SessionProvider.test.tsx @@ -0,0 +1,74 @@ +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 { SessionContext } from '@/entities/session/model/sessionContext'; +import { SessionProvider } from './SessionProvider'; +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(SessionContext); + if (!ctx) return
no context
; + return ( +
+ {ctx.isAuthenticated ? 'authenticated' : 'anonymous'} + {ctx.user?.email ?? ''} + + + + +
+ ); +} + +describe('SessionProvider', () => { + 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('session')).toHaveTextContent('anonymous'); + }); + }); + + it('restores session on mount when refresh succeeds', async () => { + renderWithProviders(); + await waitFor(() => { + expect(screen.getByTestId('session')).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('session')).toHaveTextContent('anonymous')); + await user.click(screen.getByRole('button', { name: 'login' })); + await waitFor(() => { + expect(screen.getByTestId('session')).toHaveTextContent('authenticated'); + }); + }); + + it('updates state after logout', async () => { + const user = userEvent.setup(); + renderWithProviders(); + await waitFor(() => expect(screen.getByTestId('session')).toHaveTextContent('authenticated')); + await user.click(screen.getByRole('button', { name: 'logout' })); + await waitFor(() => { + expect(screen.getByTestId('session')).toHaveTextContent('anonymous'); + }); + }); +}); diff --git a/apps/frontend/src/app/providers/SessionProvider.tsx b/apps/frontend/src/app/providers/SessionProvider.tsx new file mode 100644 index 0000000..871930d --- /dev/null +++ b/apps/frontend/src/app/providers/SessionProvider.tsx @@ -0,0 +1,115 @@ +import { useState, useEffect, useCallback, type ReactNode } from 'react'; +import * as sessionApi from '@/entities/session/api/sessionApi'; +import { SessionContext, type SessionContextValue } from '@/entities/session/model/sessionContext'; +import { setOnUnauthorized } from '@/shared/api/client'; +import type { UserResponse } from '@/shared/api/responses'; + +export function SessionProvider({ children }: { children: ReactNode }) { + const [user, setUser] = useState(null); + const [accessToken, setAccessTokenState] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [initialized, setInitialized] = useState(false); + + const updateSession = useCallback((authData: { user: UserResponse; accessToken: string }) => { + setUser(authData.user); + setAccessTokenState(authData.accessToken); + }, []); + + const clearSession = useCallback(() => { + setUser(null); + setAccessTokenState(null); + }, []); + + const login = useCallback( + async (email: string, password: string) => { + const result = await sessionApi.login(email, password); + updateSession(result); + }, + [updateSession], + ); + + const register = useCallback( + async (email: string, password: string, name?: string) => { + const result = await sessionApi.register(email, password, name); + updateSession(result); + }, + [updateSession], + ); + + const logout = useCallback(async () => { + try { + await sessionApi.logout(); + } catch { + // ignore network errors on logout + } + clearSession(); + }, [clearSession]); + + const updateProfileFn = useCallback(async (data: { name?: string }) => { + const result = await sessionApi.updateProfile(data); + setUser(result); + }, []); + + // Try to restore session on mount + useEffect(() => { + let mounted = true; + + async function init() { + try { + const result = await sessionApi.refresh(); + if (mounted) { + updateSession(result); + } + } catch { + // No valid session + } finally { + if (mounted) { + setIsLoading(false); + setInitialized(true); + } + } + } + + init(); + + return () => { + mounted = false; + }; + }, [updateSession]); + + // Set up auto-logout on unauthorized + useEffect(() => { + setOnUnauthorized(() => { + clearSession(); + }); + }, [clearSession]); + + if (!initialized && isLoading) { + return ( +
+ Загрузка... +
+ ); + } + + const value: SessionContextValue = { + user, + accessToken, + isAuthenticated: !!user, + isLoading, + login, + register, + logout, + updateProfile: updateProfileFn, + }; + + return {children}; +} diff --git a/apps/frontend/src/app/providers/index.ts b/apps/frontend/src/app/providers/index.ts new file mode 100644 index 0000000..daab25a --- /dev/null +++ b/apps/frontend/src/app/providers/index.ts @@ -0,0 +1,2 @@ +export { SessionProvider } from './SessionProvider'; +export { AppProviders } from './AppProviders'; diff --git a/apps/frontend/src/app/routing/AppRoutes.tsx b/apps/frontend/src/app/routing/AppRoutes.tsx new file mode 100644 index 0000000..96fdc16 --- /dev/null +++ b/apps/frontend/src/app/routing/AppRoutes.tsx @@ -0,0 +1,77 @@ +import { Routes, Route } from 'react-router-dom'; +import { AppLayout } from '../layouts/AppLayout'; +import { ProtectedRoute } from './ProtectedRoute'; +import { HomePage } from '@/pages/HomePage'; +import { StockPage } from '@/pages/StockPage'; +import { BondPage } from '@/pages/BondPage'; +import { LoginPage } from '@/pages/LoginPage'; +import { RegisterPage } from '@/pages/RegisterPage'; +import { ProfilePage } from '@/pages/ProfilePage'; +import { PortfoliosListPage } from '@/pages/portfolios/PortfoliosListPage'; +import { PortfolioDetailPage } from '@/pages/portfolios/PortfolioDetailPage'; +import { ScreenerPage } from '@/pages/screener/ScreenerPage'; +import { BrokerAccountsPage } from '@/pages/broker-accounts'; +import { BrokerAccountLayout } from '@/entities/broker-account/ui/BrokerAccountLayout'; +import { BrokerAccountOverviewPage } from '@/pages/broker-account'; +import { BrokerPositionsPage } from '@/pages/broker-positions'; +import { BrokerOperationsPage } from '@/pages/broker-operations'; + +export function AppRoutes() { + return ( + + }> + } /> + } /> + } /> + } /> + } /> + } /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + > + } /> + } /> + } /> + } /> + + + + ); +} diff --git a/apps/frontend/src/app/routing/ProtectedRoute.tsx b/apps/frontend/src/app/routing/ProtectedRoute.tsx new file mode 100644 index 0000000..d5fccb5 --- /dev/null +++ b/apps/frontend/src/app/routing/ProtectedRoute.tsx @@ -0,0 +1,29 @@ +import { Navigate, useLocation } from 'react-router-dom'; +import { useSession } from '@/entities/session/model/useSession'; +import type { ReactNode } from 'react'; + +export function ProtectedRoute({ children }: { children: ReactNode }) { + const { isAuthenticated, isLoading } = useSession(); + const location = useLocation(); + + if (isLoading) { + return ( +
+ Загрузка... +
+ ); + } + + if (!isAuthenticated) { + return ; + } + + return <>{children}; +} diff --git a/apps/frontend/src/app/routing/index.ts b/apps/frontend/src/app/routing/index.ts new file mode 100644 index 0000000..6220355 --- /dev/null +++ b/apps/frontend/src/app/routing/index.ts @@ -0,0 +1,2 @@ +export { AppRoutes } from './AppRoutes'; +export { ProtectedRoute } from './ProtectedRoute'; diff --git a/apps/frontend/src/components/Layout.tsx b/apps/frontend/src/components/Layout.tsx index 20ed33a..e66f8a1 100644 --- a/apps/frontend/src/components/Layout.tsx +++ b/apps/frontend/src/components/Layout.tsx @@ -1,142 +1 @@ -import { Outlet, Link, useNavigate } from 'react-router-dom'; -import { SearchBar } from './SearchBar'; -import { useAuth } from '../hooks/useAuth'; - -export function Layout() { - const { isAuthenticated, user, logout } = useAuth(); - const navigate = useNavigate(); - - async function handleLogout() { - await logout(); - navigate('/'); - } - - return ( -
-
- - MoexVibe - -
- -
- - Портфели - - - Брокер - - - Скринер - - -
- {isAuthenticated ? ( - <> - - {user?.name || user?.email} - - - - ) : ( - - Войти - - )} -
-
-
- -
-
- ); -} +export { AppLayout as Layout } from '../app/layouts/AppLayout'; diff --git a/apps/frontend/src/components/ProtectedRoute.tsx b/apps/frontend/src/components/ProtectedRoute.tsx index 813095a..01fc94a 100644 --- a/apps/frontend/src/components/ProtectedRoute.tsx +++ b/apps/frontend/src/components/ProtectedRoute.tsx @@ -1,29 +1 @@ -import { Navigate, useLocation } from 'react-router-dom'; -import { useAuth } from '../hooks/useAuth'; -import type { ReactNode } from 'react'; - -export function ProtectedRoute({ children }: { children: ReactNode }) { - const { isAuthenticated, isLoading } = useAuth(); - const location = useLocation(); - - if (isLoading) { - return ( -
- Загрузка... -
- ); - } - - if (!isAuthenticated) { - return ; - } - - return <>{children}; -} +export { ProtectedRoute } from '../app/routing/ProtectedRoute'; diff --git a/apps/frontend/src/context/AuthContext.tsx b/apps/frontend/src/context/AuthContext.tsx index 85197d2..d8f311a 100644 --- a/apps/frontend/src/context/AuthContext.tsx +++ b/apps/frontend/src/context/AuthContext.tsx @@ -1,131 +1,5 @@ -import { createContext, useState, useEffect, useCallback, type ReactNode } from 'react'; -import * as authApi from '../api/auth'; -import { setOnUnauthorized } from '@/shared/api/client'; -import type { UserResponse } from '@/shared/api/responses'; - -export interface AuthContextValue { - user: UserResponse | null; - accessToken: string | null; - isAuthenticated: boolean; - isLoading: boolean; - login: (email: string, password: string) => Promise; - register: (email: string, password: string, name?: string) => Promise; - logout: () => Promise; - updateProfile: (data: { name?: string }) => Promise; -} - -export const AuthContext = createContext(null); - -export function AuthProvider({ children }: { children: ReactNode }) { - const [user, setUser] = useState(null); - const [accessToken, setAccessTokenState] = useState(null); - const [isLoading, setIsLoading] = useState(true); - const [initialized, setInitialized] = useState(false); - - const updateSession = useCallback((authData: { user: UserResponse; accessToken: string }) => { - setUser(authData.user); - setAccessTokenState(authData.accessToken); - }, []); - - const clearSession = useCallback(() => { - setUser(null); - setAccessTokenState(null); - }, []); - - 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 logout = useCallback(async () => { - try { - await authApi.logout(); - } catch { - // ignore network errors on logout - } - clearSession(); - }, [clearSession]); - - const updateProfileFn = useCallback(async (data: { name?: string }) => { - const result = await authApi.updateProfile(data); - setUser(result); - }, []); - - // Try to restore session on mount - useEffect(() => { - let mounted = true; - - async function init() { - try { - const result = await authApi.refresh(); - if (mounted) { - updateSession(result); - } - } catch { - // No valid session - } finally { - if (mounted) { - setIsLoading(false); - setInitialized(true); - } - } - } - - init(); - - return () => { - mounted = false; - }; - }, [updateSession]); - - // Set up auto-logout on unauthorized - useEffect(() => { - setOnUnauthorized(() => { - clearSession(); - }); - }, [clearSession]); - - if (!initialized && isLoading) { - return ( -
- Загрузка... -
- ); - } - - return ( - - {children} - - ); -} +export { + SessionContext as AuthContext, + type SessionContextValue as AuthContextValue, +} from '@/entities/session/model/sessionContext'; +export { SessionProvider as AuthProvider } from '@/app/providers/SessionProvider'; diff --git a/apps/frontend/src/entities/session/api/sessionApi.test.ts b/apps/frontend/src/entities/session/api/sessionApi.test.ts new file mode 100644 index 0000000..af29bfd --- /dev/null +++ b/apps/frontend/src/entities/session/api/sessionApi.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 '@/shared/api/client'; +import { login, register, refresh, logout, getMe, updateProfile } from './sessionApi'; + +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/entities/session/api/sessionApi.ts b/apps/frontend/src/entities/session/api/sessionApi.ts new file mode 100644 index 0000000..83d9df3 --- /dev/null +++ b/apps/frontend/src/entities/session/api/sessionApi.ts @@ -0,0 +1,52 @@ +import { request, setAccessToken } from '@/shared/api/client'; +import type { AuthResponse, UserResponse } from '@/shared/api/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, + }); + 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, + }); + setAccessToken(result.data.accessToken); + return result.data; +} + +export async function refresh() { + 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', + }); + setAccessToken(null); + return result.data; +} + +export async function getMe() { + const result = await request('/api/v1/auth/me'); + return result.data; +} + +export async function updateProfile(data: { name?: string }) { + const result = await request('/api/v1/auth/me', undefined, { + method: 'PATCH', + body: data, + }); + return result.data; +} diff --git a/apps/frontend/src/entities/session/index.ts b/apps/frontend/src/entities/session/index.ts new file mode 100644 index 0000000..13c5c49 --- /dev/null +++ b/apps/frontend/src/entities/session/index.ts @@ -0,0 +1,3 @@ +export { login, register, refresh, logout, getMe, updateProfile } from './api/sessionApi'; +export { SessionContext, type SessionContextValue } from './model/sessionContext'; +export { useSession } from './model/useSession'; diff --git a/apps/frontend/src/entities/session/model/sessionContext.ts b/apps/frontend/src/entities/session/model/sessionContext.ts new file mode 100644 index 0000000..36ef2b9 --- /dev/null +++ b/apps/frontend/src/entities/session/model/sessionContext.ts @@ -0,0 +1,15 @@ +import { createContext } from 'react'; +import type { UserResponse } from '@/shared/api/responses'; + +export interface SessionContextValue { + user: UserResponse | null; + accessToken: string | null; + isAuthenticated: boolean; + isLoading: boolean; + login: (email: string, password: string) => Promise; + register: (email: string, password: string, name?: string) => Promise; + logout: () => Promise; + updateProfile: (data: { name?: string }) => Promise; +} + +export const SessionContext = createContext(null); diff --git a/apps/frontend/src/entities/session/model/useSession.test.tsx b/apps/frontend/src/entities/session/model/useSession.test.tsx new file mode 100644 index 0000000..c1ca92f --- /dev/null +++ b/apps/frontend/src/entities/session/model/useSession.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 { useSession } from './useSession'; +import { type ReactNode } from 'react'; + +function createWrapper() { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return function Wrapper({ children }: { children: ReactNode }) { + return ( + + {children} + + ); + }; +} + +describe('useSession', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('returns session context with user after mount', async () => { + const { result } = renderHook(() => useSession(), { 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(() => useSession(), { 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(() => useSession(), { 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(() => useSession(), { 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(() => useSession(), { + wrapper: ({ children }) => ( + {children} + ), + }); + }).toThrow('useSession must be used within a SessionProvider'); + }); +}); diff --git a/apps/frontend/src/entities/session/model/useSession.ts b/apps/frontend/src/entities/session/model/useSession.ts new file mode 100644 index 0000000..932eb6e --- /dev/null +++ b/apps/frontend/src/entities/session/model/useSession.ts @@ -0,0 +1,10 @@ +import { useContext } from 'react'; +import { SessionContext, type SessionContextValue } from './sessionContext'; + +export function useSession(): SessionContextValue { + const ctx = useContext(SessionContext); + if (!ctx) { + throw new Error('useSession must be used within a SessionProvider'); + } + return ctx; +} diff --git a/apps/frontend/src/hooks/useAuth.test.tsx b/apps/frontend/src/hooks/useAuth.test.tsx index d22e0bc..1c33e0c 100644 --- a/apps/frontend/src/hooks/useAuth.test.tsx +++ b/apps/frontend/src/hooks/useAuth.test.tsx @@ -58,6 +58,6 @@ describe('useAuth', () => { {children} ), }); - }).toThrow('useAuth must be used within an AuthProvider'); + }).toThrow('useSession must be used within a SessionProvider'); }); }); diff --git a/apps/frontend/src/hooks/useAuth.ts b/apps/frontend/src/hooks/useAuth.ts index d46b2d3..a89b4ca 100644 --- a/apps/frontend/src/hooks/useAuth.ts +++ b/apps/frontend/src/hooks/useAuth.ts @@ -1,10 +1 @@ -import { useContext } from 'react'; -import { AuthContext, type AuthContextValue } from '../context/AuthContext'; - -export function useAuth(): AuthContextValue { - const ctx = useContext(AuthContext); - if (!ctx) { - throw new Error('useAuth must be used within an AuthProvider'); - } - return ctx; -} +export { useSession as useAuth } from '../entities/session/model/useSession'; diff --git a/apps/frontend/src/main.tsx b/apps/frontend/src/main.tsx index e515e37..bfbaf47 100644 --- a/apps/frontend/src/main.tsx +++ b/apps/frontend/src/main.tsx @@ -1,26 +1,13 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { AuthProvider } from './context/AuthContext'; +import { AppProviders } from './app/providers/AppProviders'; import App from './App'; import './styles.css'; -const queryClient = new QueryClient({ - defaultOptions: { - queries: { - retry: 2, - staleTime: 900_000, - refetchOnWindowFocus: false, - }, - }, -}); - ReactDOM.createRoot(document.getElementById('root')!).render( - - - - - + + + , ); diff --git a/apps/frontend/src/pages/LoginPage.tsx b/apps/frontend/src/pages/LoginPage.tsx index 883927c..e189b32 100644 --- a/apps/frontend/src/pages/LoginPage.tsx +++ b/apps/frontend/src/pages/LoginPage.tsx @@ -1,11 +1,11 @@ import { useState, type FormEvent } from 'react'; import { Link, useNavigate, useSearchParams } from 'react-router-dom'; -import { useAuth } from '../hooks/useAuth'; +import { useSession } from '@/entities/session'; export function LoginPage() { const navigate = useNavigate(); const [searchParams] = useSearchParams(); - const { login } = useAuth(); + const { login } = useSession(); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [error, setError] = useState(''); diff --git a/apps/frontend/src/pages/ProfilePage.tsx b/apps/frontend/src/pages/ProfilePage.tsx index 78fd905..a804be9 100644 --- a/apps/frontend/src/pages/ProfilePage.tsx +++ b/apps/frontend/src/pages/ProfilePage.tsx @@ -1,8 +1,8 @@ import { useState, type FormEvent } from 'react'; -import { useAuth } from '../hooks/useAuth'; +import { useSession } from '@/entities/session'; export function ProfilePage() { - const { user, updateProfile } = useAuth(); + const { user, updateProfile } = useSession(); const [name, setName] = useState(user?.name || ''); const [saving, setSaving] = useState(false); const [message, setMessage] = useState(''); diff --git a/apps/frontend/src/pages/RegisterPage.tsx b/apps/frontend/src/pages/RegisterPage.tsx index cce8af3..77d3b7f 100644 --- a/apps/frontend/src/pages/RegisterPage.tsx +++ b/apps/frontend/src/pages/RegisterPage.tsx @@ -1,11 +1,11 @@ import { useState, type FormEvent } from 'react'; import { Link, useNavigate, useSearchParams } from 'react-router-dom'; -import { useAuth } from '../hooks/useAuth'; +import { useSession } from '@/entities/session'; export function RegisterPage() { const navigate = useNavigate(); const [searchParams] = useSearchParams(); - const { register } = useAuth(); + const { register } = useSession(); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState(''); diff --git a/apps/frontend/src/routes.tsx b/apps/frontend/src/routes.tsx index d757965..7df1046 100644 --- a/apps/frontend/src/routes.tsx +++ b/apps/frontend/src/routes.tsx @@ -1,77 +1 @@ -import { Routes, Route } from 'react-router-dom'; -import { Layout } from './components/Layout'; -import { HomePage } from './pages/HomePage'; -import { StockPage } from './pages/StockPage'; -import { BondPage } from './pages/BondPage'; -import { LoginPage } from './pages/LoginPage'; -import { RegisterPage } from './pages/RegisterPage'; -import { ProfilePage } from './pages/ProfilePage'; -import { ProtectedRoute } from './components/ProtectedRoute'; -import { PortfoliosListPage } from './pages/portfolios/PortfoliosListPage'; -import { PortfolioDetailPage } from './pages/portfolios/PortfolioDetailPage'; -import { ScreenerPage } from './pages/screener/ScreenerPage'; -import { BrokerAccountsPage } from './pages/broker-accounts'; -import { BrokerAccountLayout } from './entities/broker-account/ui/BrokerAccountLayout'; -import { BrokerAccountOverviewPage } from './pages/broker-account'; -import { BrokerPositionsPage } from './pages/broker-positions'; -import { BrokerOperationsPage } from './pages/broker-operations'; - -export function AppRoutes() { - return ( - - }> - } /> - } /> - } /> - } /> - } /> - } /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - > - } /> - } /> - } /> - } /> - - - - ); -} +export { AppRoutes } from './app/routing/AppRoutes';