1391 lines
44 KiB
Markdown
1391 lines
44 KiB
Markdown
# Стабилизация Quality Gate, API-контракта и документации Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Сделать стандартные проверки MoexVibe детерминированными, синхронизировать OpenAPI-артефакты и обновить документацию под фактическое состояние репозитория.
|
||
|
||
**Architecture:** Разделяем offline unit tests и opt-in live MOEX integration tests. Машинный API-контракт берём из NestJS Swagger JSON по `/api/docs-json`, затем синхронизируем `apps/frontend/src/api/types.ts`. README, AGENTS и Docusaurus остаются onboarding-документацией и описывают текущий код, а не исторические планы.
|
||
|
||
**Tech Stack:** npm workspaces, NestJS 10, Vitest 1 для backend, Vitest 4 для frontend, Docusaurus 3, Swagger/OpenAPI, openapi-typescript.
|
||
|
||
---
|
||
|
||
## Файловая структура
|
||
|
||
### Создать
|
||
|
||
- `apps/backend/src/modules/moex-client/moex-client.service.integration.spec.ts` — opt-in live MOEX smoke tests.
|
||
- `apps/backend/src/openapi-artifacts.spec.ts` — offline проверка, что generated frontend OpenAPI types содержат актуальные paths.
|
||
|
||
### Изменить
|
||
|
||
- `apps/backend/package.json` — исключить integration specs из default backend tests и добавить opt-in integration command.
|
||
- `apps/backend/src/modules/moex-client/moex-client.service.spec.ts` — заменить live MOEX calls на mocked Axios unit tests.
|
||
- `apps/backend/src/modules/securities/securities.service.spec.ts` — заменить live MOEX calls на mocked `MoexClientService` и `CacheService`.
|
||
- `apps/backend/src/modules/candles/candles.service.spec.ts` — заменить live MOEX calls на mocked dependencies.
|
||
- `apps/backend/src/modules/shares/shares.service.spec.ts` — заменить live MOEX calls на mocked dependencies.
|
||
- `apps/backend/src/modules/bonds/bonds.service.spec.ts` — заменить live MOEX calls на mocked dependencies.
|
||
- `apps/backend/src/modules/securities/screener.service.spec.ts` — убрать неиспользуемый `moexClient` или начать явно проверять его вызовы.
|
||
- `apps/frontend/src/api/types.ts` — перегенерировать из текущего Swagger JSON.
|
||
- `README.md` — актуализировать scripts, tests и docs workspace.
|
||
- `AGENTS.md` — актуализировать workspace, команды, frontend tests, Husky и CI.
|
||
- `apps/docs/docs/intro.md` — сделать Docusaurus docs home на `/`.
|
||
- `apps/docs/docs/development/commands.md` — актуализировать root/backend/frontend/docs commands.
|
||
- `apps/docs/docs/development/testing.md` — описать backend, frontend и opt-in MOEX integration tests.
|
||
- `apps/docs/docs/development/codegen.md` — описать актуальную генерацию `types.ts` и `openapi.yaml`.
|
||
- `apps/docs/docs/frontend/overview.md` — добавить auth, portfolios, screener и test helpers.
|
||
- `apps/docs/docs/frontend/routes.md` — добавить текущие routes.
|
||
- `apps/docs/docs/frontend/api-client.md` — добавить auth, portfolio и screener API modules.
|
||
- `apps/docs/docs/backend/api.md` — добавить screener и portfolio endpoints.
|
||
- `apps/docs/docs/backend/portfolio.md` — исправить `PATCH /api/v1/portfolios/:id/patch` на `PATCH /api/v1/portfolios/:id`.
|
||
|
||
---
|
||
|
||
## Task 1: Разделить backend unit tests и live MOEX integration tests
|
||
|
||
**Files:**
|
||
|
||
- Modify: `apps/backend/package.json`
|
||
- Modify: `apps/backend/src/modules/moex-client/moex-client.service.spec.ts`
|
||
- Create: `apps/backend/src/modules/moex-client/moex-client.service.integration.spec.ts`
|
||
|
||
- [ ] **Step 1: Зафиксировать красное состояние default backend tests**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
npm run test:backend
|
||
```
|
||
|
||
Expected: FAIL. В выводе есть `Vitest caught ... unhandled errors` и `DataCloneError` вокруг Axios `transformRequest`.
|
||
|
||
- [ ] **Step 2: Обновить backend scripts**
|
||
|
||
В `apps/backend/package.json` заменить scripts `test` и `test:watch`, добавить `test:integration`:
|
||
|
||
```json
|
||
{
|
||
"scripts": {
|
||
"postinstall": "prisma generate",
|
||
"build": "nest build",
|
||
"start:dev": "nest start --watch",
|
||
"start:prod": "node dist/main",
|
||
"lint": "eslint \"{src,test}/**/*.ts\"",
|
||
"test": "VITE_CJS_IGNORE_WARNING=1 vitest run --exclude \"src/**/*.integration.spec.ts\"",
|
||
"test:watch": "vitest --exclude \"src/**/*.integration.spec.ts\"",
|
||
"test:integration": "MOEX_LIVE_TESTS=1 VITE_CJS_IGNORE_WARNING=1 vitest run \"src/**/*.integration.spec.ts\""
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Создать opt-in live MOEX integration spec**
|
||
|
||
Создать `apps/backend/src/modules/moex-client/moex-client.service.integration.spec.ts`:
|
||
|
||
```typescript
|
||
import 'reflect-metadata';
|
||
import { Test, TestingModule } from '@nestjs/testing';
|
||
import { ConfigModule } from '@nestjs/config';
|
||
import { MoexClientService } from './moex-client.service';
|
||
import configuration from '../../config/configuration';
|
||
|
||
describe.skipIf(process.env.MOEX_LIVE_TESTS !== '1')('MoexClientService live MOEX integration', () => {
|
||
let service: MoexClientService;
|
||
|
||
beforeEach(async () => {
|
||
const module: TestingModule = await Test.createTestingModule({
|
||
imports: [ConfigModule.forRoot({ load: [configuration] })],
|
||
providers: [MoexClientService],
|
||
}).compile();
|
||
|
||
service = module.get<MoexClientService>(MoexClientService);
|
||
});
|
||
|
||
it('возвращает результаты поиска для SBER из live MOEX', async () => {
|
||
const results = await service.searchSecurities('SBER');
|
||
|
||
expect(results.length).toBeGreaterThan(0);
|
||
expect(results[0].secid).toBeDefined();
|
||
}, 15000);
|
||
|
||
it('возвращает рыночные данные SBER из live MOEX', async () => {
|
||
const data = await service.getShareMarketData('SBER');
|
||
|
||
expect(data).toBeDefined();
|
||
expect(data!.secid).toBe('SBER');
|
||
}, 15000);
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 4: Заменить `moex-client.service.spec.ts` на offline unit tests**
|
||
|
||
Заменить содержимое `apps/backend/src/modules/moex-client/moex-client.service.spec.ts`:
|
||
|
||
```typescript
|
||
import 'reflect-metadata';
|
||
import axios from 'axios';
|
||
import { ConfigService } from '@nestjs/config';
|
||
import { MoexClientService } from './moex-client.service';
|
||
|
||
vi.mock('axios', () => ({
|
||
default: {
|
||
create: vi.fn(),
|
||
},
|
||
}));
|
||
|
||
describe('MoexClientService', () => {
|
||
let service: MoexClientService;
|
||
let getMock: ReturnType<typeof vi.fn>;
|
||
|
||
beforeEach(() => {
|
||
getMock = vi.fn();
|
||
vi.mocked(axios.create).mockReturnValue({ get: getMock } as never);
|
||
|
||
service = new MoexClientService({
|
||
get: vi.fn((key: string, fallback?: unknown) => {
|
||
const values: Record<string, unknown> = {
|
||
'app.moex.baseUrl': 'https://iss.moex.test/iss',
|
||
'app.moex.circuitBreakerThreshold': 5,
|
||
'app.moex.circuitBreakerResetSeconds': 30,
|
||
'app.moex.rateLimit': 10,
|
||
};
|
||
return values[key] ?? fallback;
|
||
}),
|
||
} as unknown as ConfigService);
|
||
});
|
||
|
||
it('создаётся с настроенным MOEX client', () => {
|
||
expect(service).toBeDefined();
|
||
expect(axios.create).toHaveBeenCalledWith({
|
||
baseURL: 'https://iss.moex.test/iss',
|
||
timeout: 10000,
|
||
paramsSerializer: { indexes: null },
|
||
});
|
||
});
|
||
|
||
it('нормализует результаты поиска из ISS table format', async () => {
|
||
getMock.mockResolvedValueOnce({
|
||
data: {
|
||
securities: {
|
||
columns: [
|
||
'secid',
|
||
'isin',
|
||
'name',
|
||
'shortName',
|
||
'latName',
|
||
'listLevel',
|
||
'issuesize',
|
||
'facevalue',
|
||
'faceunit',
|
||
'issuedate',
|
||
'typename',
|
||
'group',
|
||
'type',
|
||
'isqualifiedinvestors',
|
||
'morningsession',
|
||
'eveningsession',
|
||
],
|
||
data: [
|
||
[
|
||
'SBER',
|
||
'RU0009029540',
|
||
'Сбербанк России ПАО ао',
|
||
'Сбербанк',
|
||
'Sberbank',
|
||
'1',
|
||
'21586948000',
|
||
'3',
|
||
'SUR',
|
||
'2007-07-20',
|
||
'Акция обыкновенная',
|
||
'stock_shares',
|
||
'common_share',
|
||
'0',
|
||
'1',
|
||
'1',
|
||
],
|
||
],
|
||
},
|
||
},
|
||
});
|
||
|
||
const results = await service.searchSecurities('SBER');
|
||
|
||
expect(getMock).toHaveBeenCalledWith('/securities.json', {
|
||
params: { q: 'SBER', 'iss.meta': 'off' },
|
||
});
|
||
expect(results).toEqual([
|
||
{
|
||
secid: 'SBER',
|
||
isin: 'RU0009029540',
|
||
name: 'Сбербанк России ПАО ао',
|
||
shortName: 'Сбербанк',
|
||
latName: 'Sberbank',
|
||
listLevel: 1,
|
||
issueSize: 21586948000,
|
||
faceValue: 3,
|
||
faceUnit: 'SUR',
|
||
issueDate: '2007-07-20',
|
||
typeName: 'Акция обыкновенная',
|
||
group: 'stock_shares',
|
||
type: 'common_share',
|
||
isQualifiedInvestors: false,
|
||
morningSession: true,
|
||
eveningSession: true,
|
||
},
|
||
]);
|
||
});
|
||
|
||
it('нормализует market data акции без live MOEX запроса', async () => {
|
||
getMock.mockResolvedValueOnce({
|
||
data: {
|
||
securities: {
|
||
columns: ['SECID', 'BOARDID', 'SHORTNAME', 'PREVPRICE'],
|
||
data: [['SBER', 'TQBR', 'Сбербанк', '320.10']],
|
||
},
|
||
marketdata: {
|
||
columns: [
|
||
'SECID',
|
||
'BOARDID',
|
||
'BID',
|
||
'OFFER',
|
||
'OPEN',
|
||
'LOW',
|
||
'HIGH',
|
||
'LAST',
|
||
'LASTCHANGE',
|
||
'LASTCHANGEPRCNT',
|
||
'VOLTODAY',
|
||
'VALTODAY',
|
||
'WAPRICE',
|
||
'NUMTRADES',
|
||
'ISSUECAPITALIZATION',
|
||
'TRADINGSTATUS',
|
||
'UPDATETIME',
|
||
],
|
||
data: [
|
||
[
|
||
'SBER',
|
||
'TQBR',
|
||
'321',
|
||
'322',
|
||
'320',
|
||
'319',
|
||
'323',
|
||
'322.35',
|
||
'1.15',
|
||
'0.36',
|
||
'1925163',
|
||
'620184479',
|
||
'321.9',
|
||
'12345',
|
||
'6958336818320',
|
||
'T',
|
||
'10:30:00',
|
||
],
|
||
],
|
||
},
|
||
},
|
||
});
|
||
|
||
const data = await service.getShareMarketData('SBER');
|
||
|
||
expect(getMock).toHaveBeenCalledWith('/engines/stock/markets/shares/securities/SBER.json', {
|
||
params: { boards: 'TQBR', 'iss.meta': 'off' },
|
||
});
|
||
expect(data).toMatchObject({
|
||
secid: 'SBER',
|
||
boardid: 'TQBR',
|
||
shortName: 'Сбербанк',
|
||
last: 322.35,
|
||
lastChange: 1.15,
|
||
lastChangePrcnt: 0.36,
|
||
volume: 1925163,
|
||
value: 620184479,
|
||
issueCapitalization: 6958336818320,
|
||
tradingStatus: 'T',
|
||
updateTime: '10:30:00',
|
||
});
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 5: Проверить offline unit spec**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
npm run test -w apps/backend -- src/modules/moex-client/moex-client.service.spec.ts
|
||
```
|
||
|
||
Expected: PASS. В выводе нет `DataCloneError`.
|
||
|
||
- [ ] **Step 6: Проверить, что live spec не попадает в default tests**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
npm run test:backend
|
||
```
|
||
|
||
Expected: всё ещё может падать на других live service specs, но `moex-client.service.integration.spec.ts` не должен запускать live MOEX checks без `test:integration`.
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add apps/backend/package.json apps/backend/src/modules/moex-client/moex-client.service.spec.ts apps/backend/src/modules/moex-client/moex-client.service.integration.spec.ts
|
||
git commit -m "test: split moex live integration checks"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 2: Перевести backend service specs на mocked dependencies и починить lint
|
||
|
||
**Files:**
|
||
|
||
- Modify: `apps/backend/src/modules/securities/securities.service.spec.ts`
|
||
- Modify: `apps/backend/src/modules/candles/candles.service.spec.ts`
|
||
- Modify: `apps/backend/src/modules/shares/shares.service.spec.ts`
|
||
- Modify: `apps/backend/src/modules/bonds/bonds.service.spec.ts`
|
||
- Modify: `apps/backend/src/modules/securities/screener.service.spec.ts`
|
||
|
||
- [ ] **Step 1: Зафиксировать красное состояние lint**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
npm run lint
|
||
```
|
||
|
||
Expected: FAIL с `moexClient is assigned a value but never used` в `screener.service.spec.ts`.
|
||
|
||
- [ ] **Step 2: Заменить `securities.service.spec.ts`**
|
||
|
||
Заменить содержимое `apps/backend/src/modules/securities/securities.service.spec.ts`:
|
||
|
||
```typescript
|
||
import { Test, TestingModule } from '@nestjs/testing';
|
||
import { SecuritiesService } from './securities.service';
|
||
import { MoexClientService } from '../moex-client/moex-client.service';
|
||
import { CacheService } from '../cache/cache.service';
|
||
import { SecurityType } from './dto/search-query.dto';
|
||
|
||
describe('SecuritiesService', () => {
|
||
let service: SecuritiesService;
|
||
let moexClient: Pick<MoexClientService, 'searchSecurities'>;
|
||
let cache: Pick<CacheService, 'getOrFetch'>;
|
||
|
||
beforeEach(async () => {
|
||
moexClient = {
|
||
searchSecurities: vi.fn(),
|
||
} as unknown as Pick<MoexClientService, 'searchSecurities'>;
|
||
cache = {
|
||
getOrFetch: vi.fn(async (_prefix, _parts, fetchFn) => ({
|
||
data: await fetchFn(),
|
||
fromCache: false,
|
||
cachedAt: '2026-06-14T00:00:00.000Z',
|
||
})),
|
||
} as unknown as Pick<CacheService, 'getOrFetch'>;
|
||
|
||
const module: TestingModule = await Test.createTestingModule({
|
||
providers: [
|
||
SecuritiesService,
|
||
{ provide: MoexClientService, useValue: moexClient },
|
||
{ provide: CacheService, useValue: cache },
|
||
],
|
||
}).compile();
|
||
|
||
service = module.get<SecuritiesService>(SecuritiesService);
|
||
});
|
||
|
||
it('возвращает только поддерживаемые инструменты и нормализует валюту SUR в RUB', async () => {
|
||
vi.mocked(moexClient.searchSecurities).mockResolvedValue([
|
||
{
|
||
secid: 'SBER',
|
||
isin: 'RU0009029540',
|
||
name: 'Сбербанк России ПАО ао',
|
||
shortName: 'Сбербанк',
|
||
latName: null,
|
||
listLevel: 1,
|
||
issueSize: 21586948000,
|
||
faceValue: 3,
|
||
faceUnit: 'SUR',
|
||
issueDate: '2007-07-20',
|
||
typeName: 'Акция обыкновенная',
|
||
group: 'stock_shares',
|
||
type: 'common_share',
|
||
isQualifiedInvestors: false,
|
||
morningSession: true,
|
||
eveningSession: true,
|
||
},
|
||
{
|
||
secid: 'SU26238RMFS5',
|
||
isin: 'RU000A106ZJ4',
|
||
name: 'ОФЗ 26238',
|
||
shortName: 'ОФЗ 26238',
|
||
latName: null,
|
||
listLevel: 1,
|
||
issueSize: 500000000,
|
||
faceValue: 1000,
|
||
faceUnit: 'SUR',
|
||
issueDate: '2021-05-15',
|
||
typeName: 'ОФЗ',
|
||
group: 'stock_bonds',
|
||
type: 'ofz_bond',
|
||
isQualifiedInvestors: false,
|
||
morningSession: false,
|
||
eveningSession: false,
|
||
},
|
||
{
|
||
secid: 'FUT',
|
||
isin: '',
|
||
name: 'Фьючерс',
|
||
shortName: 'Фьючерс',
|
||
latName: null,
|
||
listLevel: 0,
|
||
issueSize: 0,
|
||
faceValue: 0,
|
||
faceUnit: '',
|
||
issueDate: '',
|
||
typeName: 'Фьючерс',
|
||
group: 'futures',
|
||
type: 'futures',
|
||
isQualifiedInvestors: false,
|
||
morningSession: false,
|
||
eveningSession: false,
|
||
},
|
||
]);
|
||
|
||
const results = await service.search('SBER', SecurityType.ALL, 10);
|
||
|
||
expect(results).toEqual([
|
||
{
|
||
secid: 'SBER',
|
||
isin: 'RU0009029540',
|
||
shortName: 'Сбербанк',
|
||
type: 'share',
|
||
listLevel: 1,
|
||
currency: 'RUB',
|
||
price: null,
|
||
},
|
||
{
|
||
secid: 'SU26238RMFS5',
|
||
isin: 'RU000A106ZJ4',
|
||
shortName: 'ОФЗ 26238',
|
||
type: 'bond',
|
||
listLevel: 1,
|
||
currency: 'RUB',
|
||
price: null,
|
||
},
|
||
]);
|
||
});
|
||
|
||
it('фильтрует search results по типу и limit без live MOEX', async () => {
|
||
vi.mocked(moexClient.searchSecurities).mockResolvedValue([
|
||
{
|
||
secid: 'SBER',
|
||
isin: 'RU0009029540',
|
||
name: 'Сбербанк России ПАО ао',
|
||
shortName: 'Сбербанк',
|
||
latName: null,
|
||
listLevel: 1,
|
||
issueSize: 21586948000,
|
||
faceValue: 3,
|
||
faceUnit: 'SUR',
|
||
issueDate: '',
|
||
typeName: '',
|
||
group: 'stock_shares',
|
||
type: 'common_share',
|
||
isQualifiedInvestors: false,
|
||
morningSession: false,
|
||
eveningSession: false,
|
||
},
|
||
{
|
||
secid: 'GAZP',
|
||
isin: 'RU0007661625',
|
||
name: 'Газпром',
|
||
shortName: 'Газпром',
|
||
latName: null,
|
||
listLevel: 1,
|
||
issueSize: 0,
|
||
faceValue: 5,
|
||
faceUnit: 'SUR',
|
||
issueDate: '',
|
||
typeName: '',
|
||
group: 'stock_shares',
|
||
type: 'common_share',
|
||
isQualifiedInvestors: false,
|
||
morningSession: false,
|
||
eveningSession: false,
|
||
},
|
||
]);
|
||
|
||
const results = await service.search('S', SecurityType.SHARE, 1);
|
||
|
||
expect(results).toHaveLength(1);
|
||
expect(results[0].secid).toBe('SBER');
|
||
expect(cache.getOrFetch).toHaveBeenCalledWith(
|
||
'search',
|
||
['s'],
|
||
expect.any(Function),
|
||
'searchTtl',
|
||
);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 3: Заменить `candles.service.spec.ts`**
|
||
|
||
Заменить содержимое `apps/backend/src/modules/candles/candles.service.spec.ts`:
|
||
|
||
```typescript
|
||
import { Test, TestingModule } from '@nestjs/testing';
|
||
import { CandlesService } from './candles.service';
|
||
import { MoexClientService } from '../moex-client/moex-client.service';
|
||
import { CacheService } from '../cache/cache.service';
|
||
import { CandleInterval } from './dto/candles-query.dto';
|
||
|
||
describe('CandlesService', () => {
|
||
let service: CandlesService;
|
||
let moexClient: Pick<MoexClientService, 'getCandles'>;
|
||
let cache: Pick<CacheService, 'getOrFetch'>;
|
||
|
||
beforeEach(async () => {
|
||
moexClient = {
|
||
getCandles: vi.fn(),
|
||
} as unknown as Pick<MoexClientService, 'getCandles'>;
|
||
cache = {
|
||
getOrFetch: vi.fn(async (_prefix, _parts, fetchFn) => ({
|
||
data: await fetchFn(),
|
||
fromCache: false,
|
||
cachedAt: '2026-06-14T00:00:00.000Z',
|
||
})),
|
||
} as unknown as Pick<CacheService, 'getOrFetch'>;
|
||
|
||
const module: TestingModule = await Test.createTestingModule({
|
||
providers: [
|
||
CandlesService,
|
||
{ provide: MoexClientService, useValue: moexClient },
|
||
{ provide: CacheService, useValue: cache },
|
||
],
|
||
}).compile();
|
||
|
||
service = module.get<CandlesService>(CandlesService);
|
||
});
|
||
|
||
it('мапит дневные свечи акции и использует MOEX interval 24', async () => {
|
||
vi.mocked(moexClient.getCandles).mockResolvedValue([
|
||
{
|
||
open: 320,
|
||
high: 323,
|
||
low: 319,
|
||
close: 322.35,
|
||
volume: 1925163,
|
||
value: 620184479,
|
||
begin: '2026-06-01 00:00:00',
|
||
end: '2026-06-01 23:59:59',
|
||
},
|
||
]);
|
||
|
||
const result = await service.getCandles(
|
||
'shares',
|
||
'SBER',
|
||
CandleInterval.DAY,
|
||
'2026-06-01',
|
||
'2026-06-14',
|
||
);
|
||
|
||
expect(moexClient.getCandles).toHaveBeenCalledWith(
|
||
'stock',
|
||
'shares',
|
||
'SBER',
|
||
24,
|
||
'2026-06-01',
|
||
'2026-06-14',
|
||
);
|
||
expect(result).toEqual({
|
||
data: [
|
||
{
|
||
open: 320,
|
||
high: 323,
|
||
low: 319,
|
||
close: 322.35,
|
||
volume: 1925163,
|
||
value: 620184479,
|
||
begin: '2026-06-01 00:00:00',
|
||
end: '2026-06-01 23:59:59',
|
||
},
|
||
],
|
||
meta: { fromCache: false, cachedAt: '2026-06-14T00:00:00.000Z' },
|
||
});
|
||
});
|
||
|
||
it('мапит часовые свечи облигации и использует MOEX interval 60', async () => {
|
||
vi.mocked(moexClient.getCandles).mockResolvedValue([
|
||
{
|
||
open: 98,
|
||
high: 98.5,
|
||
low: 97.9,
|
||
close: 98.2,
|
||
volume: 1000,
|
||
value: 982000,
|
||
begin: '2026-06-01 10:00:00',
|
||
end: '2026-06-01 10:59:59',
|
||
},
|
||
]);
|
||
|
||
const result = await service.getCandles(
|
||
'bonds',
|
||
'SU26238RMFS5',
|
||
CandleInterval.HOUR,
|
||
'2026-06-01',
|
||
'2026-06-14',
|
||
);
|
||
|
||
expect(moexClient.getCandles).toHaveBeenCalledWith(
|
||
'stock',
|
||
'bonds',
|
||
'SU26238RMFS5',
|
||
60,
|
||
'2026-06-01',
|
||
'2026-06-14',
|
||
);
|
||
expect(result.data[0].close).toBe(98.2);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 4: Заменить `shares.service.spec.ts`**
|
||
|
||
Заменить содержимое `apps/backend/src/modules/shares/shares.service.spec.ts`:
|
||
|
||
```typescript
|
||
import { NotFoundException } from '@nestjs/common';
|
||
import { Test, TestingModule } from '@nestjs/testing';
|
||
import { SharesService } from './shares.service';
|
||
import { MoexClientService } from '../moex-client/moex-client.service';
|
||
import { CacheService } from '../cache/cache.service';
|
||
|
||
describe('SharesService', () => {
|
||
let service: SharesService;
|
||
let moexClient: Pick<MoexClientService, 'getSecurityDescription' | 'getShareMarketData'>;
|
||
let cache: Pick<CacheService, 'getOrFetch'>;
|
||
|
||
beforeEach(async () => {
|
||
moexClient = {
|
||
getSecurityDescription: vi.fn(),
|
||
getShareMarketData: vi.fn(),
|
||
} as unknown as Pick<MoexClientService, 'getSecurityDescription' | 'getShareMarketData'>;
|
||
cache = {
|
||
getOrFetch: vi.fn(async (_prefix, _parts, fetchFn) => ({
|
||
data: await fetchFn(),
|
||
fromCache: false,
|
||
cachedAt: '2026-06-14T00:00:00.000Z',
|
||
})),
|
||
} as unknown as Pick<CacheService, 'getOrFetch'>;
|
||
|
||
const module: TestingModule = await Test.createTestingModule({
|
||
providers: [
|
||
SharesService,
|
||
{ provide: MoexClientService, useValue: moexClient },
|
||
{ provide: CacheService, useValue: cache },
|
||
],
|
||
}).compile();
|
||
|
||
service = module.get<SharesService>(SharesService);
|
||
});
|
||
|
||
it('возвращает спецификацию акции и market data без live MOEX', async () => {
|
||
vi.mocked(moexClient.getSecurityDescription).mockResolvedValue({
|
||
secid: 'SBER',
|
||
isin: 'RU0009029540',
|
||
name: 'Сбербанк России ПАО ао',
|
||
shortName: 'Сбербанк',
|
||
latName: null,
|
||
listLevel: 1,
|
||
issueSize: 21586948000,
|
||
faceValue: 3,
|
||
faceUnit: 'SUR',
|
||
issueDate: '2007-07-20',
|
||
typeName: 'Акция обыкновенная',
|
||
group: 'stock_shares',
|
||
type: 'common_share',
|
||
isQualifiedInvestors: false,
|
||
morningSession: true,
|
||
eveningSession: true,
|
||
});
|
||
vi.mocked(moexClient.getShareMarketData).mockResolvedValue({
|
||
secid: 'SBER',
|
||
boardid: 'TQBR',
|
||
shortName: 'Сбербанк',
|
||
bid: 321,
|
||
offer: 322,
|
||
open: 320,
|
||
low: 319,
|
||
high: 323,
|
||
last: 322.35,
|
||
lastChange: 1.15,
|
||
lastChangePrcnt: 0.36,
|
||
volume: 1925163,
|
||
value: 620184479,
|
||
waprice: 321.9,
|
||
numtrades: 12345,
|
||
issueCapitalization: 6958336818320,
|
||
tradingStatus: 'T',
|
||
updateTime: '10:30:00',
|
||
});
|
||
|
||
const share = await service.getShare('SBER');
|
||
|
||
expect(share).toMatchObject({
|
||
secid: 'SBER',
|
||
isin: 'RU0009029540',
|
||
faceUnit: 'RUB',
|
||
marketData: {
|
||
price: 322.35,
|
||
change: 1.15,
|
||
changePercent: 0.36,
|
||
open: 320,
|
||
high: 323,
|
||
low: 319,
|
||
volume: 1925163,
|
||
value: 620184479,
|
||
issueCapitalization: 6958336818320,
|
||
},
|
||
});
|
||
});
|
||
|
||
it('выбрасывает NotFoundException для неакции', async () => {
|
||
vi.mocked(moexClient.getSecurityDescription).mockResolvedValue({
|
||
secid: 'SU26238RMFS5',
|
||
isin: 'RU000A106ZJ4',
|
||
name: 'ОФЗ',
|
||
shortName: 'ОФЗ',
|
||
latName: null,
|
||
listLevel: 1,
|
||
issueSize: 500000000,
|
||
faceValue: 1000,
|
||
faceUnit: 'SUR',
|
||
issueDate: '',
|
||
typeName: 'ОФЗ',
|
||
group: 'stock_bonds',
|
||
type: 'ofz_bond',
|
||
isQualifiedInvestors: false,
|
||
morningSession: false,
|
||
eveningSession: false,
|
||
});
|
||
|
||
await expect(service.getShare('SU26238RMFS5')).rejects.toBeInstanceOf(NotFoundException);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 5: Заменить `bonds.service.spec.ts`**
|
||
|
||
Заменить содержимое `apps/backend/src/modules/bonds/bonds.service.spec.ts`:
|
||
|
||
```typescript
|
||
import { NotFoundException } from '@nestjs/common';
|
||
import { Test, TestingModule } from '@nestjs/testing';
|
||
import { BondsService } from './bonds.service';
|
||
import { MoexClientService } from '../moex-client/moex-client.service';
|
||
import { CacheService } from '../cache/cache.service';
|
||
|
||
describe('BondsService', () => {
|
||
let service: BondsService;
|
||
let moexClient: Pick<MoexClientService, 'getBondData' | 'getBondMarketData'>;
|
||
let cache: Pick<CacheService, 'getOrFetch'>;
|
||
|
||
beforeEach(async () => {
|
||
moexClient = {
|
||
getBondData: vi.fn(),
|
||
getBondMarketData: vi.fn(),
|
||
} as unknown as Pick<MoexClientService, 'getBondData' | 'getBondMarketData'>;
|
||
cache = {
|
||
getOrFetch: vi.fn(async (_prefix, _parts, fetchFn) => ({
|
||
data: await fetchFn(),
|
||
fromCache: false,
|
||
cachedAt: '2026-06-14T00:00:00.000Z',
|
||
})),
|
||
} as unknown as Pick<CacheService, 'getOrFetch'>;
|
||
|
||
const module: TestingModule = await Test.createTestingModule({
|
||
providers: [
|
||
BondsService,
|
||
{ provide: MoexClientService, useValue: moexClient },
|
||
{ provide: CacheService, useValue: cache },
|
||
],
|
||
}).compile();
|
||
|
||
service = module.get<BondsService>(BondsService);
|
||
});
|
||
|
||
it('возвращает спецификацию облигации и market data без live MOEX', async () => {
|
||
vi.mocked(moexClient.getBondData).mockResolvedValue({
|
||
secid: 'SU26238RMFS5',
|
||
boardid: 'TQCB',
|
||
shortName: 'ОФЗ 26238',
|
||
prevWaprice: 98.1,
|
||
yieldAtPrevWaprice: 12.3,
|
||
couponValue: 34.9,
|
||
nextCoupon: '2026-12-01',
|
||
accruedInt: 12.45,
|
||
prevPrice: 98,
|
||
lotSize: 1,
|
||
faceValue: 1000,
|
||
matDate: '2041-05-15',
|
||
couponPeriod: 182,
|
||
issueSize: 500000000,
|
||
isin: 'RU000A106ZJ4',
|
||
couponPercent: 6.98,
|
||
offerDate: null,
|
||
buybackDate: null,
|
||
bondType: 'ОФЗ-ПД',
|
||
bondSubType: '',
|
||
listLevel: 1,
|
||
});
|
||
vi.mocked(moexClient.getBondMarketData).mockResolvedValue({
|
||
secid: 'SU26238RMFS5',
|
||
bid: 98.1,
|
||
offer: 98.4,
|
||
open: 98,
|
||
low: 97.9,
|
||
high: 98.6,
|
||
last: 98.45,
|
||
yield: 12.1,
|
||
waprice: 98.2,
|
||
yieldAtWaprice: 12.2,
|
||
duration: 8.34,
|
||
volume: 1500000,
|
||
value: 1476750000,
|
||
numtrades: 100,
|
||
tradingStatus: 'T',
|
||
updateTime: '10:30:00',
|
||
});
|
||
|
||
const result = await service.getBond('SU26238RMFS5');
|
||
|
||
expect(result).toMatchObject({
|
||
data: {
|
||
secid: 'SU26238RMFS5',
|
||
isin: 'RU000A106ZJ4',
|
||
faceValue: 1000,
|
||
faceUnit: 'RUB',
|
||
marketData: {
|
||
price: 98.45,
|
||
yieldToMaturity: 12.1,
|
||
duration: 8.34,
|
||
accruedInt: 12.45,
|
||
volume: 1500000,
|
||
},
|
||
},
|
||
meta: { fromCache: false, cachedAt: '2026-06-14T00:00:00.000Z' },
|
||
});
|
||
});
|
||
|
||
it('выбрасывает NotFoundException, если MOEX не вернул bond data', async () => {
|
||
vi.mocked(moexClient.getBondData).mockResolvedValue(null);
|
||
|
||
await expect(service.getBond('UNKNOWN')).rejects.toBeInstanceOf(NotFoundException);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 6: Обновить `screener.service.spec.ts` без неиспользуемого `moexClient`**
|
||
|
||
В `apps/backend/src/modules/securities/screener.service.spec.ts` удалить объявление и присваивание `moexClient`, если тесты продолжают полностью подставлять данные через `cache.getOrFetch`:
|
||
|
||
```typescript
|
||
describe('ScreenerService', () => {
|
||
let service: ScreenerService;
|
||
let cache: CacheService;
|
||
|
||
beforeEach(async () => {
|
||
const module: TestingModule = await Test.createTestingModule({
|
||
providers: [
|
||
ScreenerService,
|
||
{
|
||
provide: MoexClientService,
|
||
useValue: {
|
||
getShareMarketDataBatch: vi.fn(),
|
||
getBondPositionDataBatch: vi.fn(),
|
||
},
|
||
},
|
||
{
|
||
provide: CacheService,
|
||
useValue: {
|
||
getOrFetch: vi.fn(),
|
||
},
|
||
},
|
||
],
|
||
}).compile();
|
||
|
||
service = module.get<ScreenerService>(ScreenerService);
|
||
cache = module.get<CacheService>(CacheService);
|
||
});
|
||
});
|
||
```
|
||
|
||
Оставить существующие `screen` test cases ниже этого `beforeEach`.
|
||
|
||
- [ ] **Step 7: Проверить backend lint и backend tests**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
npm run lint
|
||
npm run test:backend
|
||
```
|
||
|
||
Expected: оба command exits 0. В backend test output нет `DataCloneError`.
|
||
|
||
- [ ] **Step 8: Commit**
|
||
|
||
```bash
|
||
git add apps/backend/src/modules/securities/securities.service.spec.ts apps/backend/src/modules/candles/candles.service.spec.ts apps/backend/src/modules/shares/shares.service.spec.ts apps/backend/src/modules/bonds/bonds.service.spec.ts apps/backend/src/modules/securities/screener.service.spec.ts
|
||
git commit -m "test: make backend service specs deterministic"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: Добавить offline проверку OpenAPI artifacts
|
||
|
||
**Files:**
|
||
|
||
- Create: `apps/backend/src/openapi-artifacts.spec.ts`
|
||
- Modify later in Task 4: `apps/frontend/src/api/types.ts`
|
||
|
||
- [ ] **Step 1: Написать failing test для generated frontend OpenAPI types**
|
||
|
||
Создать `apps/backend/src/openapi-artifacts.spec.ts`:
|
||
|
||
```typescript
|
||
import { readFileSync } from 'node:fs';
|
||
import { join, resolve } from 'node:path';
|
||
|
||
describe('checked-in OpenAPI artifacts', () => {
|
||
const rootDir = resolve(process.cwd(), '../..');
|
||
const frontendTypes = readFileSync(join(rootDir, 'apps/frontend/src/api/types.ts'), 'utf8');
|
||
|
||
const requiredPaths = [
|
||
'/api/v1/auth/register',
|
||
'/api/v1/auth/login',
|
||
'/api/v1/auth/refresh',
|
||
'/api/v1/auth/logout',
|
||
'/api/v1/auth/me',
|
||
'/api/v1/securities/screener',
|
||
'/api/v1/portfolios',
|
||
'/api/v1/portfolios/{id}',
|
||
'/api/v1/portfolios/{id}/positions',
|
||
'/api/v1/portfolios/{id}/positions/{positionId}',
|
||
'/api/v1/portfolios/{id}/analytics',
|
||
];
|
||
|
||
it('frontend generated types include current protected domains', () => {
|
||
for (const path of requiredPaths) {
|
||
expect(frontendTypes).toContain(`'${path}'`);
|
||
}
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Запустить test и убедиться, что он падает по ожидаемой причине**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
npm run test -w apps/backend -- src/openapi-artifacts.spec.ts
|
||
```
|
||
|
||
Expected: FAIL. В выводе есть missing `'/api/v1/auth/register'` или другой path из `requiredPaths`.
|
||
|
||
- [ ] **Step 3: Commit только failing artifact test**
|
||
|
||
```bash
|
||
git add apps/backend/src/openapi-artifacts.spec.ts
|
||
git commit -m "test: cover checked-in openapi artifacts"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4: Синхронизировать Swagger JSON и frontend types
|
||
|
||
**Files:**
|
||
|
||
- Modify: `apps/frontend/src/api/types.ts`
|
||
- Optional Modify: backend controller DTO metadata if `src/openapi-artifacts.spec.ts` still fails after regeneration.
|
||
|
||
- [ ] **Step 1: Запустить backend для codegen**
|
||
|
||
Run in a long-running terminal:
|
||
|
||
```bash
|
||
npm run dev:backend
|
||
```
|
||
|
||
Expected: backend starts on `http://localhost:3000`, Swagger UI is available at `http://localhost:3000/api/docs`.
|
||
|
||
- [ ] **Step 2: Проверить Swagger JSON содержит текущие paths**
|
||
|
||
Run in a second terminal:
|
||
|
||
```bash
|
||
node -e "fetch('http://localhost:3000/api/docs-json').then(r => r.json()).then(j => { const paths = Object.keys(j.paths); for (const p of ['/api/v1/auth/register','/api/v1/securities/screener','/api/v1/portfolios','/api/v1/portfolios/{id}/analytics']) { if (!paths.includes(p)) throw new Error('Missing path ' + p); } console.log('Swagger paths OK'); })"
|
||
```
|
||
|
||
Expected: prints `Swagger paths OK`.
|
||
|
||
- [ ] **Step 3: Если Swagger JSON не содержит path, добавить metadata без runtime изменений**
|
||
|
||
Если Step 2 падает из-за missing path, проверить соответствующий controller. Для `PortfolioController` базовый минимум должен выглядеть так:
|
||
|
||
```typescript
|
||
@ApiTags('Portfolios')
|
||
@ApiBearerAuth()
|
||
@Controller('portfolios')
|
||
export class PortfolioController {
|
||
@Get()
|
||
@ApiOperation({ summary: 'Get all portfolios for current user' })
|
||
@ApiOkResponse({ type: PortfolioListResponseDto, isArray: true })
|
||
async findAll(@CurrentUser() user: { sub: number }) {
|
||
const portfolios = await this.portfolioService.findAll(user.sub);
|
||
return { data: portfolios, meta: { cachedAt: null, fromCache: false } };
|
||
}
|
||
}
|
||
```
|
||
|
||
Для `SecuritiesController` screener endpoint должен иметь `@ApiOkResponse({ type: ScreenerResultDto })`, он уже есть в текущем коде. Для auth routes path обычно появляется от `@Controller('auth')` и method decorators даже без response DTO.
|
||
|
||
- [ ] **Step 4: Перегенерировать frontend OpenAPI types**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
npm run codegen -w apps/frontend
|
||
```
|
||
|
||
Expected: `apps/frontend/src/api/types.ts` changes and includes auth, screener and portfolio paths.
|
||
|
||
- [ ] **Step 5: Повторно проверить live Swagger JSON после codegen**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
node -e "fetch('http://localhost:3000/api/docs-json').then(r => r.json()).then(j => { const paths = Object.keys(j.paths); for (const p of ['/api/v1/auth/register','/api/v1/securities/screener','/api/v1/portfolios']) { if (!paths.includes(p)) throw new Error('Missing path ' + p); } console.log('Swagger paths still OK'); })"
|
||
```
|
||
|
||
Expected: prints `Swagger paths still OK`.
|
||
|
||
- [ ] **Step 6: Проверить artifact test теперь зелёный**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
npm run test -w apps/backend -- src/openapi-artifacts.spec.ts
|
||
```
|
||
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 7: Проверить backend/frontend build после codegen**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
npm run build:backend
|
||
npm run build:frontend
|
||
```
|
||
|
||
Expected: both commands exit 0.
|
||
|
||
- [ ] **Step 8: Commit**
|
||
|
||
```bash
|
||
git add apps/frontend/src/api/types.ts apps/backend/src/openapi-artifacts.spec.ts apps/backend/src/modules
|
||
git commit -m "docs: refresh openapi contract artifacts"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 5: Обновить README, AGENTS и Docusaurus docs на русском
|
||
|
||
**Files:**
|
||
|
||
- Modify: `README.md`
|
||
- Modify: `AGENTS.md`
|
||
- Modify: `apps/docs/docs/intro.md`
|
||
- Modify: `apps/docs/docs/development/commands.md`
|
||
- Modify: `apps/docs/docs/development/testing.md`
|
||
- Modify: `apps/docs/docs/development/codegen.md`
|
||
- Modify: `apps/docs/docs/frontend/overview.md`
|
||
- Modify: `apps/docs/docs/frontend/routes.md`
|
||
- Modify: `apps/docs/docs/frontend/api-client.md`
|
||
- Modify: `apps/docs/docs/backend/api.md`
|
||
- Modify: `apps/docs/docs/backend/portfolio.md`
|
||
|
||
- [ ] **Step 1: Зафиксировать текущий docs warning**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
npm run build:docs
|
||
```
|
||
|
||
Expected: command exits 0, but output includes Docusaurus broken links to `/`.
|
||
|
||
- [ ] **Step 2: Сделать intro docs home на `/`**
|
||
|
||
В начало `apps/docs/docs/intro.md` добавить front matter:
|
||
|
||
```markdown
|
||
---
|
||
slug: /
|
||
---
|
||
|
||
# MoexVibe
|
||
```
|
||
|
||
Остальной текст страницы оставить и обновить структуру репозитория, чтобы в `apps/` были `backend`, `frontend`, `docs`.
|
||
|
||
- [ ] **Step 3: Обновить root command table в `apps/docs/docs/development/commands.md`**
|
||
|
||
Заменить секцию `## Root Workspace` на:
|
||
|
||
```markdown
|
||
## Root Workspace
|
||
|
||
| Command | Description |
|
||
|---|---|
|
||
| `npm run dev:backend` | Запуск NestJS в режиме watch на `:3000` |
|
||
| `npm run dev:frontend` | Vite dev-сервер на `:5173`, проксирует `/api` на backend |
|
||
| `npm run dev:docs` | Docusaurus dev-сервер документации |
|
||
| `npm run build:backend` | `nest build` |
|
||
| `npm run build:frontend` | `tsc -b && vite build` |
|
||
| `npm run build:docs` | `docusaurus build` |
|
||
| `npm run test:backend` | Offline backend unit tests через Vitest |
|
||
| `npm run test:frontend` | Frontend tests через Vitest + Testing Library |
|
||
| `npm run lint` | ESLint для backend и frontend |
|
||
| `npm run format` | Prettier для всех `*.{ts,tsx}` |
|
||
| `npm run format:check` | Проверка Prettier для всех `*.{ts,tsx}` |
|
||
```
|
||
|
||
В `## Backend Workspace` добавить:
|
||
|
||
```markdown
|
||
| `npm run test:integration -w apps/backend` | Opt-in live MOEX integration tests, требуется network access |
|
||
```
|
||
|
||
В `## Frontend Workspace` добавить:
|
||
|
||
```markdown
|
||
| `npm run lint -w apps/frontend` | ESLint для `src/**/*.{ts,tsx}` |
|
||
| `npm run test -w apps/frontend` | Frontend Vitest suite |
|
||
```
|
||
|
||
Добавить `## Docs Workspace`:
|
||
|
||
```markdown
|
||
## Docs Workspace
|
||
|
||
| Command | Description |
|
||
|---|---|
|
||
| `npm run dev -w apps/docs` | Docusaurus dev-server |
|
||
| `npm run build -w apps/docs` | Production build документации |
|
||
| `npm run serve -w apps/docs` | Локальная проверка production build |
|
||
```
|
||
|
||
- [ ] **Step 4: Обновить `apps/docs/docs/development/testing.md`**
|
||
|
||
Заменить финальную секцию `## Frontend Tests` на:
|
||
|
||
````markdown
|
||
## Frontend Tests
|
||
|
||
Фреймворк: **Vitest 4** + **React Testing Library** + **MSW**.
|
||
|
||
Запуск:
|
||
|
||
```bash
|
||
npm run test:frontend
|
||
# или
|
||
npm run test -w apps/frontend
|
||
```
|
||
|
||
Тесты покрывают API-клиент, auth context, hooks, базовые pages и shared components.
|
||
|
||
## Live MOEX Integration Tests
|
||
|
||
Live MOEX checks вынесены из default backend suite.
|
||
|
||
```bash
|
||
npm run test:integration -w apps/backend
|
||
```
|
||
|
||
Эта команда opt-in: она требует network access и может падать при недоступности MOEX или сетевых
|
||
ограничениях окружения.
|
||
````
|
||
|
||
- [ ] **Step 5: Обновить `apps/docs/docs/frontend/routes.md`**
|
||
|
||
Заменить route table на:
|
||
|
||
```markdown
|
||
| Path | Component | Access | Description |
|
||
|---|---|---|---|
|
||
| `/` | `HomePage` | Public | Главная страница |
|
||
| `/stocks/:secid` | `StockPage` | Public | Страница акции |
|
||
| `/bonds/:secid` | `BondPage` | Public | Страница облигации |
|
||
| `/screener` | `ScreenerPage` | Public | Скринер ценных бумаг |
|
||
| `/login` | `LoginPage` | Public | Вход |
|
||
| `/register` | `RegisterPage` | Public | Регистрация |
|
||
| `/profile` | `ProfilePage` | Protected | Профиль текущего пользователя |
|
||
| `/portfolios` | `PortfoliosListPage` | Protected | Список портфелей |
|
||
| `/portfolios/:id` | `PortfolioDetailPage` | Protected | Детальная страница портфеля |
|
||
```
|
||
|
||
Обновить JSX snippet, чтобы он соответствовал `apps/frontend/src/routes.tsx`.
|
||
|
||
- [ ] **Step 6: Обновить `apps/docs/docs/frontend/api-client.md`**
|
||
|
||
Добавить в таблицу API functions:
|
||
|
||
```markdown
|
||
| `register(data)` | POST | `/api/v1/auth/register` |
|
||
| `login(data)` | POST | `/api/v1/auth/login` |
|
||
| `refresh()` | POST | `/api/v1/auth/refresh` |
|
||
| `logout()` | POST | `/api/v1/auth/logout` |
|
||
| `getProfile()` | GET | `/api/v1/auth/me` |
|
||
| `updateProfile(data)` | PATCH | `/api/v1/auth/me` |
|
||
| `screenSecurities(query)` | GET | `/api/v1/securities/screener` |
|
||
| `getPortfolios()` | GET | `/api/v1/portfolios` |
|
||
| `createPortfolio(data)` | POST | `/api/v1/portfolios` |
|
||
| `getPortfolio(id)` | GET | `/api/v1/portfolios/:id` |
|
||
| `updatePortfolio(id, data)` | PATCH | `/api/v1/portfolios/:id` |
|
||
| `deletePortfolio(id)` | DELETE | `/api/v1/portfolios/:id` |
|
||
| `addPosition(portfolioId, data)` | POST | `/api/v1/portfolios/:id/positions` |
|
||
| `updatePosition(portfolioId, positionId, data)` | PATCH | `/api/v1/portfolios/:id/positions/:positionId` |
|
||
| `removePosition(portfolioId, positionId)` | DELETE | `/api/v1/portfolios/:id/positions/:positionId` |
|
||
| `getPortfolioAnalytics(portfolioId)` | GET | `/api/v1/portfolios/:id/analytics` |
|
||
```
|
||
|
||
- [ ] **Step 7: Обновить `apps/docs/docs/backend/portfolio.md`**
|
||
|
||
В API table заменить строку update:
|
||
|
||
```markdown
|
||
| `/api/v1/portfolios/:id` | PATCH | Update portfolio (name, description, currency) |
|
||
```
|
||
|
||
- [ ] **Step 8: Обновить README и AGENTS**
|
||
|
||
В `README.md` добавить docs workspace и frontend tests:
|
||
|
||
````markdown
|
||
## Tests
|
||
|
||
```bash
|
||
npm run test:backend
|
||
npm run test:frontend
|
||
```
|
||
|
||
Live MOEX integration checks are opt-in:
|
||
|
||
```bash
|
||
npm run test:integration -w apps/backend
|
||
```
|
||
````
|
||
|
||
В `AGENTS.md` обновить:
|
||
|
||
```markdown
|
||
npm workspaces монорепозиторий: `apps/backend` (NestJS), `apps/frontend` (React + Vite), `apps/docs` (Docusaurus).
|
||
```
|
||
|
||
И строки:
|
||
|
||
```markdown
|
||
| `npm run test:frontend` | Frontend Vitest suite |
|
||
| `npm run build:docs` | `docusaurus build` |
|
||
| `npm run dev:docs` | Docusaurus dev-сервер |
|
||
| `npm run lint` | ESLint для backend и frontend |
|
||
```
|
||
|
||
Заменить утверждения:
|
||
|
||
```markdown
|
||
- Тесты фронтенда есть: Vitest + Testing Library + MSW.
|
||
- CI находится в `.gitea/workflows/ci.yml`.
|
||
- Pre-commit checks настроены через Husky и lint-staged.
|
||
```
|
||
|
||
- [ ] **Step 9: Проверить docs build**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
npm run build:docs
|
||
```
|
||
|
||
Expected: command exits 0. В выводе нет Docusaurus broken links to `/`. Warning про `/Users/ksv741/.config` может остаться, потому что это внешняя update-check настройка вне репозитория.
|
||
|
||
- [ ] **Step 10: Commit**
|
||
|
||
```bash
|
||
git add README.md AGENTS.md apps/docs/docs/intro.md apps/docs/docs/development/commands.md apps/docs/docs/development/testing.md apps/docs/docs/development/codegen.md apps/docs/docs/frontend/overview.md apps/docs/docs/frontend/routes.md apps/docs/docs/frontend/api-client.md apps/docs/docs/backend/api.md apps/docs/docs/backend/portfolio.md
|
||
git commit -m "docs: refresh project documentation"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 6: Финальная проверка quality gate
|
||
|
||
**Files:**
|
||
|
||
- No direct edits expected.
|
||
- Verification over repository root.
|
||
|
||
- [ ] **Step 1: Запустить полный набор проверок**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
npm run lint
|
||
npm run test:backend
|
||
npm run test:frontend
|
||
npm run build:backend
|
||
npm run build:frontend
|
||
npm run build:docs
|
||
npm run format:check
|
||
```
|
||
|
||
Expected: all commands exit 0. `npm run build:docs` не сообщает Docusaurus broken links на `/`.
|
||
|
||
- [ ] **Step 2: Проверить git status**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
git status --short
|
||
```
|
||
|
||
Expected: empty output.
|
||
|
||
- [ ] **Step 3: Если format changed files, сделать отдельный commit**
|
||
|
||
Run only if formatting changed files:
|
||
|
||
```bash
|
||
git add .
|
||
git commit -m "chore: apply formatting after quality gate refresh"
|
||
```
|
||
|
||
Expected: commit created only when `git status --short` showed formatting changes.
|
||
|
||
---
|
||
|
||
## Self-Review плана
|
||
|
||
- Spec coverage: Task 1 и Task 2 стабилизируют default checks; Task 3 и Task 4 покрывают OpenAPI artifacts; Task 5 покрывает README, AGENTS и Docusaurus; Task 6 покрывает финальную verification matrix.
|
||
- Placeholder scan: placeholder markers и незавершённые инструкции отсутствуют.
|
||
- Type consistency: test snippets используют существующие `MoexClientService`, `CacheService`, `SecurityType`, `CandleInterval` и DTO paths.
|
||
- Scope check: план не включает feature changes, глубокий refactor сервисов или rewrite frontend API client.
|