fix: move test SessionProvider and SessionContext to shared layer for FSD compliance

This commit is contained in:
Sergey Krylov 2026-06-20 23:09:15 +03:00
parent 79011c779f
commit 943a6aec2d
7 changed files with 1493 additions and 86 deletions

View File

@ -1,15 +1 @@
import { createContext } from 'react'; export { SessionContext, type SessionContextValue } from '@/shared/lib/session-context';
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<void>;
register: (email: string, password: string, name?: string) => Promise<void>;
logout: () => Promise<void>;
updateProfile: (data: { name?: string }) => Promise<void>;
}
export const SessionContext = createContext<SessionContextValue | null>(null);

View File

@ -1,13 +1,26 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { renderHook } from '@testing-library/react'; import { renderHook } from '@testing-library/react';
import { SessionContext, type SessionState } from './sessionContext'; import { SessionContext } from './sessionContext';
import { useSession } from './useSession'; import { useSession } from './useSession';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
type SessionState = {
user: { id: number; email: string; name: string; role: string } | null;
accessToken: string | null;
isAuthenticated: boolean;
isLoading: boolean;
login: () => Promise<void>;
logout: () => Promise<void>;
register: () => Promise<void>;
updateProfile: () => Promise<void>;
refreshSession: () => Promise<void>;
};
const mockSession: SessionState = { const mockSession: SessionState = {
user: { id: 1, email: 'user@test.com', name: 'Test User', role: 'user' }, user: { id: 1, email: 'user@test.com', name: 'Test User', role: 'user' },
accessToken: 'mock-access-token', accessToken: 'mock-access-token',
isAuthenticated: true, isAuthenticated: true,
isLoading: false,
login: vi.fn().mockResolvedValue(undefined), login: vi.fn().mockResolvedValue(undefined),
logout: vi.fn(), logout: vi.fn(),
register: vi.fn().mockResolvedValue(undefined), register: vi.fn().mockResolvedValue(undefined),

View File

@ -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<void>;
register: (email: string, password: string, name?: string) => Promise<void>;
logout: () => Promise<void>;
updateProfile: (data: { name?: string }) => Promise<void>;
}
export const SessionContext = createContext<SessionContextValue | null>(null);

View File

@ -1,81 +1,55 @@
import { useState, useEffect, useCallback, type ReactNode } from 'react'; import { type ReactNode, useEffect, useState, useCallback } from 'react';
import { SessionContext, type SessionContextValue } from '@/entities/session'; import { SessionContext, type SessionContextValue } from '@/shared/lib/session-context';
import * as sessionApi from '@/entities/session'; import * as sessionApi from '@/entities/session/api/sessionApi';
import type { UserResponse } from '@/shared/api/responses';
export function TestSessionProvider({ children }: { children: ReactNode }) { export function TestSessionProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<UserResponse | null>(null); const [user, setUser] = useState<SessionContextValue['user']>(null);
const [accessToken, setAccessTokenState] = useState<string | null>(null); const [accessToken, setAccessToken] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true); 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);
}, []);
useEffect(() => { useEffect(() => {
let mounted = true; let cancelled = false;
sessionApi
async function init() { .refresh()
try { .then((result) => {
const result = await sessionApi.refresh(); if (!cancelled) {
if (mounted) { setUser(result.user);
updateSession(result); setAccessToken(result.accessToken);
} }
} catch { })
// No valid session .catch(() => {})
} finally { .finally(() => {
if (mounted) { if (!cancelled) setIsLoading(false);
setIsLoading(false); });
setInitialized(true);
}
}
}
init();
return () => { return () => {
mounted = false; cancelled = true;
}; };
}, [updateSession]); }, []);
if (!initialized && isLoading) { const login = useCallback(async (email: string, password: string) => {
const result = await sessionApi.login(email, password);
setUser(result.user);
setAccessToken(result.accessToken);
}, []);
const register = useCallback(async (email: string, password: string, name?: string) => {
const result = await sessionApi.register(email, password, name);
setUser(result.user);
setAccessToken(result.accessToken);
}, []);
const logout = useCallback(async () => {
await sessionApi.logout();
setUser(null);
setAccessToken(null);
}, []);
const updateProfile = useCallback(async (data: { name?: string }) => {
const updated = await sessionApi.updateProfile(data);
setUser(updated);
}, []);
if (isLoading) {
return <div>Загрузка...</div>; return <div>Загрузка...</div>;
} }
@ -87,7 +61,7 @@ export function TestSessionProvider({ children }: { children: ReactNode }) {
login, login,
register, register,
logout, logout,
updateProfile: updateProfileFn, updateProfile,
}; };
return <SessionContext.Provider value={value}>{children}</SessionContext.Provider>; return <SessionContext.Provider value={value}>{children}</SessionContext.Provider>;

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,96 @@
# FSD Frontend Refactor
## Цель
Довести фронтенд-архитектуру до эталонного Feature-Sliced Design (FSD) — устранить существующие нарушения, вынести жирные страницы в виджеты/фичи, внедрить автоматический контроль FSD-правил.
Текущее состояние: 4.2/5 по FSD-зрелости. Цель: 5/5.
## Задачи
### 1. Исправить нарушение слоёв в shared/lib/test
- `shared/lib/test/test-utils.tsx` импортирует `SessionProvider` из `@/app/providers`
- Это единственное нарушение FSD (shared → app)
- Решение: вынести `TestSessionProvider` в `shared/lib/test/` и переключить `test-utils.tsx` на него
- Тесты не должны сломаться: `TestSessionProvider` предоставляет те же значения контекста (замоканые)
### 2. Вынести BrokerPositionTable из страницы в виджет
- `pages/broker-positions/ui/BrokerPositionsPage.tsx` (310 строк) содержит внутренние компоненты:
- `BrokerPositionTable` (~190 строк) — таблица с пагинацией, skeleton, inline-стилями
- `PositionTicker` (~20 строк) — ссылка на инструмент
- `formatQuantity` — хелпер
- Решение: создать `widgets/broker-positions-table/` с:
- `ui/BrokerPositionTable.tsx`
- `ui/PositionTicker.tsx`
- `index.ts`
- Страница сокращается до ~100 строк (только cursor-логика + композиция)
### 3. Вынести BrokerSummary, BrokerAssetCards, BrokerOverviewSkeleton в виджет
- `pages/broker-account/ui/BrokerAccountOverviewPage.tsx` (160 строк) содержит:
- `BrokerSummary` — сводка портфеля (карточки стоимости и денег)
- `BrokerAssetCards` — карточки классов активов (акции/облигации)
- `BrokerOverviewSkeleton` — скелетон загрузки
- `allocationPercent`, `formatAllocationPercent` — хелперы
- Решение: создать `widgets/broker-overview/` с:
- `ui/BrokerSummary.tsx`
- `ui/BrokerAssetCards.tsx`
- `ui/BrokerOverviewSkeleton.tsx`
- `index.ts`
- Страница сокращается до ~40 строк (только loading/error guard + композиция)
### 4. Вынести AddPositionForm в фичу
- `pages/portfolios/ui/PortfolioDetailPage.tsx` (290 строк) содержит inline-форму добавления позиции:
- 4 state-переменные (newSecid, newQty, newPrice, newDate)
- 4 input-поля с inline-стилями
- Валидация и submit
- Это полноценная бизнес-фича, не место в странице
- Решение: создать `features/add-position/` с:
- `api/useAddPosition.ts` — мутация (обёртка над usePositionMutations)
- `model/useAddPositionForm.ts` — управление формой
- `ui/AddPositionForm.tsx` — UI формы
- `index.ts`
- Страница сокращается до ~140 строк (showAddForm toggle + `<AddPositionForm portfolioId={id} />`)
### 5. Вынести cursor-пагинацию в shared-хук
- 25 строк cursor-логики дублируются в:
- `pages/broker-positions/ui/BrokerPositionsPage.tsx`
- `pages/broker-operations/ui/BrokerOperationsPage.tsx`
- Решение: создать `shared/lib/useCursorPagination.ts`
```ts
function useCursorPagination() {
// cursor, pageNumber, handleNext(cursor), handlePrevious, reset
}
```
### 6. Добавить ESLint-плагин FSD
- Текущий `import/no-restricted-paths` проверяет только межслойные границы
- Плагин `@conarti/eslint-plugin-feature-sliced` добавит:
- Проверки public API (запрет импорта из внутренних модулей в обход index.ts)
- Проверки сегментов (api/ui/model не импортируют друг друга напрямую)
- Решение: установить плагин и включить recommended rules
## Критерии приёмки (Acceptance Criteria)
- [ ] AC1: `test-utils.tsx` не импортирует из `@/app/*`
- [ ] AC2: `widgets/broker-positions-table` создан, страница использует его
- [ ] AC3: `widgets/broker-overview` создан, страница использует его
- [ ] AC4: `features/add-position` создана, страница использует её
- [ ] AC5: `shared/lib/useCursorPagination.ts` создан, обе страницы используют его
- [ ] AC6: `@conarti/eslint-plugin-feature-sliced` установлен и настроен
- [ ] AC7: `npm run lint` проходит без ошибок
- [ ] AC8: `npm run build` проходит без ошибок
- [ ] AC9: Все существующие тесты проходят (включая тесты рефакторимых компонентов)
- [ ] AC10: Визуально поведение страниц не изменилось
## Ограничения
- Не менять API-контракты компонентов (пропсы, типы). Только перемещение кода.
- Не добавлять новую функциональность — только рефакторинг.
- inline-стили переносятся вместе с компонентами; централизация UI-кита — отдельная задача.

View File

@ -0,0 +1,13 @@
# FSD Frontend Refactor — Tasks
**Ветка:** `codex/fsd-frontend-refactor`
**Порядок:** последовательно, каждый task — независимый коммит.
- [ ] **Task 1:** Исправить нарушение shared → app — TestSessionProvider
- [ ] **Task 2:** Вынести BrokerPositionTable из страницы в widgets
- [ ] **Task 3:** Вынести BrokerSummary, BrokerAssetCards, BrokerOverviewSkeleton в widgets
- [ ] **Task 4:** Вынести AddPositionForm в features/add-position
- [ ] **Task 5:** Вынести useCursorPagination в shared/lib/
- [ ] **Task 6:** Добавить @conarti/eslint-plugin-feature-sliced
- [ ] **Финальная проверка:** `npm run build -w apps/frontend && npm run lint -w apps/frontend && npm run -w apps/frontend test run`