From bc2d2af141793c8e0e85dae52c2f9e9ddfa8cbf7 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Sun, 14 Jun 2026 22:01:36 +0300 Subject: [PATCH] docs: add quality gate implementation plan --- .../2026-06-14-quality-gate-contract-docs.md | 1401 +++++++++++++++++ 1 file changed, 1401 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-14-quality-gate-contract-docs.md diff --git a/docs/superpowers/plans/2026-06-14-quality-gate-contract-docs.md b/docs/superpowers/plans/2026-06-14-quality-gate-contract-docs.md new file mode 100644 index 0000000..f010472 --- /dev/null +++ b/docs/superpowers/plans/2026-06-14-quality-gate-contract-docs.md @@ -0,0 +1,1401 @@ +# Стабилизация 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 на 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. +- `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: + +```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` +- Modify later in Task 4: `docs/openapi/openapi.yaml` + +- [ ] **Step 1: Написать failing test для checked-in OpenAPI artifacts** + +Создать `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 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: + +```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 и 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.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: Перегенерировать static YAML snapshot** + +Run: + +```bash +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: + +```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 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: + +```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.