# Стабилизация 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); }); 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; 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 = { '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; let cache: Pick; beforeEach(async () => { moexClient = { searchSecurities: vi.fn(), } as unknown as Pick; cache = { getOrFetch: vi.fn(async (_prefix, _parts, fetchFn) => ({ data: await fetchFn(), fromCache: false, cachedAt: '2026-06-14T00:00:00.000Z', })), } as unknown as Pick; const module: TestingModule = await Test.createTestingModule({ providers: [ SecuritiesService, { provide: MoexClientService, useValue: moexClient }, { provide: CacheService, useValue: cache }, ], }).compile(); service = module.get(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; let cache: Pick; beforeEach(async () => { moexClient = { getCandles: vi.fn(), } as unknown as Pick; cache = { getOrFetch: vi.fn(async (_prefix, _parts, fetchFn) => ({ data: await fetchFn(), fromCache: false, cachedAt: '2026-06-14T00:00:00.000Z', })), } as unknown as Pick; const module: TestingModule = await Test.createTestingModule({ providers: [ CandlesService, { provide: MoexClientService, useValue: moexClient }, { provide: CacheService, useValue: cache }, ], }).compile(); service = module.get(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; let cache: Pick; beforeEach(async () => { moexClient = { getSecurityDescription: vi.fn(), getShareMarketData: vi.fn(), } as unknown as Pick; cache = { getOrFetch: vi.fn(async (_prefix, _parts, fetchFn) => ({ data: await fetchFn(), fromCache: false, cachedAt: '2026-06-14T00:00:00.000Z', })), } as unknown as Pick; const module: TestingModule = await Test.createTestingModule({ providers: [ SharesService, { provide: MoexClientService, useValue: moexClient }, { provide: CacheService, useValue: cache }, ], }).compile(); service = module.get(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; let cache: Pick; beforeEach(async () => { moexClient = { getBondData: vi.fn(), getBondMarketData: vi.fn(), } as unknown as Pick; cache = { getOrFetch: vi.fn(async (_prefix, _parts, fetchFn) => ({ data: await fetchFn(), fromCache: false, cachedAt: '2026-06-14T00:00:00.000Z', })), } as unknown as Pick; const module: TestingModule = await Test.createTestingModule({ providers: [ BondsService, { provide: MoexClientService, useValue: moexClient }, { provide: CacheService, useValue: cache }, ], }).compile(); service = module.get(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); cache = module.get(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.