44 KiB
Стабилизация 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, затем синхронизируем docs/openapi/openapi.yaml и 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 проверка, что checked-in OpenAPI artifacts содержат актуальные 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 на mockedMoexClientServiceи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.docs/openapi/openapi.yaml— обновить snapshot из текущего 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:
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:
{
"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:
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:
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:
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:
npm run test:backend
Expected: всё ещё может падать на других live service specs, но moex-client.service.integration.spec.ts не должен запускать live MOEX checks без test:integration.
- Step 7: Commit
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:
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:
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:
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:
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:
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:
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:
npm run lint
npm run test:backend
Expected: оба command exits 0. В backend test output нет DataCloneError.
- Step 8: Commit
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 -
Modify later in Task 4:
docs/openapi/openapi.yaml -
Step 1: Написать failing test для checked-in OpenAPI artifacts
Создать apps/backend/src/openapi-artifacts.spec.ts:
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 openapiYaml = readFileSync(join(rootDir, 'docs/openapi/openapi.yaml'), '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}'`);
}
});
it('static OpenAPI YAML snapshot includes current protected domains', () => {
for (const path of requiredPaths) {
expect(openapiYaml).toContain(`${path}:`);
}
});
});
- Step 2: Запустить test и убедиться, что он падает по ожидаемой причине
Run:
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
git add apps/backend/src/openapi-artifacts.spec.ts
git commit -m "test: cover checked-in openapi artifacts"
Task 4: Синхронизировать Swagger JSON, frontend types и static OpenAPI YAML
Files:
-
Modify:
apps/frontend/src/api/types.ts -
Modify:
docs/openapi/openapi.yaml -
Optional Modify: backend controller DTO metadata if
src/openapi-artifacts.spec.tsstill fails after regeneration. -
Step 1: Запустить backend для codegen
Run in a long-running terminal:
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:
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 базовый минимум должен выглядеть так:
@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:
npm run codegen -w apps/frontend
Expected: apps/frontend/src/api/types.ts changes and includes auth, screener and portfolio paths.
- Step 5: Перегенерировать static YAML snapshot
Run:
node -e "fetch('http://localhost:3000/api/docs-json').then(r => r.json()).then(j => require('node:fs').writeFileSync('/tmp/moex-vibe-openapi.json', JSON.stringify(j, null, 2)))"
node -e "const fs = require('node:fs'); const yaml = require('js-yaml'); const json = JSON.parse(fs.readFileSync('/tmp/moex-vibe-openapi.json', 'utf8')); fs.writeFileSync('docs/openapi/openapi.yaml', yaml.dump(json, { lineWidth: 120, noRefs: true }));"
Expected: docs/openapi/openapi.yaml changes and includes /api/v1/auth/register, /api/v1/securities/screener, /api/v1/portfolios.
- Step 6: Проверить artifact test теперь зелёный
Run:
npm run test -w apps/backend -- src/openapi-artifacts.spec.ts
Expected: PASS.
- Step 7: Проверить backend/frontend build после codegen
Run:
npm run build:backend
npm run build:frontend
Expected: both commands exit 0.
- Step 8: Commit
git add apps/frontend/src/api/types.ts docs/openapi/openapi.yaml 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:
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:
---
slug: /
---
# MoexVibe
Остальной текст страницы оставить и обновить структуру репозитория, чтобы в apps/ были backend, frontend, docs.
- Step 3: Обновить root command table в
apps/docs/docs/development/commands.md
Заменить секцию ## Root Workspace на:
## 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 добавить:
| `npm run test:integration -w apps/backend` | Opt-in live MOEX integration tests, требуется network access |
В ## Frontend Workspace добавить:
| `npm run lint -w apps/frontend` | ESLint для `src/**/*.{ts,tsx}` |
| `npm run test -w apps/frontend` | Frontend Vitest suite |
Добавить ## Docs Workspace:
## 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 на:
## 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 на:
| 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:
| `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:
| `/api/v1/portfolios/:id` | PATCH | Update portfolio (name, description, currency) |
- Step 8: Обновить README и AGENTS
В README.md добавить docs workspace и frontend tests:
## 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 обновить:
npm workspaces монорепозиторий: `apps/backend` (NestJS), `apps/frontend` (React + Vite), `apps/docs` (Docusaurus).
И строки:
| `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 |
Заменить утверждения:
- Тесты фронтенда есть: Vitest + Testing Library + MSW.
- CI находится в `.gitea/workflows/ci.yml`.
- Pre-commit checks настроены через Husky и lint-staged.
- Step 9: Проверить docs build
Run:
npm run build:docs
Expected: command exits 0. В выводе нет Docusaurus broken links to /. Warning про /Users/ksv741/.config может остаться, потому что это внешняя update-check настройка вне репозитория.
- Step 10: Commit
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:
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:
git status --short
Expected: empty output.
- Step 3: Если format changed files, сделать отдельный commit
Run only if formatting changed files:
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.