refactor(frontend): remove FSD shim files and dead code
- Break entity shim chain: brokerPositionApi, brokerOperationApi now use shared/api/client directly - Remove all 43 shim re-export files (api/, hooks/, context/, components/, pages/ flat shims, routes.tsx) - Remove entire pages/broker/ dead code directory - Remove api/broker.ts after breaking the shim chain - Switch auth consumers from AuthProvider (shim) to SessionProvider (FSD) - Fix api/screener.ts imports to use @/shared/api - Update frontend hooks.md and routes.md documentation - 2840 lines removed, 71 lines added
This commit is contained in:
parent
6f9e368126
commit
b069575fbb
@ -11,13 +11,16 @@
|
|||||||
| `useBond(secid)` | `['bond', secid]` | 900s | Спецификация облигации |
|
| `useBond(secid)` | `['bond', secid]` | 900s | Спецификация облигации |
|
||||||
| `useBondCandles(secid, interval, from, till)` | `['bondCandles', secid, interval, from, till]` | 3600s | Свечи облигации |
|
| `useBondCandles(secid, interval, from, till)` | `['bondCandles', secid, interval, from, till]` | 3600s | Свечи облигации |
|
||||||
|
|
||||||
## Primary и legacy paths
|
## Public API
|
||||||
|
|
||||||
- `useSearch` primary path: `apps/frontend/src/entities/search/model/useSearch.ts`
|
- search hook: `entities/search/index.ts`
|
||||||
- Public API для search slice: `apps/frontend/src/entities/search/index.ts`
|
- stock hooks: `entities/stock/index.ts`
|
||||||
- Legacy compatibility path: `apps/frontend/src/hooks/useSearch.ts` — это shim, который
|
- bond hooks: `entities/bond/index.ts`
|
||||||
реэкспортирует primary hook
|
- portfolio hooks: `entities/portfolio/index.ts`
|
||||||
- Market pages используют `entities/search` как source of truth, а не historical hook path
|
- broker-account hooks: `entities/broker-account/index.ts`
|
||||||
|
- broker-position hooks: `entities/broker-position/index.ts`
|
||||||
|
- broker-operation hooks: `entities/broker-operation/index.ts`
|
||||||
|
- session hooks: `entities/session/index.ts`
|
||||||
|
|
||||||
## Конфигурация Query
|
## Конфигурация Query
|
||||||
|
|
||||||
@ -35,19 +38,9 @@ const queryClient = new QueryClient({
|
|||||||
|
|
||||||
## Паттерн hook
|
## Паттерн hook
|
||||||
|
|
||||||
Для historical hooks и FSD hooks общий принцип один и тот же:
|
1. Хук вызывает domain API helper из `entities/*/api/` или `shared/api/client`
|
||||||
|
2. Извлекает `res.data` (ответ API обёрнут в `{ data, meta }`)
|
||||||
1. Хук вызывает ближайший domain/shared API helper
|
3. Типизируется через response types из `shared/api/responses.ts`
|
||||||
2. Извлекает `res.data` (ответ MOEX обёрнут в `{ data, meta }`)
|
|
||||||
3. Типизируется через актуальные response types из `shared/api/responses.ts`
|
|
||||||
|
|
||||||
В legacy-слое helper может приходить из historical `api/*`, а в FSD-срезах — из domain API
|
|
||||||
файлов вроде `entities/stock/api/stockApi.ts` или `entities/bond/api/bondApi.ts`.
|
|
||||||
|
|
||||||
Для FSD-срезов domain-specific hooks постепенно переезжают ближе к своим сущностям. Для market
|
|
||||||
pages это уже сделано для `entities/search`, `entities/stock` и `entities/bond`; для portfolio
|
|
||||||
домен уже использует `entities/portfolio`; legacy imports сохраняются только как transitional
|
|
||||||
shim-слой там, где миграция ещё не завершена.
|
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
export function useStock(secid: string) {
|
export function useStock(secid: string) {
|
||||||
|
|||||||
@ -16,14 +16,15 @@ Source of truth для маршрутов: `apps/frontend/src/app/routing/AppRou
|
|||||||
| `/broker` | `BrokerAccountsPage` from `pages/broker-accounts` | Protected | Список брокерских счетов |
|
| `/broker` | `BrokerAccountsPage` from `pages/broker-accounts` | Protected | Список брокерских счетов |
|
||||||
| `/broker/:accountId` | `BrokerAccountLayout` + nested pages | Protected | Детальная область брокерского счёта |
|
| `/broker/:accountId` | `BrokerAccountLayout` + nested pages | Protected | Детальная область брокерского счёта |
|
||||||
|
|
||||||
Market route entrypoints теперь живут в:
|
Все page entrypoints живут в FSD-слоях:
|
||||||
|
|
||||||
- `apps/frontend/src/pages/home`
|
- `pages/home`
|
||||||
- `apps/frontend/src/pages/stock`
|
- `pages/stock`
|
||||||
- `apps/frontend/src/pages/bond`
|
- `pages/bond`
|
||||||
|
- `pages/broker-accounts`
|
||||||
Legacy `pages/HomePage.tsx`, `pages/StockPage.tsx`, `pages/BondPage.tsx` сохранены как shim-файлы,
|
- `pages/broker-account`
|
||||||
но не являются source of truth для новых импортов.
|
- `pages/broker-positions`
|
||||||
|
- `pages/broker-operations`
|
||||||
|
|
||||||
Все страницы обёрнуты в `AppLayout`, который содержит:
|
Все страницы обёрнуты в `AppLayout`, который содержит:
|
||||||
|
|
||||||
|
|||||||
@ -1,68 +0,0 @@
|
|||||||
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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,8 +0,0 @@
|
|||||||
export {
|
|
||||||
login,
|
|
||||||
register,
|
|
||||||
refresh,
|
|
||||||
logout,
|
|
||||||
getMe,
|
|
||||||
updateProfile,
|
|
||||||
} from '../entities/session/api/sessionApi';
|
|
||||||
@ -1,71 +0,0 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
import { getBrokerOperations, getBrokerPositions } from './broker';
|
|
||||||
|
|
||||||
describe('broker api', () => {
|
|
||||||
afterEach(() => {
|
|
||||||
vi.restoreAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('serializes operations query parameters', async () => {
|
|
||||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue({
|
|
||||||
ok: true,
|
|
||||||
json: async () => ({
|
|
||||||
data: {
|
|
||||||
data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: 'now' },
|
|
||||||
meta: { fromCache: false, cachedAt: null },
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
} as Response);
|
|
||||||
|
|
||||||
await getBrokerOperations('acc-1', { cursor: 'c1', limit: 50 });
|
|
||||||
|
|
||||||
expect(fetch).toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining('/api/v1/broker/accounts/acc-1/operations?cursor=c1&limit=50'),
|
|
||||||
expect.any(Object),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('serializes operations query parameters including operationTypes', async () => {
|
|
||||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue({
|
|
||||||
ok: true,
|
|
||||||
json: async () => ({
|
|
||||||
data: {
|
|
||||||
data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: 'now' },
|
|
||||||
meta: { fromCache: false, cachedAt: null },
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
} as Response);
|
|
||||||
|
|
||||||
await getBrokerOperations('acc-1', {
|
|
||||||
cursor: 'c1',
|
|
||||||
limit: 10,
|
|
||||||
operationTypes: 'OPERATION_TYPE_COUPON',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(fetch).toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining(
|
|
||||||
'/api/v1/broker/accounts/acc-1/operations?cursor=c1&limit=10&operationTypes=OPERATION_TYPE_COUPON',
|
|
||||||
),
|
|
||||||
expect.any(Object),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('serializes positions query parameters', async () => {
|
|
||||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue({
|
|
||||||
ok: true,
|
|
||||||
json: async () => ({
|
|
||||||
data: {
|
|
||||||
data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: 'now' },
|
|
||||||
meta: { fromCache: false, cachedAt: null },
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
} as Response);
|
|
||||||
|
|
||||||
await getBrokerPositions('acc-1', { cursor: 'pos-1', limit: 5 });
|
|
||||||
|
|
||||||
expect(fetch).toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining('/api/v1/broker/accounts/acc-1/positions?cursor=pos-1&limit=5'),
|
|
||||||
expect.any(Object),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,66 +0,0 @@
|
|||||||
import { request } from './client';
|
|
||||||
import type {
|
|
||||||
ApiResponseMeta,
|
|
||||||
BrokerAccount,
|
|
||||||
BrokerOperationsPage,
|
|
||||||
BrokerPortfolio,
|
|
||||||
BrokerPositionsPage,
|
|
||||||
} from './responses';
|
|
||||||
|
|
||||||
export type BrokerOperationQuery = {
|
|
||||||
from?: string;
|
|
||||||
to?: string;
|
|
||||||
cursor?: string;
|
|
||||||
limit?: number;
|
|
||||||
instrumentId?: string;
|
|
||||||
operationTypes?: string;
|
|
||||||
state?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function getBrokerAccounts(): Promise<{
|
|
||||||
data: BrokerAccount[];
|
|
||||||
meta: ApiResponseMeta;
|
|
||||||
}> {
|
|
||||||
return request<BrokerAccount[]>('/api/v1/broker/accounts');
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getBrokerPortfolio(accountId: string): Promise<{
|
|
||||||
data: BrokerPortfolio;
|
|
||||||
meta: ApiResponseMeta;
|
|
||||||
}> {
|
|
||||||
return request<BrokerPortfolio>(
|
|
||||||
`/api/v1/broker/accounts/${encodeURIComponent(accountId)}/portfolio`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getBrokerOperations(
|
|
||||||
accountId: string,
|
|
||||||
query: BrokerOperationQuery = {},
|
|
||||||
): Promise<{ data: BrokerOperationsPage; meta: ApiResponseMeta }> {
|
|
||||||
return request<BrokerOperationsPage>(
|
|
||||||
`/api/v1/broker/accounts/${encodeURIComponent(accountId)}/operations`,
|
|
||||||
{
|
|
||||||
from: query.from,
|
|
||||||
to: query.to,
|
|
||||||
cursor: query.cursor,
|
|
||||||
limit: query.limit ? String(query.limit) : undefined,
|
|
||||||
instrumentId: query.instrumentId,
|
|
||||||
operationTypes: query.operationTypes,
|
|
||||||
state: query.state,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getBrokerPositions(
|
|
||||||
accountId: string,
|
|
||||||
query: { cursor?: string; limit?: number; type?: string } = {},
|
|
||||||
): Promise<{ data: BrokerPositionsPage; meta: ApiResponseMeta }> {
|
|
||||||
return request<BrokerPositionsPage>(
|
|
||||||
`/api/v1/broker/accounts/${encodeURIComponent(accountId)}/positions`,
|
|
||||||
{
|
|
||||||
cursor: query.cursor,
|
|
||||||
limit: query.limit ? String(query.limit) : undefined,
|
|
||||||
type: query.type,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,153 +0,0 @@
|
|||||||
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('supports the single API envelope shape documented by Swagger', async () => {
|
|
||||||
server.use(
|
|
||||||
http.get(`${API}/test-single-envelope`, () =>
|
|
||||||
HttpResponse.json({
|
|
||||||
data: { ok: true },
|
|
||||||
meta: { fromCache: true, cachedAt: '2026-06-17T00:00:00.000Z' },
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = await request<{ ok: boolean }>('/api/v1/test-single-envelope');
|
|
||||||
|
|
||||||
expect(result).toEqual({
|
|
||||||
data: { ok: true },
|
|
||||||
meta: { fromCache: true, cachedAt: '2026-06-17T00:00:00.000Z' },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,8 +0,0 @@
|
|||||||
export {
|
|
||||||
request,
|
|
||||||
setAccessToken,
|
|
||||||
getAccessToken,
|
|
||||||
setOnUnauthorized,
|
|
||||||
getHealth,
|
|
||||||
searchSecurities,
|
|
||||||
} from '../shared/api/client';
|
|
||||||
@ -1,11 +0,0 @@
|
|||||||
export {
|
|
||||||
getPortfolios,
|
|
||||||
getPortfolio,
|
|
||||||
createPortfolio,
|
|
||||||
updatePortfolio,
|
|
||||||
deletePortfolio,
|
|
||||||
addPosition,
|
|
||||||
updatePosition,
|
|
||||||
removePosition,
|
|
||||||
getPortfolioAnalytics,
|
|
||||||
} from '../entities/portfolio/api/portfolioApi';
|
|
||||||
@ -1,32 +0,0 @@
|
|||||||
export type {
|
|
||||||
ApiResponseMeta,
|
|
||||||
ApiEnvelope,
|
|
||||||
StockMarketData,
|
|
||||||
ShareResponse,
|
|
||||||
DividendItem,
|
|
||||||
ShareHistoryItem,
|
|
||||||
BondMarketData,
|
|
||||||
BondResponse,
|
|
||||||
BondHistoryItem,
|
|
||||||
CandleItem,
|
|
||||||
SearchResultItem,
|
|
||||||
HealthResponse,
|
|
||||||
UserResponse,
|
|
||||||
AuthResponse,
|
|
||||||
Portfolio,
|
|
||||||
PositionWithPrice,
|
|
||||||
PortfolioDetail,
|
|
||||||
Position,
|
|
||||||
PortfolioSummary,
|
|
||||||
AnalyticsResponse,
|
|
||||||
ScreenerItem,
|
|
||||||
ScreenerResult,
|
|
||||||
BrokerMoney,
|
|
||||||
BrokerAccount,
|
|
||||||
BrokerPosition,
|
|
||||||
BrokerPortfolio,
|
|
||||||
BrokerOperationCategory,
|
|
||||||
BrokerOperation,
|
|
||||||
BrokerOperationsPage,
|
|
||||||
BrokerPositionsPage,
|
|
||||||
} from '../shared/api/responses';
|
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import { request } from './client';
|
import { request } from '@/shared/api/client';
|
||||||
import type { ScreenerResult } from './responses';
|
import type { ScreenerResult } from '@/shared/api/responses';
|
||||||
|
|
||||||
export interface ScreenerQuery {
|
export interface ScreenerQuery {
|
||||||
type: 'share' | 'bond';
|
type: 'share' | 'bond';
|
||||||
|
|||||||
@ -1 +0,0 @@
|
|||||||
export * from '../shared/api/types';
|
|
||||||
@ -1,71 +0,0 @@
|
|||||||
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(<BondDetails bond={bond} />);
|
|
||||||
expect(screen.getByText('ОФЗ 26238')).toBeInTheDocument();
|
|
||||||
expect(screen.getAllByText('RU000A101XU7').length).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows price as percentage', () => {
|
|
||||||
const bond = createMockBond();
|
|
||||||
render(<BondDetails bond={bond} />);
|
|
||||||
expect(screen.getByText('98.50%')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders maturity date', () => {
|
|
||||||
const bond = createMockBond();
|
|
||||||
render(<BondDetails bond={bond} />);
|
|
||||||
expect(screen.getByText('2027-05-15')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows coupon with percentage', () => {
|
|
||||||
const bond = createMockBond();
|
|
||||||
render(<BondDetails bond={bond} />);
|
|
||||||
expect(screen.getByText('36.9 ₽ (7.5%)')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows coupon without percentage when null', () => {
|
|
||||||
const bond = createMockBond({
|
|
||||||
marketData: {
|
|
||||||
...createMockBond().marketData,
|
|
||||||
couponPercent: null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
render(<BondDetails bond={bond} />);
|
|
||||||
expect(screen.getByText('36.9 ₽')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows dash for missing next coupon date', () => {
|
|
||||||
const bond = createMockBond({
|
|
||||||
marketData: {
|
|
||||||
...createMockBond().marketData,
|
|
||||||
nextCouponDate: null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
render(<BondDetails bond={bond} />);
|
|
||||||
const dashes = screen.getAllByText('—');
|
|
||||||
expect(dashes.length).toBeGreaterThanOrEqual(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows dash for null yieldToMaturity', () => {
|
|
||||||
const bond = createMockBond({
|
|
||||||
marketData: {
|
|
||||||
...createMockBond().marketData,
|
|
||||||
yieldToMaturity: null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
render(<BondDetails bond={bond} />);
|
|
||||||
expect(screen.getByText('—')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows bond type', () => {
|
|
||||||
const bond = createMockBond();
|
|
||||||
render(<BondDetails bond={bond} />);
|
|
||||||
expect(screen.getByText('ОФЗ')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1 +0,0 @@
|
|||||||
export { BondDetails } from '@/widgets/bond-details';
|
|
||||||
@ -1,57 +0,0 @@
|
|||||||
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(<Layout />);
|
|
||||||
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(<Layout />);
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText('Войти')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows user name and logout when authenticated', async () => {
|
|
||||||
renderWithProviders(<Layout />);
|
|
||||||
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(<Layout />);
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText('user@test.com')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1 +0,0 @@
|
|||||||
export { AppLayout as Layout } from '../app/layouts/AppLayout';
|
|
||||||
@ -1,21 +0,0 @@
|
|||||||
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(<PriceChart data={[]} />);
|
|
||||||
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(<PriceChart data={data} />);
|
|
||||||
expect(container.querySelector('div')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('accepts custom height', () => {
|
|
||||||
const { container } = render(<PriceChart data={[]} height={600} />);
|
|
||||||
expect(container.querySelector('div')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1 +0,0 @@
|
|||||||
export { PriceChart } from '@/widgets/price-chart';
|
|
||||||
@ -1,36 +0,0 @@
|
|||||||
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(
|
|
||||||
<ProtectedRoute>
|
|
||||||
<div data-testid="protected-content">Secret</div>
|
|
||||||
</ProtectedRoute>,
|
|
||||||
);
|
|
||||||
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(
|
|
||||||
<ProtectedRoute>
|
|
||||||
<div data-testid="protected-content">Secret</div>
|
|
||||||
</ProtectedRoute>,
|
|
||||||
{ route: '/profile' },
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByText('Загрузка...')).toBeInTheDocument();
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.queryByTestId('protected-content')).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1 +0,0 @@
|
|||||||
export { ProtectedRoute } from '../app/routing/ProtectedRoute';
|
|
||||||
@ -1,77 +0,0 @@
|
|||||||
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(
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<MemoryRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
|
||||||
<SearchBar />
|
|
||||||
</MemoryRouter>
|
|
||||||
</QueryClientProvider>,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1 +0,0 @@
|
|||||||
export { SearchBar } from '@/widgets/search-bar';
|
|
||||||
@ -1 +0,0 @@
|
|||||||
export { SkeletonBlock } from '../shared/ui/SkeletonBlock';
|
|
||||||
@ -1,63 +0,0 @@
|
|||||||
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(<StockDetails stock={stock} />);
|
|
||||||
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(<StockDetails stock={stock} />);
|
|
||||||
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(<StockDetails stock={stock} />);
|
|
||||||
const changeEl = screen.getByText('-3.00 (-1.00%)');
|
|
||||||
expect(changeEl).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows price formatted', () => {
|
|
||||||
const stock = createMockShare();
|
|
||||||
render(<StockDetails stock={stock} />);
|
|
||||||
expect(screen.getByText('289,50')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows dash for null high', () => {
|
|
||||||
const stock = createMockShare({
|
|
||||||
marketData: {
|
|
||||||
...createMockShare().marketData,
|
|
||||||
high: null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
render(<StockDetails stock={stock} />);
|
|
||||||
const dashes = screen.getAllByText('—');
|
|
||||||
expect(dashes.length).toBeGreaterThanOrEqual(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows capitalization in billions', () => {
|
|
||||||
const stock = createMockShare();
|
|
||||||
render(<StockDetails stock={stock} />);
|
|
||||||
expect(screen.getByText('6250.00 млрд ₽')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1 +0,0 @@
|
|||||||
export { StockDetails } from '@/widgets/stock-details';
|
|
||||||
@ -1 +0,0 @@
|
|||||||
export { TableSkeleton } from '../shared/ui/TableSkeleton';
|
|
||||||
@ -1,73 +0,0 @@
|
|||||||
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(
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<AuthProvider>{ui}</AuthProvider>
|
|
||||||
</QueryClientProvider>,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TestConsumer() {
|
|
||||||
const ctx = useContext(AuthContext);
|
|
||||||
if (!ctx) return <div>no context</div>;
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<span data-testid="auth">{ctx.isAuthenticated ? 'authenticated' : 'anonymous'}</span>
|
|
||||||
<span data-testid="email">{ctx.user?.email ?? ''}</span>
|
|
||||||
<button onClick={() => ctx.login('a@b.com', 'p')}>login</button>
|
|
||||||
<button onClick={() => ctx.register('a@b.com', 'p')}>register</button>
|
|
||||||
<button onClick={() => ctx.logout()}>logout</button>
|
|
||||||
<button onClick={() => ctx.updateProfile({ name: 'New' })}>updateProfile</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('AuthContext', () => {
|
|
||||||
it('starts unauthenticated when refresh fails', async () => {
|
|
||||||
server.use(http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 })));
|
|
||||||
renderWithProviders(<TestConsumer />);
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByTestId('auth')).toHaveTextContent('anonymous');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('restores session on mount when refresh succeeds', async () => {
|
|
||||||
renderWithProviders(<TestConsumer />);
|
|
||||||
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(<TestConsumer />);
|
|
||||||
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(<TestConsumer />);
|
|
||||||
await waitFor(() => expect(screen.getByTestId('auth')).toHaveTextContent('authenticated'));
|
|
||||||
await user.click(screen.getByRole('button', { name: 'logout' }));
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByTestId('auth')).toHaveTextContent('anonymous');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
export {
|
|
||||||
SessionContext as AuthContext,
|
|
||||||
type SessionContextValue as AuthContextValue,
|
|
||||||
} from '@/entities/session/model/sessionContext';
|
|
||||||
export { SessionProvider as AuthProvider } from '@/app/providers/SessionProvider';
|
|
||||||
@ -1 +1,30 @@
|
|||||||
export { getBrokerOperations, type BrokerOperationQuery } from '../../../api/broker';
|
import { request } from '@/shared/api/client';
|
||||||
|
import type { ApiResponseMeta, BrokerOperationsPage } from '@/shared/api/responses';
|
||||||
|
|
||||||
|
export type BrokerOperationQuery = {
|
||||||
|
from?: string;
|
||||||
|
to?: string;
|
||||||
|
cursor?: string;
|
||||||
|
limit?: number;
|
||||||
|
instrumentId?: string;
|
||||||
|
operationTypes?: string;
|
||||||
|
state?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getBrokerOperations(
|
||||||
|
accountId: string,
|
||||||
|
query: BrokerOperationQuery = {},
|
||||||
|
): Promise<{ data: BrokerOperationsPage; meta: ApiResponseMeta }> {
|
||||||
|
return request<BrokerOperationsPage>(
|
||||||
|
`/api/v1/broker/accounts/${encodeURIComponent(accountId)}/operations`,
|
||||||
|
{
|
||||||
|
from: query.from,
|
||||||
|
to: query.to,
|
||||||
|
cursor: query.cursor,
|
||||||
|
limit: query.limit ? String(query.limit) : undefined,
|
||||||
|
instrumentId: query.instrumentId,
|
||||||
|
operationTypes: query.operationTypes,
|
||||||
|
state: query.state,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@ -1,3 +1,16 @@
|
|||||||
import { getBrokerPositions } from '../../../api/broker';
|
import { request } from '@/shared/api/client';
|
||||||
|
import type { ApiResponseMeta, BrokerPositionsPage } from '@/shared/api/responses';
|
||||||
|
|
||||||
export { getBrokerPositions };
|
export function getBrokerPositions(
|
||||||
|
accountId: string,
|
||||||
|
query: { cursor?: string; limit?: number; type?: string } = {},
|
||||||
|
): Promise<{ data: BrokerPositionsPage; meta: ApiResponseMeta }> {
|
||||||
|
return request<BrokerPositionsPage>(
|
||||||
|
`/api/v1/broker/accounts/${encodeURIComponent(accountId)}/positions`,
|
||||||
|
{
|
||||||
|
cursor: query.cursor,
|
||||||
|
limit: query.limit ? String(query.limit) : undefined,
|
||||||
|
type: query.type,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { renderHook, waitFor } from '@testing-library/react';
|
import { renderHook, waitFor } from '@testing-library/react';
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import { AuthProvider } from '../../../context/AuthContext';
|
import { SessionProvider } from '../../../app/providers/SessionProvider';
|
||||||
import { useSession } from './useSession';
|
import { useSession } from './useSession';
|
||||||
import { type ReactNode } from 'react';
|
import { type ReactNode } from 'react';
|
||||||
|
|
||||||
@ -10,7 +10,7 @@ function createWrapper() {
|
|||||||
return function Wrapper({ children }: { children: ReactNode }) {
|
return function Wrapper({ children }: { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<AuthProvider>{children}</AuthProvider>
|
<SessionProvider>{children}</SessionProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,63 +0,0 @@
|
|||||||
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 (
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<AuthProvider>{children}</AuthProvider>
|
|
||||||
</QueryClientProvider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
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 }) => (
|
|
||||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}).toThrow('useSession must be used within a SessionProvider');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1 +0,0 @@
|
|||||||
export { useSession as useAuth } from '../entities/session/model/useSession';
|
|
||||||
@ -1 +0,0 @@
|
|||||||
export { useBond } from '../entities/bond/model/useBond';
|
|
||||||
@ -1 +0,0 @@
|
|||||||
export { useBondCandles } from '../entities/bond/model/useBondCandles';
|
|
||||||
@ -1,135 +0,0 @@
|
|||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
||||||
import { renderHook, waitFor } from '@testing-library/react';
|
|
||||||
import { type ReactNode } from 'react';
|
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
import { getBrokerPortfolio } from '../entities/broker-account/api/brokerAccountApi';
|
|
||||||
import type { BrokerAccount, BrokerPortfolio } from '@/shared/api/responses';
|
|
||||||
import { useBrokerAccountPortfolios } from './useBrokerAccountPortfolios';
|
|
||||||
|
|
||||||
vi.mock('../entities/broker-account/api/brokerAccountApi', () => ({
|
|
||||||
getBrokerPortfolio: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
function createWrapper(queryClient?: QueryClient) {
|
|
||||||
const client = queryClient ?? new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
||||||
|
|
||||||
return function Wrapper({ children }: { children: ReactNode }) {
|
|
||||||
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function createDeferred<T>() {
|
|
||||||
let resolve!: (value: T) => void;
|
|
||||||
let reject!: (reason?: unknown) => void;
|
|
||||||
const promise = new Promise<T>((res, rej) => {
|
|
||||||
resolve = res;
|
|
||||||
reject = rej;
|
|
||||||
});
|
|
||||||
|
|
||||||
return { promise, resolve, reject };
|
|
||||||
}
|
|
||||||
|
|
||||||
function createAccount(id: string): BrokerAccount {
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
type: 'brokerage',
|
|
||||||
name: id,
|
|
||||||
status: 'ACCOUNT_STATUS_OPEN',
|
|
||||||
openedAt: null,
|
|
||||||
accessLevel: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function createPortfolio(id: string): BrokerPortfolio {
|
|
||||||
return {
|
|
||||||
account: createAccount(id),
|
|
||||||
positionCounts: { shares: 1, bonds: 0, etf: 0, other: 0 },
|
|
||||||
totals: {
|
|
||||||
shares: { currency: 'RUB', units: '0', nano: 0, value: 100 },
|
|
||||||
bonds: null,
|
|
||||||
etf: null,
|
|
||||||
currencies: { currency: 'RUB', units: '0', nano: 0, value: 20 },
|
|
||||||
futures: null,
|
|
||||||
options: null,
|
|
||||||
structuredProducts: null,
|
|
||||||
dfa: null,
|
|
||||||
portfolio: { currency: 'RUB', units: '0', nano: 0, value: 120 },
|
|
||||||
},
|
|
||||||
yields: {
|
|
||||||
expectedPercent: 3,
|
|
||||||
daily: { currency: 'RUB', units: '0', nano: 0, value: 10 },
|
|
||||||
dailyPercent: 1,
|
|
||||||
},
|
|
||||||
cash: [{ currency: 'RUB', units: '0', nano: 0, value: 20 }],
|
|
||||||
blockedCash: [],
|
|
||||||
asOf: '2026-06-19T10:00:00.000Z',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('useBrokerAccountPortfolios', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps account-to-query mapping regardless of completion order', async () => {
|
|
||||||
const first = createDeferred<{
|
|
||||||
data: BrokerPortfolio;
|
|
||||||
meta: { fromCache: false; cachedAt: null };
|
|
||||||
}>();
|
|
||||||
const second = createDeferred<{
|
|
||||||
data: BrokerPortfolio;
|
|
||||||
meta: { fromCache: false; cachedAt: null };
|
|
||||||
}>();
|
|
||||||
|
|
||||||
vi.mocked(getBrokerPortfolio).mockImplementation((accountId: string) => {
|
|
||||||
if (accountId === 'acc-1') {
|
|
||||||
return first.promise;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (accountId === 'acc-2') {
|
|
||||||
return second.promise;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error(`Unexpected account ${accountId}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
const accounts = [createAccount('acc-1'), createAccount('acc-2')];
|
|
||||||
const { result } = renderHook(() => useBrokerAccountPortfolios(accounts), {
|
|
||||||
wrapper: createWrapper(),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(getBrokerPortfolio).toHaveBeenCalledTimes(2);
|
|
||||||
expect(getBrokerPortfolio).toHaveBeenNthCalledWith(1, 'acc-1');
|
|
||||||
expect(getBrokerPortfolio).toHaveBeenNthCalledWith(2, 'acc-2');
|
|
||||||
|
|
||||||
second.resolve({
|
|
||||||
data: createPortfolio('acc-2'),
|
|
||||||
meta: { fromCache: false, cachedAt: null },
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => expect(result.current[1].query.data?.account.id).toBe('acc-2'));
|
|
||||||
expect(result.current[0].account.id).toBe('acc-1');
|
|
||||||
expect(result.current[0].query.data).toBeUndefined();
|
|
||||||
|
|
||||||
first.resolve({
|
|
||||||
data: createPortfolio('acc-1'),
|
|
||||||
meta: { fromCache: false, cachedAt: null },
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => expect(result.current[0].query.data?.account.id).toBe('acc-1'));
|
|
||||||
expect(result.current[1].query.data?.account.id).toBe('acc-2');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('reuses the same cache key as broker account overview page', async () => {
|
|
||||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
||||||
const cachedPortfolio = createPortfolio('acc-1');
|
|
||||||
queryClient.setQueryData(['broker', 'portfolio', 'acc-1'], cachedPortfolio);
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useBrokerAccountPortfolios([createAccount('acc-1')]), {
|
|
||||||
wrapper: createWrapper(queryClient),
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => expect(result.current[0].query.data).toBe(cachedPortfolio));
|
|
||||||
expect(getBrokerPortfolio).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1 +0,0 @@
|
|||||||
export { useBrokerAccountPortfolios } from '../entities/broker-account';
|
|
||||||
@ -1,41 +0,0 @@
|
|||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
||||||
import { renderHook, waitFor } from '@testing-library/react';
|
|
||||||
import { type ReactNode } from 'react';
|
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
|
||||||
import { getBrokerAccounts } from '../entities/broker-account/api/brokerAccountApi';
|
|
||||||
import { useBrokerAccounts } from './useBrokerAccounts';
|
|
||||||
|
|
||||||
vi.mock('../entities/broker-account/api/brokerAccountApi', () => ({
|
|
||||||
getBrokerAccounts: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
function createWrapper() {
|
|
||||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
||||||
|
|
||||||
return function Wrapper({ children }: { children: ReactNode }) {
|
|
||||||
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('useBrokerAccounts', () => {
|
|
||||||
it('returns broker accounts from API', async () => {
|
|
||||||
vi.mocked(getBrokerAccounts).mockResolvedValue({
|
|
||||||
data: [
|
|
||||||
{
|
|
||||||
id: 'acc-1',
|
|
||||||
type: 'brokerage',
|
|
||||||
name: 'Broker',
|
|
||||||
status: 'ACCOUNT_STATUS_OPEN',
|
|
||||||
openedAt: null,
|
|
||||||
accessLevel: null,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
meta: { fromCache: false, cachedAt: null },
|
|
||||||
});
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useBrokerAccounts(), { wrapper: createWrapper() });
|
|
||||||
|
|
||||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
|
||||||
expect(result.current.data?.[0].name).toBe('Broker');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1 +0,0 @@
|
|||||||
export { useBrokerAccounts } from '../entities/broker-account';
|
|
||||||
@ -1 +0,0 @@
|
|||||||
export { useBrokerOperations } from '../entities/broker-operation';
|
|
||||||
@ -1 +0,0 @@
|
|||||||
export { useBrokerPortfolio } from '../entities/broker-account';
|
|
||||||
@ -1 +0,0 @@
|
|||||||
export { useBrokerPositions } from '../entities/broker-position';
|
|
||||||
@ -1 +0,0 @@
|
|||||||
export { usePortfolio } from '../entities/portfolio/model/usePortfolio';
|
|
||||||
@ -1 +0,0 @@
|
|||||||
export { usePortfolioAnalytics } from '../entities/portfolio/model/usePortfolioAnalytics';
|
|
||||||
@ -1 +0,0 @@
|
|||||||
export { usePortfolioMutations } from '../entities/portfolio/model/usePortfolioMutations';
|
|
||||||
@ -1 +0,0 @@
|
|||||||
export { usePortfolios } from '../entities/portfolio/model/usePortfolios';
|
|
||||||
@ -1 +0,0 @@
|
|||||||
export { usePositionMutations } from '../entities/portfolio/model/usePositionMutations';
|
|
||||||
@ -1,62 +0,0 @@
|
|||||||
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 <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1 +0,0 @@
|
|||||||
export { useSearch } from '@/entities/search';
|
|
||||||
@ -1 +0,0 @@
|
|||||||
export { useStock } from '../entities/stock/model/useStock';
|
|
||||||
@ -1 +0,0 @@
|
|||||||
export { useStockCandles } from '../entities/stock/model/useStockCandles';
|
|
||||||
@ -1 +0,0 @@
|
|||||||
export { useStockDividends } from '../entities/stock/model/useStockDividends';
|
|
||||||
@ -1,48 +0,0 @@
|
|||||||
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 '@/pages/bond';
|
|
||||||
import { renderWithProviders } from '../test/test-utils';
|
|
||||||
|
|
||||||
const API = '/api/v1';
|
|
||||||
|
|
||||||
function renderBondPage(secid = 'SU26238RMFS5') {
|
|
||||||
return renderWithProviders(
|
|
||||||
<Routes>
|
|
||||||
<Route path="/bonds/:secid" element={<BondPage />} />
|
|
||||||
</Routes>,
|
|
||||||
{ 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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1 +0,0 @@
|
|||||||
export { BondPage } from '@/pages/bond';
|
|
||||||
@ -1,25 +0,0 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
|
||||||
import { render, screen } from '@testing-library/react';
|
|
||||||
import { HomePage } from '@/pages/home';
|
|
||||||
|
|
||||||
describe('HomePage', () => {
|
|
||||||
it('renders welcome title', () => {
|
|
||||||
render(<HomePage />);
|
|
||||||
expect(screen.getByText('MoexVibe')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders description', () => {
|
|
||||||
render(<HomePage />);
|
|
||||||
expect(screen.getByText('Анализ акций и облигаций Московской биржи')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders hint text', () => {
|
|
||||||
render(<HomePage />);
|
|
||||||
expect(screen.getByText(/Введите название или тикер/)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders data delay notice', () => {
|
|
||||||
render(<HomePage />);
|
|
||||||
expect(screen.getByText(/Данные задерживаются на 15 минут/)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1 +0,0 @@
|
|||||||
export { HomePage } from '@/pages/home';
|
|
||||||
@ -1,73 +0,0 @@
|
|||||||
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 '@/pages/stock';
|
|
||||||
import { renderWithProviders } from '../test/test-utils';
|
|
||||||
|
|
||||||
const API = '/api/v1';
|
|
||||||
|
|
||||||
function renderStockPage(secid = 'SBER') {
|
|
||||||
return renderWithProviders(
|
|
||||||
<Routes>
|
|
||||||
<Route path="/stocks/:secid" element={<StockPage />} />
|
|
||||||
</Routes>,
|
|
||||||
{ 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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1 +0,0 @@
|
|||||||
export { StockPage } from '@/pages/stock';
|
|
||||||
@ -1 +0,0 @@
|
|||||||
export { BrokerAccountCard } from '../../widgets/broker-account-card';
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
export {
|
|
||||||
BrokerAccountLayout,
|
|
||||||
useBrokerAccountContext,
|
|
||||||
type BrokerAccountContext,
|
|
||||||
} from '../../entities/broker-account/ui/BrokerAccountLayout';
|
|
||||||
@ -1 +0,0 @@
|
|||||||
export { BrokerAccountOverviewPage } from '../broker-account';
|
|
||||||
@ -1,292 +0,0 @@
|
|||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
||||||
import { render, screen, within } from '@testing-library/react';
|
|
||||||
import userEvent from '@testing-library/user-event';
|
|
||||||
import { type ReactElement } from 'react';
|
|
||||||
import { MemoryRouter } from 'react-router-dom';
|
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
import type { BrokerAccount, BrokerPortfolio } from '@/shared/api/responses';
|
|
||||||
import * as brokerAccountEntity from '../../entities/broker-account';
|
|
||||||
import { BrokerAccountsPage } from './BrokerAccountsPage';
|
|
||||||
|
|
||||||
function renderPage(ui: ReactElement) {
|
|
||||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
||||||
|
|
||||||
return render(
|
|
||||||
<QueryClientProvider client={client}>
|
|
||||||
<MemoryRouter>{ui}</MemoryRouter>
|
|
||||||
</QueryClientProvider>,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function createAccount(
|
|
||||||
account: Partial<BrokerAccount> & Pick<BrokerAccount, 'id' | 'name'>,
|
|
||||||
): BrokerAccount {
|
|
||||||
return {
|
|
||||||
id: account.id,
|
|
||||||
name: account.name,
|
|
||||||
type: account.type ?? 'brokerage',
|
|
||||||
status: 'ACCOUNT_STATUS_OPEN',
|
|
||||||
openedAt: account.openedAt ?? '2022-06-16T00:00:00.000Z',
|
|
||||||
accessLevel: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function createPortfolio(
|
|
||||||
account: BrokerAccount,
|
|
||||||
overrides: Partial<BrokerPortfolio> = {},
|
|
||||||
): BrokerPortfolio {
|
|
||||||
return {
|
|
||||||
account,
|
|
||||||
positionCounts: { shares: 4, bonds: 2, etf: 1, other: 0 },
|
|
||||||
totals: {
|
|
||||||
shares: { currency: 'RUB', units: '0', nano: 0, value: 600 },
|
|
||||||
bonds: { currency: 'RUB', units: '0', nano: 0, value: 300 },
|
|
||||||
etf: { currency: 'RUB', units: '0', nano: 0, value: 100 },
|
|
||||||
currencies: { currency: 'RUB', units: '0', nano: 0, value: 100 },
|
|
||||||
futures: null,
|
|
||||||
options: null,
|
|
||||||
structuredProducts: null,
|
|
||||||
dfa: null,
|
|
||||||
portfolio: { currency: 'RUB', units: '0', nano: 0, value: 1_000 },
|
|
||||||
},
|
|
||||||
yields: {
|
|
||||||
expectedPercent: 8,
|
|
||||||
daily: { currency: 'RUB', units: '0', nano: 0, value: 100 },
|
|
||||||
dailyPercent: 11.11,
|
|
||||||
},
|
|
||||||
cash: [{ currency: 'RUB', units: '0', nano: 0, value: 200 }],
|
|
||||||
blockedCash: [],
|
|
||||||
asOf: '2026-06-19T10:00:00.000Z',
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function createQueryState(overrides: Record<string, unknown> = {}) {
|
|
||||||
return {
|
|
||||||
data: undefined,
|
|
||||||
isLoading: false,
|
|
||||||
isFetching: false,
|
|
||||||
isPending: false,
|
|
||||||
isError: false,
|
|
||||||
error: null,
|
|
||||||
refetch: vi.fn(),
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('BrokerAccountsPage', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders heading, aggregate summary, daily result and two linked cards', () => {
|
|
||||||
const broker = createAccount({ id: 'acc-1', name: 'Основной счёт' });
|
|
||||||
const iis = createAccount({ id: 'acc-2', name: 'ИИС капитал', type: 'iis' });
|
|
||||||
|
|
||||||
vi.spyOn(brokerAccountEntity, 'useBrokerAccounts').mockReturnValue({
|
|
||||||
data: [broker, iis],
|
|
||||||
isLoading: false,
|
|
||||||
isFetching: false,
|
|
||||||
error: null,
|
|
||||||
} as any);
|
|
||||||
vi.spyOn(brokerAccountEntity, 'useBrokerAccountPortfolios').mockReturnValue([
|
|
||||||
{
|
|
||||||
account: broker,
|
|
||||||
query: createQueryState({ data: createPortfolio(broker) }),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
account: iis,
|
|
||||||
query: createQueryState({
|
|
||||||
data: createPortfolio(iis, {
|
|
||||||
totals: {
|
|
||||||
shares: { currency: 'RUB', units: '0', nano: 0, value: 800 },
|
|
||||||
bonds: { currency: 'RUB', units: '0', nano: 0, value: 500 },
|
|
||||||
etf: { currency: 'RUB', units: '0', nano: 0, value: 200 },
|
|
||||||
currencies: { currency: 'RUB', units: '0', nano: 0, value: 100 },
|
|
||||||
futures: null,
|
|
||||||
options: null,
|
|
||||||
structuredProducts: null,
|
|
||||||
dfa: null,
|
|
||||||
portfolio: { currency: 'RUB', units: '0', nano: 0, value: 1_600 },
|
|
||||||
},
|
|
||||||
yields: {
|
|
||||||
expectedPercent: 12,
|
|
||||||
daily: { currency: 'RUB', units: '0', nano: 0, value: 140 },
|
|
||||||
dailyPercent: 9.59,
|
|
||||||
},
|
|
||||||
cash: [{ currency: 'RUB', units: '0', nano: 0, value: 300 }],
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
] as any);
|
|
||||||
|
|
||||||
renderPage(<BrokerAccountsPage />);
|
|
||||||
|
|
||||||
expect(screen.getByRole('heading', { level: 1, name: 'Брокерские счета' })).toBeInTheDocument();
|
|
||||||
expect(screen.getByText(/2[\s\u00a0]?600(?:,00)?[\s\u00a0]?₽/)).toBeInTheDocument();
|
|
||||||
expect(screen.getByText(/\+?240(?:,00)?[\s\u00a0]?₽/)).toBeInTheDocument();
|
|
||||||
expect(screen.getByRole('link', { name: /Основной счёт/i })).toHaveAttribute(
|
|
||||||
'href',
|
|
||||||
'/broker/acc-1',
|
|
||||||
);
|
|
||||||
expect(screen.getByRole('link', { name: /ИИС капитал/i })).toHaveAttribute(
|
|
||||||
'href',
|
|
||||||
'/broker/acc-2',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows human labels and opened date without exposing technical fields', () => {
|
|
||||||
const broker = createAccount({ id: 'account one', name: 'Основной счёт' });
|
|
||||||
const iis = createAccount({ id: 'acc-2', name: 'ИИС капитал', type: 'iis' });
|
|
||||||
|
|
||||||
vi.spyOn(brokerAccountEntity, 'useBrokerAccounts').mockReturnValue({
|
|
||||||
data: [broker, iis],
|
|
||||||
isLoading: false,
|
|
||||||
isFetching: false,
|
|
||||||
error: null,
|
|
||||||
} as any);
|
|
||||||
vi.spyOn(brokerAccountEntity, 'useBrokerAccountPortfolios').mockReturnValue([
|
|
||||||
{ account: broker, query: createQueryState({ data: createPortfolio(broker) }) },
|
|
||||||
{ account: iis, query: createQueryState({ data: createPortfolio(iis) }) },
|
|
||||||
] as any);
|
|
||||||
|
|
||||||
renderPage(<BrokerAccountsPage />);
|
|
||||||
|
|
||||||
expect(screen.getByText('Брокерский счёт')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('ИИС')).toBeInTheDocument();
|
|
||||||
expect(screen.getAllByText(/16\.06\.2022/)).toHaveLength(2);
|
|
||||||
expect(screen.queryByText('ACCOUNT_STATUS_OPEN')).not.toBeInTheDocument();
|
|
||||||
expect(screen.queryByText('account one')).not.toBeInTheDocument();
|
|
||||||
expect(screen.getByRole('link', { name: /Основной счёт/i })).toHaveAttribute(
|
|
||||||
'href',
|
|
||||||
'/broker/account%20one',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows page skeleton while accounts are loading', () => {
|
|
||||||
vi.spyOn(brokerAccountEntity, 'useBrokerAccounts').mockReturnValue({
|
|
||||||
data: undefined,
|
|
||||||
isLoading: true,
|
|
||||||
isFetching: true,
|
|
||||||
error: null,
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
const { container } = renderPage(<BrokerAccountsPage />);
|
|
||||||
|
|
||||||
expect(container.querySelectorAll('.skeleton').length).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders an empty state when there are no accounts', () => {
|
|
||||||
vi.spyOn(brokerAccountEntity, 'useBrokerAccounts').mockReturnValue({
|
|
||||||
data: [],
|
|
||||||
isLoading: false,
|
|
||||||
isFetching: false,
|
|
||||||
error: null,
|
|
||||||
} as any);
|
|
||||||
vi.spyOn(brokerAccountEntity, 'useBrokerAccountPortfolios').mockReturnValue([] as any);
|
|
||||||
|
|
||||||
renderPage(<BrokerAccountsPage />);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
screen.getByText(/После подключения T-Bank здесь появятся брокерские счета и ИИС/),
|
|
||||||
).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('marks the summary as partial when one account portfolio is unavailable', () => {
|
|
||||||
const broker = createAccount({ id: 'acc-1', name: 'Основной счёт' });
|
|
||||||
const iis = createAccount({ id: 'acc-2', name: 'ИИС капитал', type: 'iis' });
|
|
||||||
|
|
||||||
vi.spyOn(brokerAccountEntity, 'useBrokerAccounts').mockReturnValue({
|
|
||||||
data: [broker, iis],
|
|
||||||
isLoading: false,
|
|
||||||
isFetching: false,
|
|
||||||
error: null,
|
|
||||||
} as any);
|
|
||||||
vi.spyOn(brokerAccountEntity, 'useBrokerAccountPortfolios').mockReturnValue([
|
|
||||||
{ account: broker, query: createQueryState({ data: createPortfolio(broker) }) },
|
|
||||||
{
|
|
||||||
account: iis,
|
|
||||||
query: createQueryState({ isError: true, error: new Error('boom') }),
|
|
||||||
},
|
|
||||||
] as any);
|
|
||||||
|
|
||||||
renderPage(<BrokerAccountsPage />);
|
|
||||||
|
|
||||||
expect(screen.getByText('Доступно по 1 из 2 счетов')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows a local alert and retries only the failed account', async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
const broker = createAccount({ id: 'acc-1', name: 'Основной счёт' });
|
|
||||||
const iis = createAccount({ id: 'acc-2', name: 'ИИС капитал', type: 'iis' });
|
|
||||||
const refetch = vi.fn();
|
|
||||||
|
|
||||||
vi.spyOn(brokerAccountEntity, 'useBrokerAccounts').mockReturnValue({
|
|
||||||
data: [broker, iis],
|
|
||||||
isLoading: false,
|
|
||||||
isFetching: false,
|
|
||||||
error: null,
|
|
||||||
} as any);
|
|
||||||
vi.spyOn(brokerAccountEntity, 'useBrokerAccountPortfolios').mockReturnValue([
|
|
||||||
{ account: broker, query: createQueryState({ data: createPortfolio(broker) }) },
|
|
||||||
{
|
|
||||||
account: iis,
|
|
||||||
query: createQueryState({ isError: true, error: new Error('boom'), refetch }),
|
|
||||||
},
|
|
||||||
] as any);
|
|
||||||
|
|
||||||
renderPage(<BrokerAccountsPage />);
|
|
||||||
|
|
||||||
const alert = screen.getByRole('alert');
|
|
||||||
expect(alert).toHaveTextContent('Не удалось загрузить данные счёта');
|
|
||||||
await user.click(
|
|
||||||
within(alert.closest('.broker-account-card')!).getByRole('button', { name: 'Повторить' }),
|
|
||||||
);
|
|
||||||
expect(refetch).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps currencies separate in the overview summary', () => {
|
|
||||||
const broker = createAccount({ id: 'acc-1', name: 'Рублёвый счёт' });
|
|
||||||
const usd = createAccount({ id: 'acc-2', name: 'Долларовый счёт' });
|
|
||||||
|
|
||||||
vi.spyOn(brokerAccountEntity, 'useBrokerAccounts').mockReturnValue({
|
|
||||||
data: [broker, usd],
|
|
||||||
isLoading: false,
|
|
||||||
isFetching: false,
|
|
||||||
error: null,
|
|
||||||
} as any);
|
|
||||||
vi.spyOn(brokerAccountEntity, 'useBrokerAccountPortfolios').mockReturnValue([
|
|
||||||
{ account: broker, query: createQueryState({ data: createPortfolio(broker) }) },
|
|
||||||
{
|
|
||||||
account: usd,
|
|
||||||
query: createQueryState({
|
|
||||||
data: createPortfolio(usd, {
|
|
||||||
totals: {
|
|
||||||
shares: { currency: 'USD', units: '0', nano: 0, value: 300 },
|
|
||||||
bonds: { currency: 'USD', units: '0', nano: 0, value: 100 },
|
|
||||||
etf: null,
|
|
||||||
currencies: { currency: 'USD', units: '0', nano: 0, value: 100 },
|
|
||||||
futures: null,
|
|
||||||
options: null,
|
|
||||||
structuredProducts: null,
|
|
||||||
dfa: null,
|
|
||||||
portfolio: { currency: 'USD', units: '0', nano: 0, value: 500 },
|
|
||||||
},
|
|
||||||
yields: {
|
|
||||||
expectedPercent: 4,
|
|
||||||
daily: { currency: 'USD', units: '0', nano: 0, value: 20 },
|
|
||||||
dailyPercent: 4.16,
|
|
||||||
},
|
|
||||||
cash: [{ currency: 'USD', units: '0', nano: 0, value: 25 }],
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
] as any);
|
|
||||||
|
|
||||||
renderPage(<BrokerAccountsPage />);
|
|
||||||
|
|
||||||
const summary = screen.getByRole('region', { name: 'Общая сводка по счетам' });
|
|
||||||
expect(within(summary).getByText(/1[\s\u00a0]?000(?:,00)?[\s\u00a0]?₽/)).toBeInTheDocument();
|
|
||||||
expect(within(summary).getByText(/500(?:,00)?[\s\u00a0]?\$/)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1 +0,0 @@
|
|||||||
export { BrokerAccountsPage } from '../broker-accounts';
|
|
||||||
@ -1 +0,0 @@
|
|||||||
export { BrokerAccountsSummary } from '../../widgets/broker-accounts-summary';
|
|
||||||
@ -1 +0,0 @@
|
|||||||
export { BrokerAllocationBar } from '../../widgets/broker-allocation-chart';
|
|
||||||
@ -1 +0,0 @@
|
|||||||
export { BrokerAllocationChart } from '../../widgets/broker-allocation-chart';
|
|
||||||
@ -1 +0,0 @@
|
|||||||
export { BrokerOperationsPage } from '../broker-operations';
|
|
||||||
@ -1 +0,0 @@
|
|||||||
export { BrokerOperationsTable } from '../../widgets/broker-operations-table';
|
|
||||||
File diff suppressed because it is too large
Load Diff
@ -1 +0,0 @@
|
|||||||
export { BrokerPositionsPage } from '../broker-positions';
|
|
||||||
@ -1,14 +0,0 @@
|
|||||||
export {
|
|
||||||
aggregateBrokerAccounts,
|
|
||||||
brokerAccountTypeLabel,
|
|
||||||
formatBrokerCurrencyValue,
|
|
||||||
formatBrokerDate,
|
|
||||||
formatBrokerMoney,
|
|
||||||
formatBrokerPercent,
|
|
||||||
formatBrokerSignedCurrencyValue,
|
|
||||||
formatBrokerSignedPercent,
|
|
||||||
type BrokerAccountsAggregate,
|
|
||||||
type BrokerCurrencyAllocationSummary,
|
|
||||||
type BrokerCurrencyCashSummary,
|
|
||||||
type BrokerCurrencyPortfolioSummary,
|
|
||||||
} from '../../entities/broker-account/model/brokerAccountsOverview';
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
export {
|
|
||||||
buildBrokerAllocation,
|
|
||||||
type BrokerAllocationItem,
|
|
||||||
type BrokerAllocationKey,
|
|
||||||
} from '../../entities/broker-position';
|
|
||||||
@ -1,10 +0,0 @@
|
|||||||
export {
|
|
||||||
BROKER_OPERATION_TYPE_OPTIONS,
|
|
||||||
getBrokerInstrumentPath,
|
|
||||||
getBrokerOperationImpact,
|
|
||||||
getBrokerOperationTypeLabel,
|
|
||||||
getBrokerPositionGroup,
|
|
||||||
isBrokerOperationType,
|
|
||||||
type BrokerOperationImpact,
|
|
||||||
type BrokerPositionGroup,
|
|
||||||
} from '../../entities/broker-position';
|
|
||||||
@ -1 +0,0 @@
|
|||||||
export { AppRoutes } from './app/routing/AppRoutes';
|
|
||||||
@ -2,7 +2,7 @@ import { type ReactElement } from 'react';
|
|||||||
import { render, type RenderOptions } from '@testing-library/react';
|
import { render, type RenderOptions } from '@testing-library/react';
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import { MemoryRouter } from 'react-router-dom';
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
import { AuthProvider } from '../context/AuthContext';
|
import { SessionProvider } from '../app/providers/SessionProvider';
|
||||||
|
|
||||||
interface CustomRenderOptions extends Omit<RenderOptions, 'wrapper'> {
|
interface CustomRenderOptions extends Omit<RenderOptions, 'wrapper'> {
|
||||||
queryClient?: QueryClient;
|
queryClient?: QueryClient;
|
||||||
@ -33,7 +33,7 @@ export function renderWithProviders(
|
|||||||
initialEntries={[route]}
|
initialEntries={[route]}
|
||||||
future={{ v7_startTransition: true, v7_relativeSplatPath: true }}
|
future={{ v7_startTransition: true, v7_relativeSplatPath: true }}
|
||||||
>
|
>
|
||||||
<AuthProvider>{children}</AuthProvider>
|
<SessionProvider>{children}</SessionProvider>
|
||||||
</MemoryRouter>
|
</MemoryRouter>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
105
docs/features/frontend-fsd-cleanup/plan.md
Normal file
105
docs/features/frontend-fsd-cleanup/plan.md
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
# Frontend FSD Cleanup — план
|
||||||
|
|
||||||
|
## Общий подход
|
||||||
|
|
||||||
|
7 последовательных шагов, каждый шаг верифицируется `npm test -w apps/frontend`.
|
||||||
|
После шага 1 (разрыв цепочки) удаляем файлы. После шагов 3-6 обновляем документацию.
|
||||||
|
|
||||||
|
## Шаг 1. Разорвать цепочку entity shims к `api/broker.ts`
|
||||||
|
|
||||||
|
- `entities/broker-position/api/brokerPositionApi.ts` — переписать на прямой вызов `shared/api/client`
|
||||||
|
- `entities/broker-operation/api/brokerOperationApi.ts` — переписать на прямой вызов `shared/api/client`
|
||||||
|
|
||||||
|
Оба файла делают то же, что `entities/broker-account/api/brokerAccountApi.ts` уже делает:
|
||||||
|
импортируют `apiClient` из `shared/api/client` и типы из `shared/api/responses`.
|
||||||
|
|
||||||
|
После этого шага `api/broker.ts` перестаёт быть нужен.
|
||||||
|
|
||||||
|
## Шаг 2. Переключить потребителей shim на FSD-импорты
|
||||||
|
|
||||||
|
Три группы потребителей:
|
||||||
|
|
||||||
|
1. `pages/broker/BrokerPages.test.tsx` — импортирует моки `useBrokerAccounts` и `useBrokerAccountPortfolios` из `../../hooks/`. Переключить на импорт из `entities/broker-account` (через `vi.mock(entities/broker-account)`). Сами файлы shim-хуков будут удалены на шаге 3, но пока их тесты нужно переключить.
|
||||||
|
|
||||||
|
2. `pages/broker/BrokerAccountsPage.test.tsx` — импортирует `BrokerAccountsPage` из `./BrokerAccountsPage` (который shim в `pages/broker/`). Переключить на `pages/broker-accounts`. После миграции тест будет жить при FSD-странице.
|
||||||
|
|
||||||
|
3. Три файла импортируют `AuthProvider` из `context/AuthContext`:
|
||||||
|
- `test/test-utils.tsx`
|
||||||
|
- `hooks/useAuth.test.tsx`
|
||||||
|
- `entities/session/model/useSession.test.tsx`
|
||||||
|
|
||||||
|
Заменить на импорт `SessionProvider` из `app/providers/SessionProvider`. `AuthProvider` — это просто alias.
|
||||||
|
|
||||||
|
## Шаг 3. Удалить shim-файлы
|
||||||
|
|
||||||
|
### api/ (кроме api/screener.ts)
|
||||||
|
- `api/auth.ts` → re-export из `entities/session/api/sessionApi`
|
||||||
|
- `api/client.ts` → re-export из `shared/api/client`
|
||||||
|
- `api/portfolio.ts` → re-export из `entities/portfolio/api/portfolioApi`
|
||||||
|
- `api/responses.ts` → re-export из `shared/api/responses`
|
||||||
|
- `api/types.ts` → re-export из `shared/api/types`
|
||||||
|
- `api/broker.ts` → больше не нужен (шаг 1)
|
||||||
|
- `api/broker.test.ts` → мёртвый код
|
||||||
|
|
||||||
|
### hooks/ (кроме hooks/useScreener.ts)
|
||||||
|
Все 17 файлов — однострочные re-export.
|
||||||
|
|
||||||
|
### context/
|
||||||
|
- `context/AuthContext.tsx` — re-export
|
||||||
|
- `context/AuthContext.test.tsx` — тест shim
|
||||||
|
|
||||||
|
### components/ (кроме components/screener/ и components/portfolios/)
|
||||||
|
- `components/Layout.tsx` → `app/layouts/AppLayout`
|
||||||
|
- `components/ProtectedRoute.tsx` → `app/routing/ProtectedRoute`
|
||||||
|
- `components/SearchBar.tsx` → `widgets/search-bar`
|
||||||
|
- `components/PriceChart.tsx` → `widgets/price-chart`
|
||||||
|
- `components/StockDetails.tsx` → `widgets/stock-details`
|
||||||
|
- `components/BondDetails.tsx` → `widgets/bond-details`
|
||||||
|
- `components/SkeletonBlock.tsx` → `shared/ui/SkeletonBlock`
|
||||||
|
- `components/TableSkeleton.tsx` → `shared/ui/TableSkeleton`
|
||||||
|
- Соответствующие `.test.tsx` файлы
|
||||||
|
|
||||||
|
### pages/ flat shims
|
||||||
|
- `pages/HomePage.tsx` → `pages/home`
|
||||||
|
- `pages/StockPage.tsx` → `pages/stock`
|
||||||
|
- `pages/BondPage.tsx` → `pages/bond`
|
||||||
|
- Соответствующие `.test.tsx` файлы
|
||||||
|
|
||||||
|
### pages/broker/ (весь каталог)
|
||||||
|
13 файлов — все shims.
|
||||||
|
|
||||||
|
### Корень src/
|
||||||
|
- `routes.tsx` → re-export из `app/routing/AppRoutes`
|
||||||
|
|
||||||
|
## Шаг 4. Удалить тесты, привязанные к shim-файлам
|
||||||
|
|
||||||
|
После удаления shim-файлов их тесты тоже удаляются:
|
||||||
|
- `api/auth.test.ts`, `api/client.test.ts`, `api/broker.test.ts`
|
||||||
|
- `hooks/useAuth.test.tsx`, `hooks/useBrokerAccountPortfolios.test.tsx`,
|
||||||
|
`hooks/useBrokerAccounts.test.tsx`, `hooks/useSearch.test.tsx`
|
||||||
|
- `context/AuthContext.test.tsx`
|
||||||
|
- `components/BondDetails.test.tsx`, `components/Layout.test.tsx`,
|
||||||
|
`components/PriceChart.test.tsx`, `components/ProtectedRoute.test.tsx`,
|
||||||
|
`components/SearchBar.test.tsx`, `components/StockDetails.test.tsx`
|
||||||
|
- `pages/HomePage.test.tsx`, `pages/StockPage.test.tsx`, `pages/BondPage.test.tsx`
|
||||||
|
- `pages/broker/BrokerPages.test.tsx`, `pages/broker/BrokerAccountsPage.test.tsx`
|
||||||
|
|
||||||
|
Функциональность этих тестов уже покрыта тестами внутри FSD-слоёв.
|
||||||
|
|
||||||
|
## Шаг 5. Удалить `api/broker.ts`
|
||||||
|
|
||||||
|
После шага 1 (разрыв цепочки) entity API прокси больше не ссылаются на `api/broker.ts`.
|
||||||
|
Осталось только удалить сам файл.
|
||||||
|
|
||||||
|
## Шаг 6. Обновить документацию
|
||||||
|
|
||||||
|
- `apps/docs/docs/frontend/hooks.md` — убрать секции про legacy shim-пути
|
||||||
|
- `apps/docs/docs/frontend/routes.md` — убрать упоминания shim-файлов
|
||||||
|
|
||||||
|
## Шаг 7. Финальная верификация
|
||||||
|
|
||||||
|
- `npm test -w apps/frontend` — PASS
|
||||||
|
- `npm run lint -w apps/frontend` — PASS
|
||||||
|
- `npm run build -w apps/frontend` — PASS
|
||||||
|
- `npm run build -w apps/docs` — PASS
|
||||||
|
- Deep import verification: `rg -n "@/api/|@/hooks/|@/context/|@/components/(?!screener|portfolios)" apps/frontend/src` — no matches
|
||||||
84
docs/features/frontend-fsd-cleanup/spec.md
Normal file
84
docs/features/frontend-fsd-cleanup/spec.md
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
# Frontend FSD Cleanup
|
||||||
|
|
||||||
|
Дата: 2026-06-20
|
||||||
|
Статус: спецификация
|
||||||
|
|
||||||
|
## Контекст
|
||||||
|
|
||||||
|
FSD-миграция фронтенда выполнена в 5 фаз:
|
||||||
|
|
||||||
|
1. Broker pilot — entities broker-account/broker-position/broker-operation + widgets + pages
|
||||||
|
2. Shared layer — shared/api, shared/ui
|
||||||
|
3. Entities migration — stock, bond, portfolio, session, search
|
||||||
|
4. App + auth — providers, routing, layouts
|
||||||
|
5. Market pages — home/stock/bond pages + widgets
|
||||||
|
|
||||||
|
После завершения миграции остались shim-файлы — однострочные re-export из старых путей в новые
|
||||||
|
FSD-точки входа — а также мёртвый код в `pages/broker/` и старая цепочка entity API-прокси,
|
||||||
|
которая идёт через `api/broker.ts` вместо прямого вызова `shared/api/client`.
|
||||||
|
|
||||||
|
## Цель
|
||||||
|
|
||||||
|
Удалить shim-файлы и мёртвый код, разорвав последние цепочки legacy-импортов, чтобы
|
||||||
|
FSD-структура стала единственной архитектурой фронтенда без дублирующихся точек входа.
|
||||||
|
|
||||||
|
## Область изменений
|
||||||
|
|
||||||
|
### Удаляемые shim-файлы (re-export only)
|
||||||
|
|
||||||
|
- `api/` — `auth.ts`, `client.ts`, `portfolio.ts`, `responses.ts`, `types.ts`, `broker.test.ts`
|
||||||
|
- `hooks/` — все файлы, кроме `useScreener.ts`
|
||||||
|
- `context/` — `AuthContext.tsx`, `AuthContext.test.tsx`
|
||||||
|
- `components/` — все файлы, кроме `screener/` и `portfolios/`
|
||||||
|
- `pages/broker/` — все 13 файлов
|
||||||
|
- `pages/HomePage.tsx`, `pages/StockPage.tsx`, `pages/BondPage.tsx`
|
||||||
|
- `routes.tsx`
|
||||||
|
- Тесты, привязанные к удаляемым shim-файлам
|
||||||
|
|
||||||
|
### Переписываемые entity API (разрыв цепочки к `api/broker.ts`)
|
||||||
|
|
||||||
|
- `entities/broker-position/api/brokerPositionApi.ts` — прямой вызов `shared/api/client`
|
||||||
|
- `entities/broker-operation/api/brokerOperationApi.ts` — прямой вызов `shared/api/client`
|
||||||
|
|
||||||
|
### Удаляемый мёртвый код
|
||||||
|
|
||||||
|
- `api/broker.ts` — после разрыва цепочки перестаёт быть нужен
|
||||||
|
|
||||||
|
### Обновляемые потребители (переключение с shim на FSD-импорты)
|
||||||
|
|
||||||
|
- `pages/broker/BrokerPages.test.tsx` — импорты из `../../hooks/` → `entities/broker-*`
|
||||||
|
- `pages/broker/BrokerAccountsPage.test.tsx` — импорт shim → `pages/broker-accounts`
|
||||||
|
- `test/test-utils.tsx`, `hooks/useAuth.test.tsx`, `entities/session/model/useSession.test.tsx` — `AuthProvider` из `context/AuthContext` → `SessionProvider` из `app/providers/SessionProvider`
|
||||||
|
|
||||||
|
### Обновляемая документация
|
||||||
|
|
||||||
|
- `apps/docs/docs/frontend/hooks.md` — убрать legacy shim-пути
|
||||||
|
- `apps/docs/docs/frontend/routes.md` — убрать shim-файлы
|
||||||
|
|
||||||
|
## Ограничения
|
||||||
|
|
||||||
|
- Изменения ограничены frontend-пакетом.
|
||||||
|
- Не мигрируется screener (живой код, не shim) — остаётся в текущей структуре.
|
||||||
|
- Не мигрируется portfolio pages (живой код, не shim) — остаются в текущей структуре.
|
||||||
|
- Не мигрируется LoginPage/RegisterPage/ProfilePage (живой код, не shim).
|
||||||
|
- Не меняется поведение UI, API-контракты, роутинг.
|
||||||
|
- Не вводятся ESLint import boundaries.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- `api/` содержит только `screener.ts` (живой код)
|
||||||
|
- `hooks/` содержит только `useScreener.ts` (живой код)
|
||||||
|
- `context/` удалён полностью
|
||||||
|
- `components/` содержит только `screener/` и `portfolios/` (живой код)
|
||||||
|
- `pages/broker/` удалён полностью
|
||||||
|
- `pages/HomePage.tsx`, `StockPage.tsx`, `BondPage.tsx` удалены (живут в `pages/home/`, `pages/stock/`, `pages/bond/`)
|
||||||
|
- `routes.tsx` удалён (живёт в `app/routing/AppRoutes.tsx`)
|
||||||
|
- `api/broker.ts` удалён
|
||||||
|
- `entities/broker-position/api/brokerPositionApi.ts` и `entities/broker-operation/api/brokerOperationApi.ts` импортируют напрямую из `shared/api/client`
|
||||||
|
- Все тестовые файлы, привязанные к удалённым shim-файлам, удалены
|
||||||
|
- Все потребители переключены на FSD-импорты
|
||||||
|
- `npm test -w apps/frontend` — PASS
|
||||||
|
- `npm run lint -w apps/frontend` — PASS
|
||||||
|
- `npm run build -w apps/frontend` — PASS
|
||||||
|
- `npm run build -w apps/docs` — PASS
|
||||||
|
- Документация обновлена
|
||||||
68
docs/features/frontend-fsd-cleanup/tasks.md
Normal file
68
docs/features/frontend-fsd-cleanup/tasks.md
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
# Frontend FSD Cleanup — задачи
|
||||||
|
|
||||||
|
Статус: выполнено
|
||||||
|
|
||||||
|
## 1. Разорвать цепочку entity shims
|
||||||
|
|
||||||
|
- [x] Переписать `entities/broker-position/api/brokerPositionApi.ts` на прямой вызов `shared/api/client`
|
||||||
|
- [x] Переписать `entities/broker-operation/api/brokerOperationApi.ts` на прямой вызов `shared/api/client`
|
||||||
|
- [x] Проверить `npm test -w apps/frontend`
|
||||||
|
|
||||||
|
## 2. Переключить потребителей shim на FSD-импорты
|
||||||
|
|
||||||
|
- [x] Удалены вместе с `pages/broker/` (BrokerPages.test.tsx, BrokerAccountsPage.test.tsx)
|
||||||
|
- [x] Обновить `test/test-utils.tsx` — `AuthProvider` → `SessionProvider` из `app/providers/SessionProvider`
|
||||||
|
- [x] Обновить `hooks/useAuth.test.tsx` — `AuthProvider` → `SessionProvider` (удалён на шаге 4)
|
||||||
|
- [x] Обновить `entities/session/model/useSession.test.tsx` — `AuthProvider` → `SessionProvider`
|
||||||
|
- [x] Проверить `npm test -w apps/frontend`
|
||||||
|
|
||||||
|
## 3. Удалить shim-файлы
|
||||||
|
|
||||||
|
### api/
|
||||||
|
- [x] Удалить `api/auth.ts`, `api/client.ts`, `api/portfolio.ts`, `api/responses.ts`, `api/types.ts`
|
||||||
|
- [x] Удалить `api/broker.test.ts`
|
||||||
|
|
||||||
|
### hooks/
|
||||||
|
- [x] Удалить все файлы кроме `useScreener.ts`
|
||||||
|
|
||||||
|
### context/
|
||||||
|
- [x] Удалить `context/AuthContext.tsx`
|
||||||
|
- [x] Удалить `context/AuthContext.test.tsx`
|
||||||
|
|
||||||
|
### components/
|
||||||
|
- [x] Удалить все flat shims
|
||||||
|
|
||||||
|
### pages/ flat shims
|
||||||
|
- [x] Удалить `pages/HomePage.tsx`, `pages/StockPage.tsx`, `pages/BondPage.tsx`
|
||||||
|
|
||||||
|
### pages/broker/
|
||||||
|
- [x] Удалить весь каталог `pages/broker/`
|
||||||
|
|
||||||
|
### Корень src/
|
||||||
|
- [x] Удалить `routes.tsx`
|
||||||
|
|
||||||
|
- [x] Проверить `npm test -w apps/frontend`
|
||||||
|
|
||||||
|
## 4. Удалить тесты, привязанные к shim-файлам
|
||||||
|
|
||||||
|
- [x] Удалены 17 тестовых файлов
|
||||||
|
- [x] Исправлен `api/screener.ts` — импорты из `./client` → `@/shared/api/client`
|
||||||
|
|
||||||
|
## 5. Удалить api/broker.ts
|
||||||
|
|
||||||
|
- [x] Удалить `api/broker.ts`
|
||||||
|
- [x] Проверить `npm test -w apps/frontend`
|
||||||
|
|
||||||
|
## 6. Обновить документацию
|
||||||
|
|
||||||
|
- [x] Обновить `apps/docs/docs/frontend/hooks.md`
|
||||||
|
- [x] Обновить `apps/docs/docs/frontend/routes.md`
|
||||||
|
- [x] Проверить `npm run build -w apps/docs`
|
||||||
|
|
||||||
|
## 7. Финальная верификация
|
||||||
|
|
||||||
|
- [x] `npm test -w apps/frontend` — PASS (23 files, 112 tests)
|
||||||
|
- [x] `npm run lint -w apps/frontend` — PASS
|
||||||
|
- [x] `npm run build -w apps/frontend` — PASS
|
||||||
|
- [x] `npm run build -w apps/docs` — PASS
|
||||||
|
- [x] Legacy import check — no matches
|
||||||
Loading…
x
Reference in New Issue
Block a user