Sergey Krylov 659be4636c
All checks were successful
CI / ci (pull_request) Successful in 12m44s
CI / ci (push) Successful in 14m21s
docs: mark portfolio-analytics and quality-gate-contract-docs as completed in spec/plan
2026-06-24 19:06:24 +03:00

1391 lines
44 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Стабилизация 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 (`- [x]`) 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`
- [x] **Step 1: Зафиксировать красное состояние default backend tests**
Run:
```bash
npm run test:backend
```
Expected: FAIL. В выводе есть `Vitest caught ... unhandled errors` и `DataCloneError` вокруг Axios `transformRequest`.
- [x] **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\""
}
}
```
- [x] **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);
});
```
- [x] **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',
});
});
});
```
- [x] **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`.
- [x] **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`.
- [x] **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`
- [x] **Step 1: Зафиксировать красное состояние lint**
Run:
```bash
npm run lint
```
Expected: FAIL с `moexClient is assigned a value but never used` в `screener.service.spec.ts`.
- [x] **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',
);
});
});
```
- [x] **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);
});
});
```
- [x] **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);
});
});
```
- [x] **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);
});
});
```
- [x] **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`.
- [x] **Step 7: Проверить backend lint и backend tests**
Run:
```bash
npm run lint
npm run test:backend
```
Expected: оба command exits 0. В backend test output нет `DataCloneError`.
- [x] **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`
- [x] **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}'`);
}
});
});
```
- [x] **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`.
- [x] **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.
- [x] **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`.
- [x] **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`.
- [x] **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.
- [x] **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.
- [x] **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`.
- [x] **Step 6: Проверить artifact test теперь зелёный**
Run:
```bash
npm run test -w apps/backend -- src/openapi-artifacts.spec.ts
```
Expected: PASS.
- [x] **Step 7: Проверить backend/frontend build после codegen**
Run:
```bash
npm run build:backend
npm run build:frontend
```
Expected: both commands exit 0.
- [x] **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`
- [x] **Step 1: Зафиксировать текущий docs warning**
Run:
```bash
npm run build:docs
```
Expected: command exits 0, but output includes Docusaurus broken links to `/`.
- [x] **Step 2: Сделать intro docs home на `/`**
В начало `apps/docs/docs/intro.md` добавить front matter:
```markdown
---
slug: /
---
# MoexVibe
```
Остальной текст страницы оставить и обновить структуру репозитория, чтобы в `apps/` были `backend`, `frontend`, `docs`.
- [x] **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 |
```
- [x] **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 или сетевых
ограничениях окружения.
````
- [x] **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`.
- [x] **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` |
```
- [x] **Step 7: Обновить `apps/docs/docs/backend/portfolio.md`**
В API table заменить строку update:
```markdown
| `/api/v1/portfolios/:id` | PATCH | Update portfolio (name, description, currency) |
```
- [x] **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.
```
- [x] **Step 9: Проверить docs build**
Run:
```bash
npm run build:docs
```
Expected: command exits 0. В выводе нет Docusaurus broken links to `/`. Warning про `/Users/ksv741/.config` может остаться, потому что это внешняя update-check настройка вне репозитория.
- [x] **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.
- [x] **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 на `/`.
- [x] **Step 2: Проверить git status**
Run:
```bash
git status --short
```
Expected: empty output.
- [x] **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.