moex-vibe/docs/superpowers/plans/2026-06-14-quality-gate-contract-docs.md

44 KiB
Raw Blame History

Стабилизация 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:

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.ts still 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.