From ee88b2918129701d8a648dd23dd94bd3bd244924 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Sun, 14 Jun 2026 21:46:38 +0300 Subject: [PATCH 01/10] docs: add quality gate contract docs spec --- ...06-14-quality-gate-contract-docs-design.md | 326 ++++++++++++++++++ 1 file changed, 326 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-14-quality-gate-contract-docs-design.md diff --git a/docs/superpowers/specs/2026-06-14-quality-gate-contract-docs-design.md b/docs/superpowers/specs/2026-06-14-quality-gate-contract-docs-design.md new file mode 100644 index 0000000..a93bd1f --- /dev/null +++ b/docs/superpowers/specs/2026-06-14-quality-gate-contract-docs-design.md @@ -0,0 +1,326 @@ +# Quality Gate, API Contract, and Documentation Refresh Design + +## Status + +Approved for specification on 2026-06-14. + +## PRD + +### Problem + +MoexVibe has accumulated coordination debt between tests, generated contracts, and documentation. +The application still builds, but the default backend quality gate is not trustworthy: + +- `npm run lint` fails on an unused variable in a backend test. +- `npm run test:backend` fails because some backend tests call the live MOEX API through the real + `MoexClientService`. +- The live MOEX failures surface as Vitest `DataCloneError` reports because Axios errors contain + non-cloneable request configuration functions. +- `docs/openapi/openapi.yaml` and `apps/frontend/src/api/types.ts` do not include current `auth`, + `portfolios`, and `securities/screener` endpoints. +- README, AGENTS, and Docusaurus pages describe an older state of the repo in several places. +- `npm run build:docs` succeeds but emits Docusaurus broken-link warnings for `/`. + +This makes future feature work slower: contributors cannot tell which command output matters, which +API contract is current, or whether backend tests are failing because of application behavior or a +network dependency. + +### Goals + +1. Make default quality checks deterministic and suitable for local development and CI. +2. Keep live MOEX checks available, but move them out of the default unit-test path. +3. Re-establish a clear source of truth for the OpenAPI contract. +4. Regenerate or synchronize frontend OpenAPI types with the current backend routes. +5. Bring README, AGENTS, and Docusaurus docs in line with the current repo. +6. Remove actionable Docusaurus broken-link warnings from the docs build. + +### Non-Goals + +- No user-facing feature changes. +- No deep refactor of `PortfolioService` or `MoexClientService`. +- No migration to a fully generated frontend API client. +- No Redis, database, auth model, or portfolio analytics redesign. +- No CI provider migration. + +## Current Findings + +### Passing Checks + +- `npm run test:frontend` passes: 22 files, 95 tests. +- `npm run format:check` passes. +- `npm run build:backend` passes. +- `npm run build:frontend` passes. +- `npm run build:docs` produces static files successfully. + +### Failing or Noisy Checks + +- `npm run lint` fails in + `apps/backend/src/modules/securities/screener.service.spec.ts` because `moexClient` is assigned + but not used. +- `npm run test:backend` fails with unhandled Vitest errors. The affected specs instantiate the real + `MoexClientService` and call MOEX: + - `apps/backend/src/modules/moex-client/moex-client.service.spec.ts` + - `apps/backend/src/modules/securities/securities.service.spec.ts` + - `apps/backend/src/modules/candles/candles.service.spec.ts` + - similar service specs for shares and bonds also rely on live MOEX access. +- `npm run build:docs` warns that many pages link to `/`. + +### Stale Documentation and Contract Artifacts + +- `AGENTS.md` says `npm run lint` is backend-only and frontend tests are absent. +- `apps/docs/docs/development/testing.md` says frontend tests are absent. +- `apps/docs/docs/development/commands.md` omits `test:frontend`, docs scripts, and frontend lint. +- `apps/docs/docs/frontend/routes.md` omits `/login`, `/register`, `/profile`, `/portfolios`, + `/portfolios/:id`, and `/screener`. +- `apps/docs/docs/frontend/overview.md` omits auth, portfolio, screener, and test directories. +- `apps/docs/docs/backend/api.md` omits portfolio and screener endpoints. +- `apps/docs/docs/backend/portfolio.md` documents `PATCH /api/v1/portfolios/:id/patch`, while the + controller implements `PATCH /api/v1/portfolios/:id`. +- `docs/openapi/openapi.yaml` and `apps/frontend/src/api/types.ts` only contain the early health, + search, shares, bonds, and candles paths. + +## Domain Model + +### Quality Gate + +A quality gate is a repeatable command that developers can run without external dependencies unless +the command explicitly says otherwise. + +Default gates: + +- `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` + +Opt-in gates: + +- Live MOEX integration checks. These may use network access and should not run in default unit-test + or CI jobs unless explicitly requested. + +### Contract Source + +The authoritative backend contract is the NestJS Swagger document generated from controllers and DTO +decorators at `/api/docs-json`. + +Generated or synchronized artifacts: + +- `docs/openapi/openapi.yaml` as a checked-in human-readable snapshot. +- `apps/frontend/src/api/types.ts` as generated TypeScript path and schema types. +- Docusaurus API pages as explanatory documentation, not the canonical machine contract. + +### Documentation Source + +Documentation should describe the current repository state, not a historical implementation plan. +Superpowers specs and plans remain project history; README, AGENTS, and Docusaurus pages are the +current onboarding surface. + +## ADR + +### Decision + +Split backend tests into deterministic unit tests and opt-in live MOEX integration tests. + +### Rationale + +The default backend test command currently mixes unit behavior with external network availability. +That makes failures ambiguous and produces noisy Vitest serialization errors when Axios rejects with +non-cloneable configuration fields. Unit tests should verify application logic with controlled +fixtures. Live MOEX tests are still valuable, but they belong in a separately named command with an +explicit environment requirement. + +### Consequences + +- `npm run test:backend` becomes stable offline. +- Live MOEX coverage remains available through a dedicated integration command. +- Some existing specs will change from "real MOEX smoke test" to "service behavior with mocked + `MoexClientService`". +- Contract drift becomes visible because OpenAPI snapshots and frontend generated types are updated + as part of this work. + +## Backend Architecture + +### Unit Test Boundary + +Service tests for `SharesService`, `BondsService`, `CandlesService`, and `SecuritiesService` should +mock `MoexClientService` and `CacheService`. + +The mocked data should exercise behavior that matters to MoexVibe: + +- normalized share data is returned from cached or fetched MOEX client data; +- normalized bond data handles missing market fields as nullable values; +- candles are mapped into the public response shape; +- search and screener behavior uses deterministic fixture rows; +- cache metadata remains represented through `{ data, meta }` where service contracts require it. + +### Live Integration Test Boundary + +Live MOEX checks should be isolated in `*.integration.spec.ts` files or an equivalent explicit test +path. They should only run through a dedicated command such as `npm run test:integration -w +apps/backend` and should document that network access is required. + +The integration command should not be part of the default `npm run test:backend` command. + +### OpenAPI Decorators + +Controllers that already exist should expose enough Swagger metadata for generated path types: + +- Auth routes: register, login, refresh, logout, me, profile update. +- Securities routes: search and screener. +- Portfolio routes: list, create, detail, update, delete, position mutations, analytics. +- Existing shares, bonds, candles, and health routes. + +The implementation should prefer existing DTOs and response DTOs. If a response lacks a DTO and +adding one would be large, the first pass may use minimal response decorators while keeping runtime +behavior unchanged. + +## Frontend Architecture + +### Generated Types + +`apps/frontend/src/api/types.ts` should be regenerated from the current backend Swagger JSON after +backend Swagger metadata includes the current routes. + +The existing hand-written `responses.ts` and API wrapper modules remain in place for this epic. The +goal is contract freshness, not a full client rewrite. + +### Tests + +Frontend tests already exist and pass. This epic should not rewrite frontend testing architecture. +If API response types change, update frontend tests only where the regenerated contract reveals a +real mismatch. + +## OpenAPI Contract Scope + +The synchronized contract should include at least these paths under `/api/v1`: + +- `GET /health` +- `POST /auth/register` +- `POST /auth/login` +- `POST /auth/refresh` +- `POST /auth/logout` +- `GET /auth/me` +- `PATCH /auth/me` +- `GET /securities/search` +- `GET /securities/screener` +- `GET /securities/shares/{secid}` +- `GET /securities/shares/{secid}/marketdata` +- `GET /securities/shares/{secid}/dividends` +- `GET /securities/shares/{secid}/history` +- `GET /securities/shares/{secid}/candles` +- `GET /securities/bonds/{secid}` +- `GET /securities/bonds/{secid}/marketdata` +- `GET /securities/bonds/{secid}/history` +- `GET /securities/bonds/{secid}/candles` +- `GET /portfolios` +- `POST /portfolios` +- `GET /portfolios/{id}` +- `PATCH /portfolios/{id}` +- `DELETE /portfolios/{id}` +- `POST /portfolios/{id}/positions` +- `PATCH /portfolios/{id}/positions/{positionId}` +- `DELETE /portfolios/{id}/positions/{positionId}` +- `GET /portfolios/{id}/analytics` + +## Documentation Architecture + +### README + +Update README so quickstart and test sections mention backend, frontend, and docs workspaces. + +### AGENTS + +Update AGENTS to reflect: + +- `apps/docs` is part of the workspace. +- `npm run lint` runs backend and frontend lint. +- frontend tests exist. +- pre-commit checks exist through Husky and lint-staged. +- CI exists under `.gitea/workflows/ci.yml`. + +### Docusaurus + +Update current onboarding pages: + +- development commands; +- testing; +- code generation; +- frontend overview; +- frontend routes; +- frontend API client; +- backend API; +- backend portfolio. + +Fix Docusaurus config or docs links so `npm run build:docs` no longer reports broken links to `/`. +The separate update-check warning about `/Users/ksv741/.config` permissions is external to the repo +and does not need to be fixed in this epic. + +## Implementation Phases + +### Phase 1: Stabilize Default Checks + +1. Fix the unused backend test variable that breaks lint. +2. Convert default backend service specs away from live MOEX calls. +3. Move or add live MOEX smoke coverage under an opt-in integration command. +4. Verify `npm run lint` and `npm run test:backend`. + +### Phase 2: Refresh Contract Artifacts + +1. Add or complete Swagger metadata for current routes. +2. Regenerate `apps/frontend/src/api/types.ts`. +3. Synchronize `docs/openapi/openapi.yaml` with the current contract. +4. Verify generated paths include auth, screener, and portfolio routes. + +### Phase 3: Refresh Documentation + +1. Update README and AGENTS. +2. Update Docusaurus development, frontend, backend, and portfolio pages. +3. Fix Docusaurus broken `/` link warning. +4. Verify `npm run build:docs`. + +### Phase 4: Full Verification + +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 +``` + +## Acceptance Criteria + +- `npm run lint` exits 0. +- `npm run test:backend` exits 0 without live MOEX/network dependency. +- `npm run test:frontend` exits 0. +- `npm run build:backend` exits 0. +- `npm run build:frontend` exits 0. +- `npm run build:docs` exits 0 and no longer reports Docusaurus broken links for `/`. +- `npm run format:check` exits 0. +- `apps/frontend/src/api/types.ts` includes current auth, screener, and portfolio paths. +- `docs/openapi/openapi.yaml` includes current auth, screener, and portfolio paths. +- README, AGENTS, and Docusaurus docs no longer claim that frontend tests are absent. +- Portfolio docs use `PATCH /api/v1/portfolios/:id`, matching the controller. + +## Risks + +- Swagger decorators may expose DTO gaps that were previously hidden by hand-written frontend types. + Keep the first pass focused on path and schema freshness; defer API-client redesign. +- Live MOEX tests may still fail under network restrictions. That is acceptable only for the + opt-in integration command, not for the default backend test command. +- Regenerating OpenAPI artifacts can produce broad diffs. Review generated changes separately from + hand-written docs changes. + +## Spec Self-Review + +- Placeholder scan: no placeholder markers or incomplete sections. +- Internal consistency: default tests are offline; live MOEX checks are opt-in. +- Scope check: focused on quality gates, contract snapshots, and docs freshness. +- Ambiguity check: acceptance criteria name concrete commands and contract paths. -- 2.47.2 From 2e79e6940344d9853057fb29e7c7e417851e6cc1 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Sun, 14 Jun 2026 21:50:58 +0300 Subject: [PATCH 02/10] docs: translate quality gate spec to russian --- ...06-14-quality-gate-contract-docs-design.md | 349 +++++++++--------- 1 file changed, 178 insertions(+), 171 deletions(-) diff --git a/docs/superpowers/specs/2026-06-14-quality-gate-contract-docs-design.md b/docs/superpowers/specs/2026-06-14-quality-gate-contract-docs-design.md index a93bd1f..97c01e8 100644 --- a/docs/superpowers/specs/2026-06-14-quality-gate-contract-docs-design.md +++ b/docs/superpowers/specs/2026-06-14-quality-gate-contract-docs-design.md @@ -1,92 +1,94 @@ -# Quality Gate, API Contract, and Documentation Refresh Design +# Дизайн стабилизации quality gate, API-контракта и документации -## Status +## Статус -Approved for specification on 2026-06-14. +Одобрено для спецификации 2026-06-14. ## PRD -### Problem +### Проблема -MoexVibe has accumulated coordination debt between tests, generated contracts, and documentation. -The application still builds, but the default backend quality gate is not trustworthy: +В MoexVibe накопился координационный технический долг между тестами, сгенерированными контрактами +и документацией. Приложение всё ещё собирается, но стандартный backend quality gate сейчас нельзя +считать надёжным: -- `npm run lint` fails on an unused variable in a backend test. -- `npm run test:backend` fails because some backend tests call the live MOEX API through the real - `MoexClientService`. -- The live MOEX failures surface as Vitest `DataCloneError` reports because Axios errors contain - non-cloneable request configuration functions. -- `docs/openapi/openapi.yaml` and `apps/frontend/src/api/types.ts` do not include current `auth`, - `portfolios`, and `securities/screener` endpoints. -- README, AGENTS, and Docusaurus pages describe an older state of the repo in several places. -- `npm run build:docs` succeeds but emits Docusaurus broken-link warnings for `/`. +- `npm run lint` падает из-за неиспользуемой переменной в backend-тесте. +- `npm run test:backend` падает, потому что часть backend-тестов обращается к живому MOEX API через + настоящий `MoexClientService`. +- Ошибки live MOEX-запросов проявляются как Vitest `DataCloneError`, потому что `AxiosError` + содержит функции в конфигурации запроса, которые нельзя клонировать между worker'ами. +- `docs/openapi/openapi.yaml` и `apps/frontend/src/api/types.ts` не содержат актуальные эндпоинты + `auth`, `portfolios` и `securities/screener`. +- README, AGENTS и страницы Docusaurus местами описывают старое состояние репозитория. +- `npm run build:docs` успешно генерирует статические файлы, но выводит предупреждения Docusaurus о + broken link на `/`. -This makes future feature work slower: contributors cannot tell which command output matters, which -API contract is current, or whether backend tests are failing because of application behavior or a -network dependency. +Из-за этого следующие задачи делать медленнее: разработчику неочевидно, какой вывод команд важен, +какой API-контракт актуален и почему backend-тесты падают: из-за поведения приложения или из-за +сетевой зависимости. -### Goals +### Цели -1. Make default quality checks deterministic and suitable for local development and CI. -2. Keep live MOEX checks available, but move them out of the default unit-test path. -3. Re-establish a clear source of truth for the OpenAPI contract. -4. Regenerate or synchronize frontend OpenAPI types with the current backend routes. -5. Bring README, AGENTS, and Docusaurus docs in line with the current repo. -6. Remove actionable Docusaurus broken-link warnings from the docs build. +1. Сделать стандартные проверки детерминированными и пригодными для локальной разработки и CI. +2. Сохранить live MOEX-проверки, но вынести их из стандартного unit-test пути. +3. Вернуть понятный источник правды для OpenAPI-контракта. +4. Перегенерировать или синхронизировать frontend OpenAPI-типы с текущими backend routes. +5. Привести README, AGENTS и Docusaurus-документацию к текущему состоянию репозитория. +6. Убрать actionable предупреждения Docusaurus о broken links из `npm run build:docs`. -### Non-Goals +### Не входит в задачу -- No user-facing feature changes. -- No deep refactor of `PortfolioService` or `MoexClientService`. -- No migration to a fully generated frontend API client. -- No Redis, database, auth model, or portfolio analytics redesign. -- No CI provider migration. +- Пользовательские feature-изменения. +- Глубокий рефакторинг `PortfolioService` или `MoexClientService`. +- Переход frontend API-клиента на полностью сгенерированный клиент. +- Redis, изменение схемы БД, redesign auth-модели или portfolio analytics. +- Миграция CI-провайдера. -## Current Findings +## Текущие находки -### Passing Checks +### Проходящие проверки -- `npm run test:frontend` passes: 22 files, 95 tests. -- `npm run format:check` passes. -- `npm run build:backend` passes. -- `npm run build:frontend` passes. -- `npm run build:docs` produces static files successfully. +- `npm run test:frontend` проходит: 22 файла, 95 тестов. +- `npm run format:check` проходит. +- `npm run build:backend` проходит. +- `npm run build:frontend` проходит. +- `npm run build:docs` успешно генерирует статические файлы. -### Failing or Noisy Checks +### Падающие или шумные проверки -- `npm run lint` fails in - `apps/backend/src/modules/securities/screener.service.spec.ts` because `moexClient` is assigned - but not used. -- `npm run test:backend` fails with unhandled Vitest errors. The affected specs instantiate the real - `MoexClientService` and call MOEX: +- `npm run lint` падает в `apps/backend/src/modules/securities/screener.service.spec.ts`, потому + что `moexClient` присваивается, но не используется. +- `npm run test:backend` падает с unhandled Vitest errors. Затронутые specs создают настоящий + `MoexClientService` и ходят в MOEX: - `apps/backend/src/modules/moex-client/moex-client.service.spec.ts` - `apps/backend/src/modules/securities/securities.service.spec.ts` - `apps/backend/src/modules/candles/candles.service.spec.ts` - - similar service specs for shares and bonds also rely on live MOEX access. -- `npm run build:docs` warns that many pages link to `/`. + - похожие service specs для shares и bonds тоже зависят от live MOEX-доступа. +- `npm run build:docs` предупреждает, что многие страницы ссылаются на `/`. -### Stale Documentation and Contract Artifacts +### Устаревшая документация и контрактные артефакты -- `AGENTS.md` says `npm run lint` is backend-only and frontend tests are absent. -- `apps/docs/docs/development/testing.md` says frontend tests are absent. -- `apps/docs/docs/development/commands.md` omits `test:frontend`, docs scripts, and frontend lint. -- `apps/docs/docs/frontend/routes.md` omits `/login`, `/register`, `/profile`, `/portfolios`, - `/portfolios/:id`, and `/screener`. -- `apps/docs/docs/frontend/overview.md` omits auth, portfolio, screener, and test directories. -- `apps/docs/docs/backend/api.md` omits portfolio and screener endpoints. -- `apps/docs/docs/backend/portfolio.md` documents `PATCH /api/v1/portfolios/:id/patch`, while the - controller implements `PATCH /api/v1/portfolios/:id`. -- `docs/openapi/openapi.yaml` and `apps/frontend/src/api/types.ts` only contain the early health, - search, shares, bonds, and candles paths. +- `AGENTS.md` говорит, что `npm run lint` проверяет только backend, а frontend-тестов нет. +- `apps/docs/docs/development/testing.md` говорит, что frontend-тестов нет. +- `apps/docs/docs/development/commands.md` не описывает `test:frontend`, docs scripts и frontend + lint. +- `apps/docs/docs/frontend/routes.md` не описывает `/login`, `/register`, `/profile`, + `/portfolios`, `/portfolios/:id` и `/screener`. +- `apps/docs/docs/frontend/overview.md` не описывает auth, portfolio, screener и test-директории. +- `apps/docs/docs/backend/api.md` не содержит portfolio и screener endpoints. +- `apps/docs/docs/backend/portfolio.md` документирует `PATCH /api/v1/portfolios/:id/patch`, хотя + controller реализует `PATCH /api/v1/portfolios/:id`. +- `docs/openapi/openapi.yaml` и `apps/frontend/src/api/types.ts` содержат только ранние paths для + health, search, shares, bonds и candles. -## Domain Model +## Доменная модель ### Quality Gate -A quality gate is a repeatable command that developers can run without external dependencies unless -the command explicitly says otherwise. +Quality gate: это повторяемая команда, которую разработчик может запускать без внешних зависимостей, +если сама команда явно не говорит обратного. -Default gates: +Стандартные проверки: - `npm run lint` - `npm run test:backend` @@ -96,106 +98,108 @@ Default gates: - `npm run build:docs` - `npm run format:check` -Opt-in gates: +Opt-in проверки: -- Live MOEX integration checks. These may use network access and should not run in default unit-test - or CI jobs unless explicitly requested. +- Live MOEX integration checks. Они могут требовать network access и не должны запускаться в + стандартных unit tests или CI jobs без явного запроса. -### Contract Source +### Источник API-контракта -The authoritative backend contract is the NestJS Swagger document generated from controllers and DTO -decorators at `/api/docs-json`. +Авторитетный backend contract: NestJS Swagger document, который генерируется из controllers и DTO +decorators по `/api/docs-json`. -Generated or synchronized artifacts: +Сгенерированные или синхронизированные артефакты: -- `docs/openapi/openapi.yaml` as a checked-in human-readable snapshot. -- `apps/frontend/src/api/types.ts` as generated TypeScript path and schema types. -- Docusaurus API pages as explanatory documentation, not the canonical machine contract. +- `docs/openapi/openapi.yaml`: checked-in человекочитаемый snapshot. +- `apps/frontend/src/api/types.ts`: сгенерированные TypeScript path и schema types. +- Docusaurus API pages: поясняющая документация, но не канонический machine contract. -### Documentation Source +### Источник документации -Documentation should describe the current repository state, not a historical implementation plan. -Superpowers specs and plans remain project history; README, AGENTS, and Docusaurus pages are the -current onboarding surface. +Документация должна описывать текущее состояние репозитория, а не исторический план реализации. +Superpowers specs и plans остаются историей проекта. README, AGENTS и Docusaurus pages являются +актуальной onboarding-поверхностью. ## ADR -### Decision +### Решение -Split backend tests into deterministic unit tests and opt-in live MOEX integration tests. +Разделить backend tests на детерминированные unit tests и opt-in live MOEX integration tests. -### Rationale +### Обоснование -The default backend test command currently mixes unit behavior with external network availability. -That makes failures ambiguous and produces noisy Vitest serialization errors when Axios rejects with -non-cloneable configuration fields. Unit tests should verify application logic with controlled -fixtures. Live MOEX tests are still valuable, but they belong in a separately named command with an -explicit environment requirement. +Стандартная backend test command сейчас смешивает unit-поведение и доступность внешней сети. Это +делает failures неоднозначными и порождает шумные Vitest serialization errors, когда Axios +возвращает rejection с non-cloneable configuration fields. Unit tests должны проверять логику +приложения на контролируемых fixtures. Live MOEX tests полезны, но должны быть отдельной явно +названной командой с понятным требованием к окружению. -### Consequences +### Последствия -- `npm run test:backend` becomes stable offline. -- Live MOEX coverage remains available through a dedicated integration command. -- Some existing specs will change from "real MOEX smoke test" to "service behavior with mocked +- `npm run test:backend` становится стабильной offline-командой. +- Live MOEX coverage остаётся доступным через отдельную integration command. +- Часть существующих specs изменится с "real MOEX smoke test" на "service behavior with mocked `MoexClientService`". -- Contract drift becomes visible because OpenAPI snapshots and frontend generated types are updated - as part of this work. +- Contract drift станет видимым, потому что OpenAPI snapshots и frontend generated types будут + обновлены в рамках этой работы. ## Backend Architecture -### Unit Test Boundary +### Граница unit tests -Service tests for `SharesService`, `BondsService`, `CandlesService`, and `SecuritiesService` should -mock `MoexClientService` and `CacheService`. +Service tests для `SharesService`, `BondsService`, `CandlesService` и `SecuritiesService` должны +mock'ать `MoexClientService` и `CacheService`. -The mocked data should exercise behavior that matters to MoexVibe: +Mocked data должны проверять поведение, важное для MoexVibe: -- normalized share data is returned from cached or fetched MOEX client data; -- normalized bond data handles missing market fields as nullable values; -- candles are mapped into the public response shape; -- search and screener behavior uses deterministic fixture rows; -- cache metadata remains represented through `{ data, meta }` where service contracts require it. +- нормализованные share data возвращаются из cached или fetched MOEX client data; +- нормализованные bond data корректно обрабатывают отсутствующие market fields как nullable values; +- candles мапятся в public response shape; +- search и screener используют детерминированные fixture rows; +- cache metadata остаётся представленной через `{ data, meta }`, где этого требуют service + contracts. -### Live Integration Test Boundary +### Граница live integration tests -Live MOEX checks should be isolated in `*.integration.spec.ts` files or an equivalent explicit test -path. They should only run through a dedicated command such as `npm run test:integration -w -apps/backend` and should document that network access is required. +Live MOEX checks должны быть изолированы в `*.integration.spec.ts` files или эквивалентном явном +test path. Они должны запускаться только через отдельную команду, например +`npm run test:integration -w apps/backend`, и должны документировать, что для них требуется network +access. -The integration command should not be part of the default `npm run test:backend` command. +Integration command не должна входить в стандартный `npm run test:backend`. -### OpenAPI Decorators +### OpenAPI decorators -Controllers that already exist should expose enough Swagger metadata for generated path types: +Существующие controllers должны отдавать достаточно Swagger metadata для generated path types: - Auth routes: register, login, refresh, logout, me, profile update. -- Securities routes: search and screener. +- Securities routes: search и screener. - Portfolio routes: list, create, detail, update, delete, position mutations, analytics. -- Existing shares, bonds, candles, and health routes. +- Существующие shares, bonds, candles и health routes. -The implementation should prefer existing DTOs and response DTOs. If a response lacks a DTO and -adding one would be large, the first pass may use minimal response decorators while keeping runtime -behavior unchanged. +В реализации нужно предпочитать существующие DTO и response DTO. Если для response нет DTO и +добавление полного DTO слишком раздувает первый проход, можно использовать минимальные response +decorators без изменения runtime behavior. ## Frontend Architecture ### Generated Types -`apps/frontend/src/api/types.ts` should be regenerated from the current backend Swagger JSON after -backend Swagger metadata includes the current routes. +`apps/frontend/src/api/types.ts` должен быть перегенерирован из текущего backend Swagger JSON после +того, как backend Swagger metadata будет покрывать актуальные routes. -The existing hand-written `responses.ts` and API wrapper modules remain in place for this epic. The -goal is contract freshness, not a full client rewrite. +Существующие hand-written `responses.ts` и API wrapper modules остаются на месте в этом эпике. Цель: +свежесть контракта, а не полный rewrite клиента. ### Tests -Frontend tests already exist and pass. This epic should not rewrite frontend testing architecture. -If API response types change, update frontend tests only where the regenerated contract reveals a -real mismatch. +Frontend tests уже существуют и проходят. Этот эпик не должен переписывать frontend testing +architecture. Если API response types изменятся, frontend tests нужно обновлять только там, где +перегенерированный contract выявит реальное несоответствие. ## OpenAPI Contract Scope -The synchronized contract should include at least these paths under `/api/v1`: +Синхронизированный contract должен включать минимум эти paths под `/api/v1`: - `GET /health` - `POST /auth/register` @@ -229,21 +233,22 @@ The synchronized contract should include at least these paths under `/api/v1`: ### README -Update README so quickstart and test sections mention backend, frontend, and docs workspaces. +Обновить README так, чтобы quickstart и test sections упоминали backend, frontend и docs +workspaces. ### AGENTS -Update AGENTS to reflect: +Обновить AGENTS: -- `apps/docs` is part of the workspace. -- `npm run lint` runs backend and frontend lint. -- frontend tests exist. -- pre-commit checks exist through Husky and lint-staged. -- CI exists under `.gitea/workflows/ci.yml`. +- `apps/docs` является частью workspace. +- `npm run lint` запускает backend и frontend lint. +- frontend tests существуют. +- pre-commit checks существуют через Husky и lint-staged. +- CI существует в `.gitea/workflows/ci.yml`. ### Docusaurus -Update current onboarding pages: +Обновить текущие onboarding pages: - development commands; - testing; @@ -254,36 +259,36 @@ Update current onboarding pages: - backend API; - backend portfolio. -Fix Docusaurus config or docs links so `npm run build:docs` no longer reports broken links to `/`. -The separate update-check warning about `/Users/ksv741/.config` permissions is external to the repo -and does not need to be fixed in this epic. +Исправить Docusaurus config или docs links так, чтобы `npm run build:docs` больше не сообщал о +broken links на `/`. Отдельное update-check warning про permissions в `/Users/ksv741/.config` +является внешним к репозиторию и в этот эпик не входит. -## Implementation Phases +## Этапы реализации -### Phase 1: Stabilize Default Checks +### Этап 1: стабилизировать стандартные проверки -1. Fix the unused backend test variable that breaks lint. -2. Convert default backend service specs away from live MOEX calls. -3. Move or add live MOEX smoke coverage under an opt-in integration command. -4. Verify `npm run lint` and `npm run test:backend`. +1. Исправить неиспользуемую backend test variable, которая ломает lint. +2. Перевести стандартные backend service specs с live MOEX calls на mocked dependencies. +3. Вынести или добавить live MOEX smoke coverage под opt-in integration command. +4. Проверить `npm run lint` и `npm run test:backend`. -### Phase 2: Refresh Contract Artifacts +### Этап 2: обновить contract artifacts -1. Add or complete Swagger metadata for current routes. -2. Regenerate `apps/frontend/src/api/types.ts`. -3. Synchronize `docs/openapi/openapi.yaml` with the current contract. -4. Verify generated paths include auth, screener, and portfolio routes. +1. Добавить или завершить Swagger metadata для актуальных routes. +2. Перегенерировать `apps/frontend/src/api/types.ts`. +3. Синхронизировать `docs/openapi/openapi.yaml` с текущим contract. +4. Проверить, что generated paths включают auth, screener и portfolio routes. -### Phase 3: Refresh Documentation +### Этап 3: обновить документацию -1. Update README and AGENTS. -2. Update Docusaurus development, frontend, backend, and portfolio pages. -3. Fix Docusaurus broken `/` link warning. -4. Verify `npm run build:docs`. +1. Обновить README и AGENTS. +2. Обновить Docusaurus development, frontend, backend и portfolio pages. +3. Исправить Docusaurus broken `/` link warning. +4. Проверить `npm run build:docs`. -### Phase 4: Full Verification +### Этап 4: полная проверка -Run: +Запустить: ```bash npm run lint @@ -297,30 +302,32 @@ npm run format:check ## Acceptance Criteria -- `npm run lint` exits 0. -- `npm run test:backend` exits 0 without live MOEX/network dependency. -- `npm run test:frontend` exits 0. -- `npm run build:backend` exits 0. -- `npm run build:frontend` exits 0. -- `npm run build:docs` exits 0 and no longer reports Docusaurus broken links for `/`. -- `npm run format:check` exits 0. -- `apps/frontend/src/api/types.ts` includes current auth, screener, and portfolio paths. -- `docs/openapi/openapi.yaml` includes current auth, screener, and portfolio paths. -- README, AGENTS, and Docusaurus docs no longer claim that frontend tests are absent. -- Portfolio docs use `PATCH /api/v1/portfolios/:id`, matching the controller. +- `npm run lint` завершается с exit code 0. +- `npm run test:backend` завершается с exit code 0 без live MOEX/network dependency. +- `npm run test:frontend` завершается с exit code 0. +- `npm run build:backend` завершается с exit code 0. +- `npm run build:frontend` завершается с exit code 0. +- `npm run build:docs` завершается с exit code 0 и больше не сообщает о Docusaurus broken links на + `/`. +- `npm run format:check` завершается с exit code 0. +- `apps/frontend/src/api/types.ts` содержит актуальные auth, screener и portfolio paths. +- `docs/openapi/openapi.yaml` содержит актуальные auth, screener и portfolio paths. +- README, AGENTS и Docusaurus docs больше не утверждают, что frontend tests отсутствуют. +- Portfolio docs используют `PATCH /api/v1/portfolios/:id`, что соответствует controller. -## Risks +## Риски -- Swagger decorators may expose DTO gaps that were previously hidden by hand-written frontend types. - Keep the first pass focused on path and schema freshness; defer API-client redesign. -- Live MOEX tests may still fail under network restrictions. That is acceptable only for the - opt-in integration command, not for the default backend test command. -- Regenerating OpenAPI artifacts can produce broad diffs. Review generated changes separately from - hand-written docs changes. +- Swagger decorators могут показать DTO gaps, которые раньше были скрыты hand-written frontend + types. Первый проход должен оставаться сфокусированным на свежести paths и schemas; redesign + API-клиента откладывается. +- Live MOEX tests могут по-прежнему падать в окружениях с network restrictions. Это допустимо только + для opt-in integration command, но не для default backend test command. +- Regenerating OpenAPI artifacts может дать большой diff. Generated changes нужно ревьюить отдельно + от hand-written docs changes. -## Spec Self-Review +## Self-Review спецификации -- Placeholder scan: no placeholder markers or incomplete sections. -- Internal consistency: default tests are offline; live MOEX checks are opt-in. -- Scope check: focused on quality gates, contract snapshots, and docs freshness. -- Ambiguity check: acceptance criteria name concrete commands and contract paths. +- Placeholder scan: placeholder markers и незавершённые sections отсутствуют. +- Internal consistency: default tests остаются offline, live MOEX checks являются opt-in. +- Scope check: scope ограничен quality gates, contract snapshots и актуальностью docs. +- Ambiguity check: acceptance criteria называют конкретные commands и contract paths. -- 2.47.2 From bc2d2af141793c8e0e85dae52c2f9e9ddfa8cbf7 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Sun, 14 Jun 2026 22:01:36 +0300 Subject: [PATCH 03/10] 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. -- 2.47.2 From c994b6a2fb57c80fae66427606230f66102e12b9 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Mon, 15 Jun 2026 04:47:13 +0300 Subject: [PATCH 04/10] test: split moex live integration checks --- apps/backend/package.json | 5 +- .../moex-client.service.integration.spec.ts | 35 ++++ .../moex-client/moex-client.service.spec.ts | 192 ++++++++++++++++-- 3 files changed, 208 insertions(+), 24 deletions(-) create mode 100644 apps/backend/src/modules/moex-client/moex-client.service.integration.spec.ts diff --git a/apps/backend/package.json b/apps/backend/package.json index 1d8a57e..c194228 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -8,8 +8,9 @@ "start:dev": "nest start --watch", "start:prod": "node dist/main", "lint": "eslint \"{src,test}/**/*.ts\"", - "test": "VITE_CJS_IGNORE_WARNING=1 vitest run", - "test:watch": "vitest" + "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\"" }, "dependencies": { "@libsql/client": "^0.17.3", diff --git a/apps/backend/src/modules/moex-client/moex-client.service.integration.spec.ts b/apps/backend/src/modules/moex-client/moex-client.service.integration.spec.ts new file mode 100644 index 0000000..ff2ae26 --- /dev/null +++ b/apps/backend/src/modules/moex-client/moex-client.service.integration.spec.ts @@ -0,0 +1,35 @@ +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); + }, +); diff --git a/apps/backend/src/modules/moex-client/moex-client.service.spec.ts b/apps/backend/src/modules/moex-client/moex-client.service.spec.ts index 5921972..3b13bb7 100644 --- a/apps/backend/src/modules/moex-client/moex-client.service.spec.ts +++ b/apps/backend/src/modules/moex-client/moex-client.service.spec.ts @@ -1,38 +1,186 @@ import 'reflect-metadata'; -import { Test, TestingModule } from '@nestjs/testing'; -import { ConfigModule } from '@nestjs/config'; +import axios from 'axios'; +import { ConfigService } from '@nestjs/config'; import { MoexClientService } from './moex-client.service'; -import configuration from '../../config/configuration'; + +vi.mock('axios', () => ({ + default: { + create: vi.fn(), + }, +})); describe('MoexClientService', () => { let service: MoexClientService; + let getMock: ReturnType; - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - imports: [ConfigModule.forRoot({ load: [configuration] })], - providers: [MoexClientService], - }).compile(); + beforeEach(() => { + getMock = vi.fn(); + vi.mocked(axios.create).mockReturnValue({ get: getMock } as never); - service = module.get(MoexClientService); + 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('should be defined', () => { + it('создаётся с настроенным MOEX client', () => { expect(service).toBeDefined(); + expect(axios.create).toHaveBeenCalledWith({ + baseURL: 'https://iss.moex.test/iss', + timeout: 10000, + paramsSerializer: { indexes: null }, + }); }); - describe('searchSecurities', () => { - it('should return results for SBER query', async () => { - const results = await service.searchSecurities('SBER'); - expect(results.length).toBeGreaterThan(0); - expect(results[0].secid).toBeDefined(); - }, 15000); + 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, + }, + ]); }); - describe('getShareMarketData', () => { - it('should return market data for SBER', async () => { - const data = await service.getShareMarketData('SBER'); - expect(data).toBeDefined(); - expect(data!.secid).toBe('SBER'); - }, 15000); + 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', + }); }); }); -- 2.47.2 From 974c83d67e130a8664aa37b98a89afee74b7e70d Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Mon, 15 Jun 2026 04:56:53 +0300 Subject: [PATCH 05/10] test: make backend service specs deterministic --- .../src/modules/bonds/bonds.service.spec.ts | 146 +++++++++++++-- .../modules/candles/candles.service.spec.ts | 109 +++++++++-- .../securities/screener.service.spec.ts | 2 - .../securities/securities.service.spec.ts | 172 ++++++++++++++++-- .../src/modules/shares/shares.service.spec.ts | 134 ++++++++++++-- 5 files changed, 483 insertions(+), 80 deletions(-) diff --git a/apps/backend/src/modules/bonds/bonds.service.spec.ts b/apps/backend/src/modules/bonds/bonds.service.spec.ts index 903de5c..532214e 100644 --- a/apps/backend/src/modules/bonds/bonds.service.spec.ts +++ b/apps/backend/src/modules/bonds/bonds.service.spec.ts @@ -1,41 +1,147 @@ +import { NotFoundException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { ConfigModule } from '@nestjs/config'; import { BondsService } from './bonds.service'; import { MoexClientService } from '../moex-client/moex-client.service'; import { CacheService } from '../cache/cache.service'; -import configuration from '../../config/configuration'; describe('BondsService', () => { let service: BondsService; + let moexClient: Pick; + let cache: Pick; beforeEach(async () => { + moexClient = { + getBondData: vi.fn(), + getBondMarketData: vi.fn(), + }; + cache = { + getOrFetch: vi.fn(async (_keyPrefix, _keyParts, fetchFn) => ({ + data: await fetchFn(), + fromCache: false, + cachedAt: '2026-06-15T00:00:00.000Z', + })), + }; + const module: TestingModule = await Test.createTestingModule({ - imports: [ConfigModule.forRoot({ load: [configuration] })], providers: [ BondsService, - MoexClientService, - { - provide: 'CACHE_MANAGER', - useValue: { - get: () => undefined, - set: () => Promise.resolve(), - del: () => Promise.resolve(), - }, - }, - CacheService, + { provide: MoexClientService, useValue: moexClient }, + { provide: CacheService, useValue: cache }, ], }).compile(); service = module.get(BondsService); }); - it('should be defined', () => { - expect(service).toBeDefined(); + it('returns normalized SU26238RMFS5 bond spec and market data without live MOEX dependency', async () => { + vi.mocked(moexClient.getBondData).mockResolvedValue({ + secid: 'SU26238RMFS5', + boardid: 'TQCB', + shortName: 'ОФЗ 26238', + prevWaprice: 73.2, + yieldAtPrevWaprice: 14.1, + couponValue: 35.4, + nextCoupon: '2026-06-24', + accruedInt: 34.1, + prevPrice: 73, + lotSize: 1, + faceValue: 1000, + matDate: '2041-05-15', + couponPeriod: 182, + issueSize: 150000000, + isin: 'RU000A1038V6', + couponPercent: 7.1, + offerDate: null, + buybackDate: null, + bondType: 'ofz', + bondSubType: 'fixed', + listLevel: 1, + }); + vi.mocked(moexClient.getBondMarketData).mockResolvedValue({ + secid: 'SU26238RMFS5', + bid: 72.9, + offer: 73.1, + open: 72.8, + low: 72.5, + high: 73.4, + last: 73.05, + yield: 14.2, + waprice: 73, + yieldAtWaprice: 14.15, + duration: 2250, + volume: 10000, + value: 7305000, + numtrades: 450, + tradingStatus: 'T', + updateTime: '18:45:00', + }); + + const result = await service.getBond('SU26238RMFS5'); + + expect(cache.getOrFetch).toHaveBeenNthCalledWith( + 1, + 'bond', + ['SU26238RMFS5'], + expect.any(Function), + 'securityTtl', + ); + expect(cache.getOrFetch).toHaveBeenNthCalledWith( + 2, + 'marketdata', + ['bonds', 'SU26238RMFS5'], + expect.any(Function), + 'marketDataTtl', + ); + expect(moexClient.getBondData).toHaveBeenCalledWith('SU26238RMFS5'); + expect(moexClient.getBondMarketData).toHaveBeenCalledWith('SU26238RMFS5'); + expect(result).toMatchObject({ + data: { + secid: 'SU26238RMFS5', + isin: 'RU000A1038V6', + name: 'ОФЗ 26238', + shortName: 'ОФЗ 26238', + latName: null, + listLevel: 1, + issueSize: 150000000, + faceValue: 1000, + faceUnit: 'RUB', + matDate: '2041-05-15', + couponValue: 35.4, + couponPercent: 7.1, + couponPeriod: 182, + nextCoupon: '2026-06-24', + accruedInt: 34.1, + bondType: 'ofz', + bondSubType: 'fixed', + offerDate: null, + buybackDate: null, + marketData: { + price: 73.05, + yieldToMaturity: 14.2, + duration: 2250, + accruedInt: 34.1, + couponValue: 35.4, + couponPercent: 7.1, + nextCouponDate: '2026-06-24', + open: 72.8, + high: 73.4, + low: 72.5, + volume: 10000, + }, + }, + meta: { + fromCache: false, + cachedAt: '2026-06-15T00:00:00.000Z', + }, + }); + expect(result.data.marketData.updatedAt).toMatch(/T18:45:00$/); }); - it('should return OFZ bond data for SU26207RMFS9', async () => { - const result = await service.getBond('SU26207RMFS9'); - expect(result.data.secid).toBe('SU26207RMFS9'); - expect(result.data.marketData).toBeDefined(); - }, 15000); + it('throws NotFoundException when bond data is missing', async () => { + vi.mocked(moexClient.getBondData).mockResolvedValue(null); + + await expect(service.getBond('UNKNOWN')).rejects.toBeInstanceOf(NotFoundException); + expect(cache.getOrFetch).toHaveBeenCalledTimes(1); + expect(moexClient.getBondMarketData).not.toHaveBeenCalled(); + }); }); diff --git a/apps/backend/src/modules/candles/candles.service.spec.ts b/apps/backend/src/modules/candles/candles.service.spec.ts index fd37b24..d97ce7e 100644 --- a/apps/backend/src/modules/candles/candles.service.spec.ts +++ b/apps/backend/src/modules/candles/candles.service.spec.ts @@ -1,40 +1,51 @@ import { Test, TestingModule } from '@nestjs/testing'; -import { ConfigModule } from '@nestjs/config'; import { CandlesService } from './candles.service'; import { MoexClientService } from '../moex-client/moex-client.service'; import { CacheService } from '../cache/cache.service'; -import configuration from '../../config/configuration'; import { CandleInterval } from './dto/candles-query.dto'; describe('CandlesService', () => { let service: CandlesService; + let moexClient: Pick; + let cache: Pick; beforeEach(async () => { + moexClient = { + getCandles: vi.fn(), + }; + cache = { + getOrFetch: vi.fn(async (_keyPrefix, _keyParts, fetchFn) => ({ + data: await fetchFn(), + fromCache: false, + cachedAt: '2026-06-15T00:00:00.000Z', + })), + }; + const module: TestingModule = await Test.createTestingModule({ - imports: [ConfigModule.forRoot({ load: [configuration] })], providers: [ CandlesService, - MoexClientService, - { - provide: 'CACHE_MANAGER', - useValue: { - get: () => undefined, - set: () => Promise.resolve(), - del: () => Promise.resolve(), - }, - }, - CacheService, + { provide: MoexClientService, useValue: moexClient }, + { provide: CacheService, useValue: cache }, ], }).compile(); service = module.get(CandlesService); }); - it('should be defined', () => { - expect(service).toBeDefined(); - }); + it('uses MOEX interval 24 for daily share candles and maps output envelope', async () => { + vi.mocked(moexClient.getCandles).mockResolvedValue([ + { + open: 320, + high: 325, + low: 318, + close: 323, + volume: 1500000, + value: 480000000, + begin: '2026-05-01 00:00:00', + end: '2026-05-01 23:59:59', + }, + ]); - it('should return daily candles for SBER', async () => { const result = await service.getCandles( 'shares', 'SBER', @@ -42,7 +53,65 @@ describe('CandlesService', () => { '2026-05-01', '2026-06-01', ); - expect(result.data.length).toBeGreaterThan(0); - expect(result.data[0].open).toBeDefined(); - }, 15000); + + expect(cache.getOrFetch).toHaveBeenCalledWith( + 'candles', + ['shares', 'SBER', '24', '2026-05-01', '2026-06-01'], + expect.any(Function), + 'candlesTtl', + ); + expect(moexClient.getCandles).toHaveBeenCalledWith( + 'stock', + 'shares', + 'SBER', + 24, + '2026-05-01', + '2026-06-01', + ); + expect(result).toEqual({ + data: [ + { + open: 320, + high: 325, + low: 318, + close: 323, + volume: 1500000, + value: 480000000, + begin: '2026-05-01 00:00:00', + end: '2026-05-01 23:59:59', + }, + ], + meta: { + fromCache: false, + cachedAt: '2026-06-15T00:00:00.000Z', + }, + }); + }); + + it('uses MOEX interval 60 for hourly bond candles without live MOEX dependency', async () => { + vi.mocked(moexClient.getCandles).mockResolvedValue([]); + + await service.getCandles( + 'bonds', + 'SU26238RMFS5', + CandleInterval.HOUR, + '2026-05-01', + '2026-06-01', + ); + + expect(cache.getOrFetch).toHaveBeenCalledWith( + 'candles', + ['bonds', 'SU26238RMFS5', '60', '2026-05-01', '2026-06-01'], + expect.any(Function), + 'candlesTtl', + ); + expect(moexClient.getCandles).toHaveBeenCalledWith( + 'stock', + 'bonds', + 'SU26238RMFS5', + 60, + '2026-05-01', + '2026-06-01', + ); + }); }); diff --git a/apps/backend/src/modules/securities/screener.service.spec.ts b/apps/backend/src/modules/securities/screener.service.spec.ts index 5f9f017..dc627d5 100644 --- a/apps/backend/src/modules/securities/screener.service.spec.ts +++ b/apps/backend/src/modules/securities/screener.service.spec.ts @@ -6,7 +6,6 @@ import { ScreenerType } from './dto/screener-query.dto'; describe('ScreenerService', () => { let service: ScreenerService; - let moexClient: MoexClientService; let cache: CacheService; beforeEach(async () => { @@ -30,7 +29,6 @@ describe('ScreenerService', () => { }).compile(); service = module.get(ScreenerService); - moexClient = module.get(MoexClientService); cache = module.get(CacheService); }); diff --git a/apps/backend/src/modules/securities/securities.service.spec.ts b/apps/backend/src/modules/securities/securities.service.spec.ts index d0370e3..be9434c 100644 --- a/apps/backend/src/modules/securities/securities.service.spec.ts +++ b/apps/backend/src/modules/securities/securities.service.spec.ts @@ -1,38 +1,174 @@ import { Test, TestingModule } from '@nestjs/testing'; -import { ConfigModule } from '@nestjs/config'; import { SecuritiesService } from './securities.service'; import { MoexClientService } from '../moex-client/moex-client.service'; import { CacheService } from '../cache/cache.service'; -import configuration from '../../config/configuration'; import { SecurityType } from './dto/search-query.dto'; describe('SecuritiesService', () => { let service: SecuritiesService; + let moexClient: Pick; + let cache: Pick; beforeEach(async () => { + moexClient = { + searchSecurities: vi.fn(), + }; + cache = { + getOrFetch: vi.fn(async (_keyPrefix, _keyParts, fetchFn) => ({ + data: await fetchFn(), + fromCache: false, + cachedAt: '2026-06-15T00:00:00.000Z', + })), + }; + const module: TestingModule = await Test.createTestingModule({ - imports: [ConfigModule.forRoot({ load: [configuration] })], providers: [ SecuritiesService, - MoexClientService, - { - provide: 'CACHE_MANAGER', - useValue: { - get: () => undefined, - set: () => Promise.resolve(), - del: () => Promise.resolve(), - }, - }, - CacheService, + { provide: MoexClientService, useValue: moexClient }, + { provide: CacheService, useValue: cache }, ], }).compile(); service = module.get(SecuritiesService); }); - it('should return search results for SBER', async () => { - const results = await service.search('SBER', SecurityType.ALL, 5); - expect(results.length).toBeGreaterThan(0); - expect(results[0].secid).toBeDefined(); - }, 15000); + it('returns supported securities only and normalizes SUR currency to RUB', async () => { + vi.mocked(moexClient.searchSecurities).mockResolvedValue([ + { + 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, + }, + { + secid: 'SU26238RMFS5', + isin: 'RU000A1038V6', + name: 'ОФЗ 26238', + shortName: 'ОФЗ 26238', + latName: null, + listLevel: 1, + issueSize: 100000000, + faceValue: 1000, + faceUnit: 'RUB', + issueDate: '2021-06-23', + typeName: 'Государственная облигация', + group: 'stock_bonds', + type: 'ofz_bond', + isQualifiedInvestors: false, + morningSession: false, + eveningSession: false, + }, + { + secid: 'SiM6', + isin: '', + name: 'USD/RUB Futures', + shortName: 'SiM6', + latName: null, + listLevel: 0, + issueSize: 0, + faceValue: 0, + faceUnit: '', + issueDate: '', + typeName: 'Фьючерс', + group: 'futures', + type: 'futures', + isQualifiedInvestors: false, + morningSession: false, + eveningSession: true, + }, + ]); + + 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: 'RU000A1038V6', + shortName: 'ОФЗ 26238', + type: 'bond', + listLevel: 1, + currency: 'RUB', + price: null, + }, + ]); + expect(cache.getOrFetch).toHaveBeenCalledWith( + 'search', + ['sber'], + expect.any(Function), + 'searchTtl', + ); + expect(moexClient.searchSecurities).toHaveBeenCalledWith('SbEr'); + }); + + it('filters by type and applies limit without live MOEX dependency', async () => { + vi.mocked(cache.getOrFetch).mockResolvedValue({ + data: [ + { + secid: 'SBER', + isin: 'RU0009029540', + shortName: 'Сбербанк', + type: 'share', + listLevel: 1, + currency: 'RUB', + price: null, + }, + { + secid: 'GAZP', + isin: 'RU0007661625', + shortName: 'Газпром', + type: 'share', + listLevel: 1, + currency: 'RUB', + price: null, + }, + { + secid: 'SU26238RMFS5', + isin: 'RU000A1038V6', + shortName: 'ОФЗ 26238', + type: 'bond', + listLevel: 1, + currency: 'RUB', + price: null, + }, + ], + fromCache: true, + cachedAt: null, + }); + + const results = await service.search('ru', SecurityType.SHARE, 1); + + expect(results).toEqual([ + { + secid: 'SBER', + isin: 'RU0009029540', + shortName: 'Сбербанк', + type: 'share', + listLevel: 1, + currency: 'RUB', + price: null, + }, + ]); + expect(moexClient.searchSecurities).not.toHaveBeenCalled(); + }); }); diff --git a/apps/backend/src/modules/shares/shares.service.spec.ts b/apps/backend/src/modules/shares/shares.service.spec.ts index 82ab025..5873412 100644 --- a/apps/backend/src/modules/shares/shares.service.spec.ts +++ b/apps/backend/src/modules/shares/shares.service.spec.ts @@ -1,41 +1,135 @@ +import { NotFoundException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { ConfigModule } from '@nestjs/config'; import { SharesService } from './shares.service'; import { MoexClientService } from '../moex-client/moex-client.service'; import { CacheService } from '../cache/cache.service'; -import configuration from '../../config/configuration'; describe('SharesService', () => { let service: SharesService; + let moexClient: Pick; + let cache: Pick; beforeEach(async () => { + moexClient = { + getSecurityDescription: vi.fn(), + getShareMarketData: vi.fn(), + }; + cache = { + getOrFetch: vi.fn(async (_keyPrefix, _keyParts, fetchFn) => ({ + data: await fetchFn(), + fromCache: false, + cachedAt: '2026-06-15T00:00:00.000Z', + })), + }; + const module: TestingModule = await Test.createTestingModule({ - imports: [ConfigModule.forRoot({ load: [configuration] })], providers: [ SharesService, - MoexClientService, - { - provide: 'CACHE_MANAGER', - useValue: { - get: () => undefined, - set: () => Promise.resolve(), - del: () => Promise.resolve(), - }, - }, - CacheService, + { provide: MoexClientService, useValue: moexClient }, + { provide: CacheService, useValue: cache }, ], }).compile(); service = module.get(SharesService); }); - it('should be defined', () => { - expect(service).toBeDefined(); + it('returns normalized SBER share spec and market data without live MOEX dependency', async () => { + vi.mocked(moexClient.getSecurityDescription).mockResolvedValue({ + 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, + }); + vi.mocked(moexClient.getShareMarketData).mockResolvedValue({ + secid: 'SBER', + boardid: 'TQBR', + shortName: 'Сбербанк', + bid: 320, + offer: 321, + open: 318, + low: 317, + high: 325, + last: 323, + lastChange: 4, + lastChangePrcnt: 1.25, + volume: 1500000, + value: 480000000, + waprice: 321, + numtrades: 4200, + issueCapitalization: 6900000000000, + tradingStatus: 'T', + updateTime: '18:45:00', + }); + + const result = await service.getShare('SBER'); + + expect(moexClient.getSecurityDescription).toHaveBeenCalledWith('SBER'); + expect(cache.getOrFetch).toHaveBeenCalledWith( + 'marketdata', + ['shares', 'SBER'], + expect.any(Function), + 'marketDataTtl', + ); + expect(moexClient.getShareMarketData).toHaveBeenCalledWith('SBER'); + expect(result).toMatchObject({ + secid: 'SBER', + isin: 'RU0009029540', + name: 'Сбербанк России ПАО ао', + shortName: 'Сбербанк', + latName: 'Sberbank', + listLevel: 1, + issueSize: 21586948000, + faceValue: 3, + faceUnit: 'RUB', + type: 'common_share', + marketData: { + price: 323, + change: 4, + changePercent: 1.25, + open: 318, + high: 325, + low: 317, + volume: 1500000, + value: 480000000, + issueCapitalization: 6900000000000, + }, + }); + expect(result.marketData.updatedAt).toMatch(/T18:45:00$/); }); - it('should return SBER share data', async () => { - const share = await service.getShare('SBER'); - expect(share.secid).toBe('SBER'); - expect(share.marketData).toBeDefined(); - }, 15000); + it('throws NotFoundException for non-share security', async () => { + vi.mocked(moexClient.getSecurityDescription).mockResolvedValue({ + secid: 'SU26238RMFS5', + isin: 'RU000A1038V6', + name: 'ОФЗ 26238', + shortName: 'ОФЗ 26238', + latName: null, + listLevel: 1, + issueSize: 100000000, + faceValue: 1000, + faceUnit: 'RUB', + issueDate: '2021-06-23', + typeName: 'Государственная облигация', + group: 'stock_bonds', + type: 'ofz_bond', + isQualifiedInvestors: false, + morningSession: false, + eveningSession: false, + }); + + await expect(service.getShare('SU26238RMFS5')).rejects.toBeInstanceOf(NotFoundException); + expect(cache.getOrFetch).not.toHaveBeenCalled(); + }); }); -- 2.47.2 From 44e13cc67eb9873027790fbadfadcf45dd023fcc Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Mon, 15 Jun 2026 05:05:13 +0300 Subject: [PATCH 06/10] test: cover checked-in openapi artifacts --- apps/backend/src/openapi-artifacts.spec.ts | 34 ++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 apps/backend/src/openapi-artifacts.spec.ts diff --git a/apps/backend/src/openapi-artifacts.spec.ts b/apps/backend/src/openapi-artifacts.spec.ts new file mode 100644 index 0000000..38caeec --- /dev/null +++ b/apps/backend/src/openapi-artifacts.spec.ts @@ -0,0 +1,34 @@ +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}:`); + } + }); +}); -- 2.47.2 From 143b4758c356f3a6ada9c2e8769c8effc1ca9ba1 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Mon, 15 Jun 2026 05:31:55 +0300 Subject: [PATCH 07/10] docs: refresh openapi contract artifacts --- apps/frontend/src/api/types.ts | 757 +++++++++++++++- docs/openapi/openapi.yaml | 1552 +++++++++++++++++++------------- 2 files changed, 1674 insertions(+), 635 deletions(-) diff --git a/apps/frontend/src/api/types.ts b/apps/frontend/src/api/types.ts index 0a51dc8..86eceb2 100644 --- a/apps/frontend/src/api/types.ts +++ b/apps/frontend/src/api/types.ts @@ -21,6 +21,92 @@ export interface paths { patch?: never; trace?: never; }; + '/api/v1/auth/register': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Register new user */ + post: operations['AuthController_register']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/v1/auth/login': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Login with email and password */ + post: operations['AuthController_login']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/v1/auth/refresh': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Refresh access token */ + post: operations['AuthController_refresh']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/v1/auth/logout': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Logout user */ + post: operations['AuthController_logout']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/v1/auth/me': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get current user profile */ + get: operations['AuthController_getProfile']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** Update current user profile */ + patch: operations['AuthController_updateProfile']; + trace?: never; + }; '/api/v1/securities/search': { parameters: { query?: never; @@ -38,6 +124,23 @@ export interface paths { patch?: never; trace?: never; }; + '/api/v1/securities/screener': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Фильтр ценных бумаг по параметрам */ + get: operations['SecuritiesController_screener']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; '/api/v1/securities/shares/{secid}': { parameters: { query?: never; @@ -191,10 +294,316 @@ export interface paths { patch?: never; trace?: never; }; + '/api/v1/portfolios': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get all portfolios for current user */ + get: operations['PortfolioController_findAll']; + put?: never; + /** Create a new portfolio */ + post: operations['PortfolioController_create']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/v1/portfolios/{id}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get portfolio details with positions and prices */ + get: operations['PortfolioController_findOne']; + put?: never; + post?: never; + /** Delete portfolio */ + delete: operations['PortfolioController_remove']; + options?: never; + head?: never; + /** Update portfolio */ + patch: operations['PortfolioController_update']; + trace?: never; + }; + '/api/v1/portfolios/{id}/positions': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Add position to portfolio */ + post: operations['PortfolioController_addPosition']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/v1/portfolios/{id}/positions/{positionId}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** Remove position from portfolio */ + delete: operations['PortfolioController_removePosition']; + options?: never; + head?: never; + /** Update position */ + patch: operations['PortfolioController_updatePosition']; + trace?: never; + }; + '/api/v1/portfolios/{id}/analytics': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get portfolio analytics with PnL */ + get: operations['PortfolioController_getAnalytics']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; } export type webhooks = Record; export interface components { - schemas: never; + schemas: { + RegisterDto: { + /** @example user@example.com */ + email: string; + /** @example securePass123 */ + password: string; + /** @example John */ + name?: string; + }; + LoginDto: { + /** @example user@example.com */ + email: string; + /** @example securePass123 */ + password: string; + }; + UpdateProfileDto: { + /** @example John Doe */ + name?: string; + }; + ScreenerItemDto: { + /** @example SBER */ + secid: string; + /** @example Сбербанк */ + shortName: string; + /** @example RU0009029540 */ + isin: string; + /** @enum {string} */ + type: 'share' | 'bond'; + /** @example 322.35 */ + price?: Record; + /** @example 1.15 */ + change?: Record; + /** @example 0.36 */ + changePercent?: Record; + /** @example 1925163 */ + volume: number; + /** @example 1 */ + listLevel: number; + /** @example 6958336818320 */ + capitalization?: Record; + /** @example 12.71 */ + yieldToMaturity?: Record; + /** @example 4.5 */ + duration?: Record; + /** @example 40.64 */ + couponValue?: Record; + /** @example 8.15 */ + couponPercent?: Record; + /** @example 29.48 */ + accruedInt?: Record; + /** @example 2027-02-03 */ + matDate?: Record; + /** @example ОФЗ-ПД */ + bondType?: Record; + }; + ScreenerResultDto: { + items: components['schemas']['ScreenerItemDto'][]; + total: number; + page: number; + pageSize: number; + totalPages: number; + }; + PortfolioListResponseDto: { + id: number; + name: string; + description?: Record; + /** @default RUB */ + currency: string; + createdAt: string; + updatedAt: string; + /** @description Total market value of all positions */ + totalValue: number; + /** @description Total number of positions */ + positionCount: number; + /** @description Number of share positions */ + shareCount: number; + /** @description Number of bond positions */ + bondCount: number; + }; + CreatePortfolioDto: { + /** @example Мой портфель */ + name: string; + /** @example Описание портфеля */ + description?: string; + /** + * @default RUB + * @enum {string} + */ + currency: 'RUB' | 'USD' | 'EUR' | 'CNY' | 'KZT' | 'BYN'; + }; + PositionWithPriceDto: { + id: number; + /** @example SBER */ + secid: string; + shortName?: Record; + /** + * @example share + * @enum {string} + */ + type: 'share' | 'bond'; + /** @example 10 */ + quantity: number; + buyPrice?: Record; + buyDate?: Record; + notes?: Record; + tags?: Record; + currentPrice?: Record; + totalCost?: Record; + currentValue?: Record; + weightPercent: number; + pnl?: Record; + pnlPercent?: Record; + dividendIncome?: Record; + totalReturn?: Record; + totalReturnPercent?: Record; + change?: Record; + changePercent?: Record; + yieldToMaturity?: Record; + duration?: Record; + couponValue?: Record; + couponPercent?: Record; + nextCouponDate?: Record; + matDate?: Record; + accruedInt?: Record; + bid?: Record; + offer?: Record; + couponPeriod?: Record; + bondType?: Record; + offerDate?: Record; + }; + PortfolioSummaryDto: { + totalInvested: number; + totalValue: number; + totalPnl: number; + totalPnlPercent: Record; + totalDividends: number; + totalReturn: number; + totalReturnPercent: Record; + positionCount: number; + weightedYield: Record; + }; + PortfolioDetailResponseDto: { + id: number; + name: string; + description?: Record; + /** @default RUB */ + currency: string; + createdAt: string; + updatedAt: string; + positions: components['schemas']['PositionWithPriceDto'][]; + totalValue: number; + analytics: components['schemas']['PortfolioSummaryDto']; + }; + UpdatePortfolioDto: { + /** @example Мой портфель */ + name?: string; + /** @example Обновлённое описание */ + description?: string; + /** + * @default RUB + * @enum {string} + */ + currency: 'RUB' | 'USD' | 'EUR' | 'CNY' | 'KZT' | 'BYN'; + }; + AddPositionDto: { + /** @example SBER */ + secid: string; + /** @example 10 */ + quantity: number; + /** @example 250.5 */ + buyPrice?: number; + /** @example 2026-06-01 */ + buyDate?: string; + /** @example Покупка на дип */ + notes?: string; + /** + * @example [ + * "DIVIDEND", + * "GROWTH" + * ] + * @enum {string} + */ + tags?: + | 'DIVIDEND' + | 'GROWTH' + | 'DEFENSIVE' + | 'SPECULATIVE' + | 'BOND' + | 'ETF' + | 'GOVERNMENT' + | 'CASH'; + }; + UpdatePositionDto: { + /** @example 15 */ + quantity?: number; + /** @example 260 */ + buyPrice?: number; + /** @example 2026-06-15 */ + buyDate?: string; + /** @example Докупка */ + notes?: string; + /** + * @example [ + * "DIVIDEND" + * ] + * @enum {string} + */ + tags?: + | 'DIVIDEND' + | 'GROWTH' + | 'DEFENSIVE' + | 'SPECULATIVE' + | 'BOND' + | 'ETF' + | 'GOVERNMENT' + | 'CASH'; + }; + }; responses: never; parameters: never; requestBodies: never; @@ -220,6 +629,120 @@ export interface operations { }; }; }; + AuthController_register: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['RegisterDto']; + }; + }; + responses: { + 201: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + AuthController_login: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['LoginDto']; + }; + }; + responses: { + 201: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + AuthController_refresh: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + AuthController_logout: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + AuthController_getProfile: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + AuthController_updateProfile: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['UpdateProfileDto']; + }; + }; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; SecuritiesController_search: { parameters: { query: { @@ -242,6 +765,49 @@ export interface operations { }; }; }; + SecuritiesController_screener: { + parameters: { + query: { + type: 'share' | 'bond'; + priceMin?: number; + priceMax?: number; + volumeMin?: number; + listLevel?: number; + changePercentMin?: number; + changePercentMax?: number; + capitalizationMin?: number; + yieldMin?: number; + yieldMax?: number; + durationMin?: number; + durationMax?: number; + couponMin?: number; + couponMax?: number; + couponPercentMin?: number; + couponPercentMax?: number; + maturityBefore?: string; + maturityAfter?: string; + bondType?: string; + sortBy?: string; + sortOrder?: string; + page?: number; + pageSize?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ScreenerResultDto']; + }; + }; + }; + }; SharesController_getShare: { parameters: { query?: never; @@ -427,4 +993,193 @@ export interface operations { }; }; }; + PortfolioController_findAll: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['PortfolioListResponseDto'][]; + }; + }; + }; + }; + PortfolioController_create: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['CreatePortfolioDto']; + }; + }; + responses: { + 201: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + PortfolioController_findOne: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['PortfolioDetailResponseDto']; + }; + }; + }; + }; + PortfolioController_remove: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + PortfolioController_update: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['UpdatePortfolioDto']; + }; + }; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + PortfolioController_addPosition: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['AddPositionDto']; + }; + }; + responses: { + 201: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + PortfolioController_removePosition: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + positionId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + PortfolioController_updatePosition: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + positionId: number; + }; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['UpdatePositionDto']; + }; + }; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + PortfolioController_getAnalytics: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; } diff --git a/docs/openapi/openapi.yaml b/docs/openapi/openapi.yaml index aa3c486..c81835a 100644 --- a/docs/openapi/openapi.yaml +++ b/docs/openapi/openapi.yaml @@ -1,776 +1,1060 @@ -openapi: "3.0.3" -info: - title: MoexVibe API - description: | - API для анализа ценных бумаг Московской биржи. - Backend является единственной точкой доступа к MOEX ISS. - version: "1.0.0" - contact: - name: MoexVibe Team - -servers: - - url: http://localhost:3000/api/v1 - description: Local development - - url: https://api.moexvibe.example.com/api/v1 - description: Production - +openapi: 3.0.0 paths: - /health: + /api/v1/health: get: - operationId: healthCheck - tags: [Health] + operationId: HealthController_check summary: Проверка состояния сервиса + parameters: [] responses: - "200": - description: Сервис работает - content: - application/json: - schema: - $ref: "#/components/schemas/HealthResponse" - - /securities/search: + '200': + description: '' + tags: + - Health + /api/v1/auth/register: + post: + operationId: AuthController_register + summary: Register new user + parameters: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/RegisterDto' + responses: + '201': + description: '' + tags: + - Auth + /api/v1/auth/login: + post: + operationId: AuthController_login + summary: Login with email and password + parameters: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/LoginDto' + responses: + '201': + description: '' + tags: + - Auth + /api/v1/auth/refresh: + post: + operationId: AuthController_refresh + summary: Refresh access token + parameters: [] + responses: + '200': + description: '' + tags: + - Auth + /api/v1/auth/logout: + post: + operationId: AuthController_logout + summary: Logout user + parameters: [] + responses: + '200': + description: '' + tags: + - Auth + security: + - bearer: [] + /api/v1/auth/me: get: - operationId: searchSecurities - tags: [Securities] + operationId: AuthController_getProfile + summary: Get current user profile + parameters: [] + responses: + '200': + description: '' + tags: + - Auth + security: + - bearer: [] + patch: + operationId: AuthController_updateProfile + summary: Update current user profile + parameters: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateProfileDto' + responses: + '200': + description: '' + tags: + - Auth + security: + - bearer: [] + /api/v1/securities/search: + get: + operationId: SecuritiesController_search summary: Поиск по инструментам parameters: - name: q - in: query required: true - schema: - type: string - minLength: 1 - maxLength: 100 + in: query description: Поисковый запрос (тикер, название, ISIN) - - name: type - in: query - required: false schema: type: string - enum: [all, share, bond] - default: all - description: Фильтр по типу инструмента - - name: limit - in: query + - name: type required: false + in: query + schema: + default: all + enum: + - all + - share + - bond + type: string + - name: limit + required: false + in: query schema: - type: integer - minimum: 1 - maximum: 100 default: 20 - description: Максимальное количество результатов + type: number responses: - "200": - description: Результаты поиска + '200': + description: '' + tags: + - Securities + /api/v1/securities/screener: + get: + operationId: SecuritiesController_screener + summary: Фильтр ценных бумаг по параметрам + parameters: + - name: type + required: true + in: query + schema: + enum: + - share + - bond + type: string + - name: priceMin + required: false + in: query + schema: + type: number + - name: priceMax + required: false + in: query + schema: + type: number + - name: volumeMin + required: false + in: query + schema: + type: number + - name: listLevel + required: false + in: query + schema: + type: number + - name: changePercentMin + required: false + in: query + schema: + type: number + - name: changePercentMax + required: false + in: query + schema: + type: number + - name: capitalizationMin + required: false + in: query + schema: + type: number + - name: yieldMin + required: false + in: query + schema: + type: number + - name: yieldMax + required: false + in: query + schema: + type: number + - name: durationMin + required: false + in: query + schema: + type: number + - name: durationMax + required: false + in: query + schema: + type: number + - name: couponMin + required: false + in: query + schema: + type: number + - name: couponMax + required: false + in: query + schema: + type: number + - name: couponPercentMin + required: false + in: query + schema: + type: number + - name: couponPercentMax + required: false + in: query + schema: + type: number + - name: maturityBefore + required: false + in: query + schema: + type: string + - name: maturityAfter + required: false + in: query + schema: + type: string + - name: bondType + required: false + in: query + schema: + type: string + - name: sortBy + required: false + in: query + schema: + default: price + type: string + - name: sortOrder + required: false + in: query + schema: + default: asc + type: string + - name: page + required: false + in: query + schema: + default: 1 + type: number + - name: pageSize + required: false + in: query + schema: + default: 20 + type: number + responses: + '200': + description: '' content: application/json: schema: - $ref: "#/components/schemas/SearchResponse" - "400": - $ref: "#/components/responses/BadRequest" - - /securities/shares/{secid}: + $ref: '#/components/schemas/ScreenerResultDto' + tags: + - Securities + /api/v1/securities/shares/{secid}: get: - operationId: getShare - tags: [Shares] + operationId: SharesController_getShare summary: Получить спецификацию акции parameters: - name: secid - in: path required: true + in: path schema: type: string - description: SECID инструмента (e.g. SBER) responses: - "200": - description: Спецификация акции - content: - application/json: - schema: - $ref: "#/components/schemas/StockResponse" - "404": - $ref: "#/components/responses/NotFound" - - /securities/shares/{secid}/marketdata: + '200': + description: '' + tags: + - Shares + /api/v1/securities/shares/{secid}/marketdata: get: - operationId: getShareMarketData - tags: [Shares] + operationId: SharesController_getMarketData summary: Получить рыночные данные акции parameters: - name: secid - in: path required: true + in: path schema: type: string responses: - "200": - description: Рыночные данные - content: - application/json: - schema: - $ref: "#/components/schemas/StockMarketDataResponse" - "404": - $ref: "#/components/responses/NotFound" - - /securities/shares/{secid}/candles: + '200': + description: '' + tags: + - Shares + /api/v1/securities/shares/{secid}/dividends: get: - operationId: getShareCandles - tags: [Shares] - summary: Получить свечи для графика цены акции + operationId: SharesController_getDividends + summary: Получить дивиденды parameters: - name: secid + required: true in: path - required: true schema: type: string - - name: interval - in: query - required: true - schema: - type: string - enum: ["1h", "24h"] - description: Таймфрейм свечей - - name: from - in: query - required: true - schema: - type: string - format: date - description: Начальная дата (ISO 8601) - - name: till - in: query - required: true - schema: - type: string - format: date - description: Конечная дата (ISO 8601) responses: - "200": - description: Массив свечей - content: - application/json: - schema: - $ref: "#/components/schemas/CandlesResponse" - "400": - $ref: "#/components/responses/BadRequest" - - /securities/shares/{secid}/history: + '200': + description: '' + tags: + - Shares + /api/v1/securities/shares/{secid}/history: get: - operationId: getShareHistory - tags: [Shares] + operationId: SharesController_getHistory summary: Получить дневную историю торгов акции parameters: - name: secid - in: path required: true + in: path schema: type: string - name: from - in: query required: true + in: query schema: type: string - format: date - name: till + required: true in: query - required: true - schema: - type: string - format: date - responses: - "200": - description: Дневная история - content: - application/json: - schema: - $ref: "#/components/schemas/HistoryResponse" - - /securities/shares/{secid}/dividends: - get: - operationId: getShareDividends - tags: [Shares] - summary: Получить историю дивидендных выплат - parameters: - - name: secid - in: path - required: true schema: type: string responses: - "200": - description: Дивиденды - content: - application/json: - schema: - $ref: "#/components/schemas/DividendsResponse" - "404": - $ref: "#/components/responses/NotFound" - - /securities/bonds/{secid}: + '200': + description: '' + tags: + - Shares + /api/v1/securities/bonds/{secid}: get: - operationId: getBond - tags: [Bonds] + operationId: BondsController_getBond summary: Получить спецификацию облигации parameters: - name: secid - in: path required: true + in: path schema: type: string responses: - "200": - description: Спецификация облигации - content: - application/json: - schema: - $ref: "#/components/schemas/BondResponse" - "404": - $ref: "#/components/responses/NotFound" - - /securities/bonds/{secid}/marketdata: + '200': + description: '' + tags: + - Bonds + /api/v1/securities/bonds/{secid}/marketdata: get: - operationId: getBondMarketData - tags: [Bonds] + operationId: BondsController_getMarketData summary: Получить рыночные данные облигации parameters: - name: secid - in: path required: true + in: path schema: type: string responses: - "200": - description: Рыночные данные облигации - content: - application/json: - schema: - $ref: "#/components/schemas/BondMarketDataResponse" - "404": - $ref: "#/components/responses/NotFound" - - /securities/bonds/{secid}/candles: + '200': + description: '' + tags: + - Bonds + /api/v1/securities/bonds/{secid}/history: get: - operationId: getBondCandles - tags: [Bonds] - summary: Получить свечи для графика цены облигации - parameters: - - name: secid - in: path - required: true - schema: - type: string - - name: interval - in: query - required: true - schema: - type: string - enum: ["1h", "24h"] - - name: from - in: query - required: true - schema: - type: string - format: date - - name: till - in: query - required: true - schema: - type: string - format: date - responses: - "200": - description: Массив свечей - content: - application/json: - schema: - $ref: "#/components/schemas/CandlesResponse" - - /securities/bonds/{secid}/history: - get: - operationId: getBondHistory - tags: [Bonds] + operationId: BondsController_getHistory summary: Получить дневную историю торгов облигации parameters: - name: secid - in: path required: true + in: path schema: type: string - name: from - in: query required: true + in: query schema: type: string - format: date - name: till - in: query required: true + in: query schema: type: string - format: date responses: - "200": - description: Дневная история + '200': + description: '' + tags: + - Bonds + /api/v1/securities/shares/{secid}/candles: + get: + operationId: CandlesController_getShareCandles + summary: Получить свечи акции + parameters: + - name: secid + required: true + in: path + schema: + type: string + - name: interval + required: true + in: query + schema: + enum: + - 1h + - 24h + type: string + - name: from + required: true + in: query + schema: + format: date + example: '2025-06-13' + type: string + - name: till + required: true + in: query + schema: + format: date + example: '2026-06-13' + type: string + responses: + '200': + description: '' + tags: + - Candles + /api/v1/securities/bonds/{secid}/candles: + get: + operationId: CandlesController_getBondCandles + summary: Получить свечи облигации + parameters: + - name: secid + required: true + in: path + schema: + type: string + - name: interval + required: true + in: query + schema: + enum: + - 1h + - 24h + type: string + - name: from + required: true + in: query + schema: + format: date + example: '2025-06-13' + type: string + - name: till + required: true + in: query + schema: + format: date + example: '2026-06-13' + type: string + responses: + '200': + description: '' + tags: + - Candles + /api/v1/portfolios: + get: + operationId: PortfolioController_findAll + summary: Get all portfolios for current user + parameters: [] + responses: + '200': + description: '' content: application/json: schema: - $ref: "#/components/schemas/BondHistoryResponse" - + type: array + items: + $ref: '#/components/schemas/PortfolioListResponseDto' + tags: + - Portfolios + security: + - bearer: [] + post: + operationId: PortfolioController_create + summary: Create a new portfolio + parameters: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreatePortfolioDto' + responses: + '201': + description: '' + tags: + - Portfolios + security: + - bearer: [] + /api/v1/portfolios/{id}: + get: + operationId: PortfolioController_findOne + summary: Get portfolio details with positions and prices + parameters: + - name: id + required: true + in: path + schema: + type: number + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/PortfolioDetailResponseDto' + tags: + - Portfolios + security: + - bearer: [] + patch: + operationId: PortfolioController_update + summary: Update portfolio + parameters: + - name: id + required: true + in: path + schema: + type: number + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdatePortfolioDto' + responses: + '200': + description: '' + tags: + - Portfolios + security: + - bearer: [] + delete: + operationId: PortfolioController_remove + summary: Delete portfolio + parameters: + - name: id + required: true + in: path + schema: + type: number + responses: + '200': + description: '' + tags: + - Portfolios + security: + - bearer: [] + /api/v1/portfolios/{id}/positions: + post: + operationId: PortfolioController_addPosition + summary: Add position to portfolio + parameters: + - name: id + required: true + in: path + schema: + type: number + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/AddPositionDto' + responses: + '201': + description: '' + tags: + - Portfolios + security: + - bearer: [] + /api/v1/portfolios/{id}/positions/{positionId}: + patch: + operationId: PortfolioController_updatePosition + summary: Update position + parameters: + - name: id + required: true + in: path + schema: + type: number + - name: positionId + required: true + in: path + schema: + type: number + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdatePositionDto' + responses: + '200': + description: '' + tags: + - Portfolios + security: + - bearer: [] + delete: + operationId: PortfolioController_removePosition + summary: Remove position from portfolio + parameters: + - name: id + required: true + in: path + schema: + type: number + - name: positionId + required: true + in: path + schema: + type: number + responses: + '200': + description: '' + tags: + - Portfolios + security: + - bearer: [] + /api/v1/portfolios/{id}/analytics: + get: + operationId: PortfolioController_getAnalytics + summary: Get portfolio analytics with PnL + parameters: + - name: id + required: true + in: path + schema: + type: number + responses: + '200': + description: '' + tags: + - Portfolios + security: + - bearer: [] +info: + title: MoexVibe API + description: '' + version: 1.0.0 + contact: {} +tags: [] +servers: [] components: + securitySchemes: + bearer: + scheme: bearer + bearerFormat: JWT + type: http schemas: - # ── Health ── - HealthResponse: + RegisterDto: type: object properties: - status: + email: type: string - example: "ok" - timestamp: + example: user@example.com + password: type: string - format: date-time - uptime: - type: number - required: [status, timestamp, uptime] - - # ── Api Response Wrapper ── - ApiResponse: + example: securePass123 + name: + type: string + example: John + required: + - email + - password + LoginDto: type: object properties: - data: {} - meta: - type: object - properties: - cachedAt: - type: string - format: date-time - nullable: true - fromCache: - type: boolean - required: [data] - - # ── Search ── - SearchResult: + email: + type: string + example: user@example.com + password: + type: string + example: securePass123 + required: + - email + - password + UpdateProfileDto: + type: object + properties: + name: + type: string + example: John Doe + ScreenerItemDto: type: object properties: secid: type: string - example: "SBER" - isin: - type: string - example: "RU0009029540" + example: SBER shortName: type: string - example: "Сбербанк" + example: Сбербанк + isin: + type: string + example: RU0009029540 type: type: string - enum: [share, bond] - listLevel: - type: integer - example: 1 - currency: - type: string - nullable: true - example: "RUB" + enum: + - share + - bond price: - type: number - nullable: true - example: 322.35 - required: [secid, isin, shortName, type, listLevel] - - SearchResponse: - type: object - properties: - data: - type: array - items: - $ref: "#/components/schemas/SearchResult" - meta: - $ref: "#/components/schemas/ApiResponse/properties/meta" - - # ── Stock ── - StockMarketData: - type: object - properties: - price: - type: number + type: object example: 322.35 change: - type: number + type: object example: 1.15 changePercent: - type: number + type: object example: 0.36 - open: - type: number - example: 321.30 - high: - type: number - example: 322.66 - low: - type: number - nullable: true - example: 321.20 volume: - type: integer + type: number example: 1925163 - value: - type: number - example: 620184479 - issueCapitalization: - type: number - example: 6958336818320 - updatedAt: - type: string - format: date-time - example: "2026-06-13T18:03:11Z" - required: [price, change, changePercent, open, volume, value, updatedAt] - - Stock: - type: object - properties: - secid: - type: string - example: "SBER" - isin: - type: string - example: "RU0009029540" - name: - type: string - example: "Сбербанк России ПАО ао" - shortName: - type: string - example: "Сбербанк" - latName: - type: string - nullable: true - example: "Sberbank" listLevel: - type: integer + type: number example: 1 - issueSize: - type: integer - example: 21586948000 - faceValue: - type: number - example: 3 - faceUnit: - type: string - example: "RUB" - type: - type: string - example: "common_share" - marketData: - $ref: "#/components/schemas/StockMarketData" - required: [secid, isin, name, shortName, listLevel, type, marketData] - - StockResponse: - type: object - properties: - data: - $ref: "#/components/schemas/Stock" - meta: - $ref: "#/components/schemas/ApiResponse/properties/meta" - - StockMarketDataResponse: - type: object - properties: - data: - $ref: "#/components/schemas/StockMarketData" - meta: - $ref: "#/components/schemas/ApiResponse/properties/meta" - - # ── Bond ── - BondMarketData: - type: object - properties: - price: - type: number - example: 100.45 - description: Цена в % от номинала + capitalization: + type: object + example: 6958336818320 yieldToMaturity: - type: number - nullable: true + type: object example: 12.71 - yieldAtWaprice: - type: number - nullable: true duration: - type: number - nullable: true - accruedInt: - type: number - example: 29.48 + type: object + example: 4.5 couponValue: - type: number + type: object example: 40.64 couponPercent: - type: number - nullable: true + type: object example: 8.15 - nextCouponDate: - type: string - format: date - nullable: true - example: "2026-08-05" - open: - type: number - high: - type: number - nullable: true - low: - type: number - nullable: true - volume: - type: integer - updatedAt: - type: string - format: date-time - required: [price, accruedInt, couponValue, volume, updatedAt] - - Bond: + accruedInt: + type: object + example: 29.48 + matDate: + type: object + example: '2027-02-03' + bondType: + type: object + example: ОФЗ-ПД + required: + - secid + - shortName + - isin + - type + - volume + - listLevel + ScreenerResultDto: type: object properties: - secid: - type: string - isin: - type: string + items: + type: array + items: + $ref: '#/components/schemas/ScreenerItemDto' + total: + type: number + page: + type: number + pageSize: + type: number + totalPages: + type: number + required: + - items + - total + - page + - pageSize + - totalPages + PortfolioListResponseDto: + type: object + properties: + id: + type: number name: type: string - shortName: - type: string - latName: - type: string - nullable: true - listLevel: - type: integer - issueSize: - type: integer - faceValue: - type: number - faceUnit: - type: string - matDate: - type: string - format: date - example: "2027-02-03" - couponValue: - type: number - example: 40.64 - couponPercent: - type: number - nullable: true - example: 8.15 - couponPeriod: - type: integer - example: 182 - nextCoupon: - type: string - format: date - example: "2026-08-05" - accruedInt: - type: number - example: 29.48 - bondType: - type: string - example: "Фикс с известным купоном" - bondSubType: - type: string - example: "До погашения" - offerDate: - type: string - format: date - nullable: true - buybackDate: - type: string - format: date - nullable: true - marketData: - $ref: "#/components/schemas/BondMarketData" - required: [secid, isin, name, shortName, listLevel, matDate, couponValue, - couponPeriod, accruedInt, bondType, marketData] - - BondResponse: - type: object - properties: - data: - $ref: "#/components/schemas/Bond" - meta: - $ref: "#/components/schemas/ApiResponse/properties/meta" - - BondMarketDataResponse: - type: object - properties: - data: - $ref: "#/components/schemas/BondMarketData" - meta: - $ref: "#/components/schemas/ApiResponse/properties/meta" - - # ── Candle ── - Candle: - type: object - properties: - open: - type: number - example: 280.00 - high: - type: number - example: 280.41 - low: - type: number - example: 271.80 - close: - type: number - example: 272.25 - volume: - type: integer - example: 43086870 - value: - type: number - example: 11853565984.9 - begin: - type: string - format: date-time - example: "2025-01-03T00:00:00Z" - end: - type: string - format: date-time - example: "2025-01-03T23:59:59Z" - required: [open, high, low, close, volume, value, begin, end] - - CandlesResponse: - type: object - properties: - data: - type: array - items: - $ref: "#/components/schemas/Candle" - meta: - $ref: "#/components/schemas/ApiResponse/properties/meta" - - # ── History ── - HistoryEntry: - type: object - properties: - date: - type: string - format: date - open: - type: number - high: - type: number - low: - type: number - close: - type: number - volume: - type: integer - value: - type: number - required: [date, open, high, low, close, volume, value] - - HistoryResponse: - type: object - properties: - data: - type: array - items: - $ref: "#/components/schemas/HistoryEntry" - meta: - $ref: "#/components/schemas/ApiResponse/properties/meta" - - BondHistoryEntry: - type: object - properties: - date: - type: string - format: date - closePrice: - type: number - yieldClose: - type: number - nullable: true - duration: - type: number - nullable: true - required: [date, closePrice] - - BondHistoryResponse: - type: object - properties: - data: - type: array - items: - $ref: "#/components/schemas/BondHistoryEntry" - meta: - $ref: "#/components/schemas/ApiResponse/properties/meta" - - # ── Dividend ── - Dividend: - type: object - properties: - registryCloseDate: - type: string - format: date - example: "2025-07-18" - value: - type: number - example: 34.84 + description: + type: object currency: type: string - example: "RUB" - required: [registryCloseDate, value, currency] - - DividendsResponse: + default: RUB + createdAt: + type: string + updatedAt: + type: string + totalValue: + type: number + description: Total market value of all positions + positionCount: + type: number + description: Total number of positions + shareCount: + type: number + description: Number of share positions + bondCount: + type: number + description: Number of bond positions + required: + - id + - name + - currency + - createdAt + - updatedAt + - totalValue + - positionCount + - shareCount + - bondCount + CreatePortfolioDto: type: object properties: - data: + name: + type: string + example: Мой портфель + description: + type: string + example: Описание портфеля + currency: + type: string + default: RUB + enum: + - RUB + - USD + - EUR + - CNY + - KZT + - BYN + required: + - name + PositionWithPriceDto: + type: object + properties: + id: + type: number + secid: + type: string + example: SBER + shortName: + type: object + type: + type: string + example: share + enum: + - share + - bond + quantity: + type: number + example: 10 + buyPrice: + type: object + buyDate: + type: object + notes: + type: object + tags: + type: object + currentPrice: + type: object + totalCost: + type: object + currentValue: + type: object + weightPercent: + type: number + pnl: + type: object + pnlPercent: + type: object + dividendIncome: + type: object + totalReturn: + type: object + totalReturnPercent: + type: object + change: + type: object + changePercent: + type: object + yieldToMaturity: + type: object + duration: + type: object + couponValue: + type: object + couponPercent: + type: object + nextCouponDate: + type: object + matDate: + type: object + accruedInt: + type: object + bid: + type: object + offer: + type: object + couponPeriod: + type: object + bondType: + type: object + offerDate: + type: object + required: + - id + - secid + - type + - quantity + - weightPercent + PortfolioSummaryDto: + type: object + properties: + totalInvested: + type: number + totalValue: + type: number + totalPnl: + type: number + totalPnlPercent: + type: object + totalDividends: + type: number + totalReturn: + type: number + totalReturnPercent: + type: object + positionCount: + type: number + weightedYield: + type: object + required: + - totalInvested + - totalValue + - totalPnl + - totalPnlPercent + - totalDividends + - totalReturn + - totalReturnPercent + - positionCount + - weightedYield + PortfolioDetailResponseDto: + type: object + properties: + id: + type: number + name: + type: string + description: + type: object + currency: + type: string + default: RUB + createdAt: + type: string + updatedAt: + type: string + positions: type: array items: - $ref: "#/components/schemas/Dividend" - meta: - $ref: "#/components/schemas/ApiResponse/properties/meta" - - # ── Error ── - ErrorResponse: + $ref: '#/components/schemas/PositionWithPriceDto' + totalValue: + type: number + analytics: + $ref: '#/components/schemas/PortfolioSummaryDto' + required: + - id + - name + - currency + - createdAt + - updatedAt + - positions + - totalValue + - analytics + UpdatePortfolioDto: type: object properties: - statusCode: - type: integer - example: 404 - message: + name: type: string - example: "Instrument SBER_NOT_FOUND not found" - error: + example: Мой портфель + description: type: string - example: "Not Found" - timestamp: + example: Обновлённое описание + currency: type: string - format: date-time - path: + default: RUB + enum: + - RUB + - USD + - EUR + - CNY + - KZT + - BYN + AddPositionDto: + type: object + properties: + secid: type: string - example: "/api/v1/securities/shares/SBER_NOT_FOUND" - required: [statusCode, message, error, timestamp, path] - - responses: - BadRequest: - description: Неверный запрос - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorResponse" - NotFound: - description: Инструмент не найден - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorResponse" - -tags: - - name: Health - description: Мониторинг состояния сервиса - - name: Securities - description: Поиск инструментов - - name: Shares - description: Акции - - name: Bonds - description: Облигации + example: SBER + quantity: + type: number + example: 10 + buyPrice: + type: number + example: 250.5 + buyDate: + type: string + example: '2026-06-01' + notes: + type: string + example: Покупка на дип + tags: + type: string + example: + - DIVIDEND + - GROWTH + enum: + - DIVIDEND + - GROWTH + - DEFENSIVE + - SPECULATIVE + - BOND + - ETF + - GOVERNMENT + - CASH + required: + - secid + - quantity + UpdatePositionDto: + type: object + properties: + quantity: + type: number + example: 15 + buyPrice: + type: number + example: 260 + buyDate: + type: string + example: '2026-06-15' + notes: + type: string + example: Докупка + tags: + type: string + example: + - DIVIDEND + enum: + - DIVIDEND + - GROWTH + - DEFENSIVE + - SPECULATIVE + - BOND + - ETF + - GOVERNMENT + - CASH -- 2.47.2 From 835686d8869a8da5a8ae0447a55c70e1cb3bf1cc Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Mon, 15 Jun 2026 05:58:45 +0300 Subject: [PATCH 08/10] docs: improve openapi schema metadata --- .../src/modules/auth/auth.controller.ts | 19 +- .../src/modules/auth/dto/auth-response.dto.ts | 60 +++ .../modules/portfolio/dto/add-position.dto.ts | 2 +- .../portfolio/dto/analytics-response.dto.ts | 10 +- .../portfolio/dto/portfolio-envelope.dto.ts | 53 ++ .../portfolio/dto/portfolio-response.dto.ts | 38 +- .../portfolio/dto/position-response.dto.ts | 4 +- .../portfolio/dto/position-with-price.dto.ts | 99 ++++ .../portfolio/dto/update-position.dto.ts | 2 +- .../modules/portfolio/portfolio.controller.ts | 45 +- .../securities/dto/screener-response.dto.ts | 38 +- .../securities/securities.controller.ts | 4 +- apps/backend/src/openapi-artifacts.spec.ts | 62 +++ apps/frontend/src/api/types.ts | 248 ++++++--- docs/openapi/openapi.yaml | 477 +++++++++++++++--- 15 files changed, 964 insertions(+), 197 deletions(-) create mode 100644 apps/backend/src/modules/auth/dto/auth-response.dto.ts create mode 100644 apps/backend/src/modules/portfolio/dto/portfolio-envelope.dto.ts create mode 100644 apps/backend/src/modules/portfolio/dto/position-with-price.dto.ts diff --git a/apps/backend/src/modules/auth/auth.controller.ts b/apps/backend/src/modules/auth/auth.controller.ts index 68c58bf..d933b34 100644 --- a/apps/backend/src/modules/auth/auth.controller.ts +++ b/apps/backend/src/modules/auth/auth.controller.ts @@ -1,10 +1,21 @@ import { Controller, Post, Get, Patch, Body, Req, Res, HttpCode, HttpStatus } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; +import { + ApiTags, + ApiOperation, + ApiBearerAuth, + ApiCreatedResponse, + ApiOkResponse, +} from '@nestjs/swagger'; import { Request, Response } from 'express'; import { AuthService } from './auth.service'; import { RegisterDto } from './dto/register.dto'; import { LoginDto } from './dto/login.dto'; import { UpdateProfileDto } from './dto/update-profile.dto'; +import { + AuthLogoutResponseDto, + AuthProfileResponseDto, + AuthTokenResponseDto, +} from './dto/auth-response.dto'; import { CurrentUser } from './decorators/current-user.decorator'; import { Public } from './decorators/public.decorator'; import { JwtPayload } from './interfaces/jwt-payload.interface'; @@ -26,6 +37,7 @@ export class AuthController { @Public() @Post('register') @ApiOperation({ summary: 'Register new user' }) + @ApiCreatedResponse({ type: AuthTokenResponseDto }) async register(@Body() dto: RegisterDto, @Res({ passthrough: true }) res: Response) { const result = await this.authService.register(dto); res.cookie(REFRESH_COOKIE, result.refreshToken, COOKIE_OPTIONS); @@ -41,6 +53,7 @@ export class AuthController { @Public() @Post('login') @ApiOperation({ summary: 'Login with email and password' }) + @ApiCreatedResponse({ type: AuthTokenResponseDto }) async login(@Body() dto: LoginDto, @Res({ passthrough: true }) res: Response) { const result = await this.authService.login(dto); res.cookie(REFRESH_COOKIE, result.refreshToken, COOKIE_OPTIONS); @@ -57,6 +70,7 @@ export class AuthController { @Post('refresh') @HttpCode(HttpStatus.OK) @ApiOperation({ summary: 'Refresh access token' }) + @ApiOkResponse({ type: AuthTokenResponseDto }) async refresh(@Req() req: Request, @Res({ passthrough: true }) res: Response) { const token = req.cookies?.[REFRESH_COOKIE]; const result = await this.authService.refresh(token); @@ -74,6 +88,7 @@ export class AuthController { @HttpCode(HttpStatus.OK) @ApiBearerAuth() @ApiOperation({ summary: 'Logout user' }) + @ApiOkResponse({ type: AuthLogoutResponseDto }) async logout(@CurrentUser() user: JwtPayload, @Res({ passthrough: true }) res: Response) { await this.authService.logout(user.sub); res.clearCookie(REFRESH_COOKIE, { path: '/api/v1/auth' }); @@ -86,6 +101,7 @@ export class AuthController { @Get('me') @ApiBearerAuth() @ApiOperation({ summary: 'Get current user profile' }) + @ApiOkResponse({ type: AuthProfileResponseDto }) async getProfile(@CurrentUser() user: JwtPayload) { const profile = await this.authService.getProfile(user.sub); return { @@ -97,6 +113,7 @@ export class AuthController { @Patch('me') @ApiBearerAuth() @ApiOperation({ summary: 'Update current user profile' }) + @ApiOkResponse({ type: AuthProfileResponseDto }) async updateProfile(@CurrentUser() user: JwtPayload, @Body() dto: UpdateProfileDto) { const profile = await this.authService.updateProfile(user.sub, dto); return { diff --git a/apps/backend/src/modules/auth/dto/auth-response.dto.ts b/apps/backend/src/modules/auth/dto/auth-response.dto.ts new file mode 100644 index 0000000..da8885d --- /dev/null +++ b/apps/backend/src/modules/auth/dto/auth-response.dto.ts @@ -0,0 +1,60 @@ +import { ApiProperty } from '@nestjs/swagger'; + +class AuthResponseMetaDto { + @ApiProperty({ type: String, nullable: true }) + cachedAt!: string | null; + + @ApiProperty() + fromCache!: boolean; +} + +class AuthUserDto { + @ApiProperty() + id!: number; + + @ApiProperty() + email!: string; + + @ApiProperty({ type: String, nullable: true }) + name!: string | null; + + @ApiProperty() + role!: string; +} + +class AuthTokenDataDto { + @ApiProperty({ type: AuthUserDto }) + user!: AuthUserDto; + + @ApiProperty() + accessToken!: string; +} + +class LogoutDataDto { + @ApiProperty() + message!: string; +} + +export class AuthTokenResponseDto { + @ApiProperty({ type: AuthTokenDataDto }) + data!: AuthTokenDataDto; + + @ApiProperty({ type: AuthResponseMetaDto }) + meta!: AuthResponseMetaDto; +} + +export class AuthProfileResponseDto { + @ApiProperty({ type: AuthUserDto }) + data!: AuthUserDto; + + @ApiProperty({ type: AuthResponseMetaDto }) + meta!: AuthResponseMetaDto; +} + +export class AuthLogoutResponseDto { + @ApiProperty({ type: LogoutDataDto }) + data!: LogoutDataDto; + + @ApiProperty({ type: AuthResponseMetaDto }) + meta!: AuthResponseMetaDto; +} diff --git a/apps/backend/src/modules/portfolio/dto/add-position.dto.ts b/apps/backend/src/modules/portfolio/dto/add-position.dto.ts index 5f8649c..7ed69b3 100644 --- a/apps/backend/src/modules/portfolio/dto/add-position.dto.ts +++ b/apps/backend/src/modules/portfolio/dto/add-position.dto.ts @@ -51,7 +51,7 @@ export class AddPositionDto { @MaxLength(500) notes?: string; - @ApiPropertyOptional({ example: ['DIVIDEND', 'GROWTH'], enum: TAGS }) + @ApiPropertyOptional({ example: ['DIVIDEND', 'GROWTH'], enum: TAGS, isArray: true }) @IsArray() @IsIn(TAGS, { each: true }) @IsOptional() diff --git a/apps/backend/src/modules/portfolio/dto/analytics-response.dto.ts b/apps/backend/src/modules/portfolio/dto/analytics-response.dto.ts index 079eba0..059b678 100644 --- a/apps/backend/src/modules/portfolio/dto/analytics-response.dto.ts +++ b/apps/backend/src/modules/portfolio/dto/analytics-response.dto.ts @@ -1,19 +1,19 @@ import { ApiProperty } from '@nestjs/swagger'; -import { EnrichedPosition } from '../portfolio.service'; +import { PositionWithPriceDto } from './position-with-price.dto'; export class PortfolioSummaryDto { @ApiProperty() totalInvested!: number; @ApiProperty() totalValue!: number; @ApiProperty() totalPnl!: number; - @ApiProperty() totalPnlPercent!: number | null; + @ApiProperty({ type: Number, nullable: true }) totalPnlPercent!: number | null; @ApiProperty() totalDividends!: number; @ApiProperty() totalReturn!: number; - @ApiProperty() totalReturnPercent!: number | null; + @ApiProperty({ type: Number, nullable: true }) totalReturnPercent!: number | null; @ApiProperty() positionCount!: number; - @ApiProperty() weightedYield!: number | null; + @ApiProperty({ type: Number, nullable: true }) weightedYield!: number | null; } export class AnalyticsResponseDto { - @ApiProperty({ type: [Object] }) positions!: EnrichedPosition[]; + @ApiProperty({ type: [PositionWithPriceDto] }) positions!: PositionWithPriceDto[]; @ApiProperty() summary!: PortfolioSummaryDto; } diff --git a/apps/backend/src/modules/portfolio/dto/portfolio-envelope.dto.ts b/apps/backend/src/modules/portfolio/dto/portfolio-envelope.dto.ts new file mode 100644 index 0000000..37ae555 --- /dev/null +++ b/apps/backend/src/modules/portfolio/dto/portfolio-envelope.dto.ts @@ -0,0 +1,53 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { AnalyticsResponseDto } from './analytics-response.dto'; +import { PortfolioListResponseDto } from './portfolio-list-response.dto'; +import { PortfolioDetailResponseDto, PortfolioResponseDto } from './portfolio-response.dto'; +import { PositionResponseDto } from './position-response.dto'; + +export class PortfolioResponseMetaDto { + @ApiProperty({ type: String, nullable: true }) + cachedAt!: string | null; + + @ApiProperty() + fromCache!: boolean; +} + +export class PortfolioListEnvelopeDto { + @ApiProperty({ type: [PortfolioListResponseDto] }) + data!: PortfolioListResponseDto[]; + + @ApiProperty({ type: PortfolioResponseMetaDto }) + meta!: PortfolioResponseMetaDto; +} + +export class PortfolioEnvelopeDto { + @ApiProperty({ type: PortfolioResponseDto }) + data!: PortfolioResponseDto; + + @ApiProperty({ type: PortfolioResponseMetaDto }) + meta!: PortfolioResponseMetaDto; +} + +export class PortfolioDetailEnvelopeDto { + @ApiProperty({ type: PortfolioDetailResponseDto }) + data!: PortfolioDetailResponseDto; + + @ApiProperty({ type: PortfolioResponseMetaDto }) + meta!: PortfolioResponseMetaDto; +} + +export class PositionEnvelopeDto { + @ApiProperty({ type: PositionResponseDto }) + data!: PositionResponseDto; + + @ApiProperty({ type: PortfolioResponseMetaDto }) + meta!: PortfolioResponseMetaDto; +} + +export class AnalyticsEnvelopeDto { + @ApiProperty({ type: AnalyticsResponseDto }) + data!: AnalyticsResponseDto; + + @ApiProperty({ type: PortfolioResponseMetaDto }) + meta!: PortfolioResponseMetaDto; +} diff --git a/apps/backend/src/modules/portfolio/dto/portfolio-response.dto.ts b/apps/backend/src/modules/portfolio/dto/portfolio-response.dto.ts index 3d25f11..1b3d02d 100644 --- a/apps/backend/src/modules/portfolio/dto/portfolio-response.dto.ts +++ b/apps/backend/src/modules/portfolio/dto/portfolio-response.dto.ts @@ -1,45 +1,11 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { PortfolioSummaryDto } from './analytics-response.dto'; - -class PositionWithPriceDto { - @ApiProperty() id!: number; - @ApiProperty({ example: 'SBER' }) secid!: string; - @ApiPropertyOptional() shortName!: string | null; - @ApiProperty({ example: 'share', enum: ['share', 'bond'] }) type!: string; - @ApiProperty({ example: 10 }) quantity!: number; - @ApiPropertyOptional() buyPrice!: number | null; - @ApiPropertyOptional() buyDate!: string | null; - @ApiPropertyOptional() notes!: string | null; - @ApiPropertyOptional() tags!: string[] | null; - @ApiPropertyOptional() currentPrice!: number | null; - @ApiPropertyOptional() totalCost!: number | null; - @ApiPropertyOptional() currentValue!: number | null; - @ApiProperty() weightPercent!: number; - @ApiPropertyOptional() pnl!: number | null; - @ApiPropertyOptional() pnlPercent!: number | null; - @ApiPropertyOptional() dividendIncome!: number | null; - @ApiPropertyOptional() totalReturn!: number | null; - @ApiPropertyOptional() totalReturnPercent!: number | null; - @ApiPropertyOptional() change!: number | null; - @ApiPropertyOptional() changePercent!: number | null; - @ApiPropertyOptional() yieldToMaturity!: number | null; - @ApiPropertyOptional() duration!: number | null; - @ApiPropertyOptional() couponValue!: number | null; - @ApiPropertyOptional() couponPercent!: number | null; - @ApiPropertyOptional() nextCouponDate!: string | null; - @ApiPropertyOptional() matDate!: string | null; - @ApiPropertyOptional() accruedInt!: number | null; - @ApiPropertyOptional() bid!: number | null; - @ApiPropertyOptional() offer!: number | null; - @ApiPropertyOptional() couponPeriod!: number | null; - @ApiPropertyOptional() bondType!: string | null; - @ApiPropertyOptional() offerDate!: string | null; -} +import { PositionWithPriceDto } from './position-with-price.dto'; export class PortfolioResponseDto { @ApiProperty() id!: number; @ApiProperty() name!: string; - @ApiPropertyOptional() description!: string | null; + @ApiPropertyOptional({ type: String, nullable: true }) description!: string | null; @ApiProperty({ default: 'RUB' }) currency!: string; @ApiProperty() createdAt!: string; @ApiProperty() updatedAt!: string; diff --git a/apps/backend/src/modules/portfolio/dto/position-response.dto.ts b/apps/backend/src/modules/portfolio/dto/position-response.dto.ts index 7064ea1..a76bd9a 100644 --- a/apps/backend/src/modules/portfolio/dto/position-response.dto.ts +++ b/apps/backend/src/modules/portfolio/dto/position-response.dto.ts @@ -4,8 +4,8 @@ export class PositionResponseDto { @ApiProperty() id!: number; @ApiProperty({ example: 'SBER' }) secid!: string; @ApiProperty({ example: 10 }) quantity!: number; - @ApiPropertyOptional() notes!: string | null; - @ApiPropertyOptional() tags!: string[] | null; + @ApiPropertyOptional({ type: String, nullable: true }) notes!: string | null; + @ApiPropertyOptional({ type: String, isArray: true, nullable: true }) tags!: string[] | null; @ApiProperty() portfolioId!: number; @ApiProperty() createdAt!: string; @ApiProperty() updatedAt!: string; diff --git a/apps/backend/src/modules/portfolio/dto/position-with-price.dto.ts b/apps/backend/src/modules/portfolio/dto/position-with-price.dto.ts new file mode 100644 index 0000000..edff465 --- /dev/null +++ b/apps/backend/src/modules/portfolio/dto/position-with-price.dto.ts @@ -0,0 +1,99 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class PositionWithPriceDto { + @ApiProperty() + id!: number; + + @ApiProperty({ example: 'SBER' }) + secid!: string; + + @ApiPropertyOptional({ type: String, nullable: true }) + shortName!: string | null; + + @ApiProperty({ example: 'share', enum: ['share', 'bond'] }) + type!: string; + + @ApiProperty({ example: 10 }) + quantity!: number; + + @ApiPropertyOptional({ type: Number, nullable: true }) + buyPrice!: number | null; + + @ApiPropertyOptional({ type: String, nullable: true }) + buyDate!: string | null; + + @ApiPropertyOptional({ type: String, nullable: true }) + notes!: string | null; + + @ApiPropertyOptional({ type: String, isArray: true, nullable: true }) + tags!: string[] | null; + + @ApiPropertyOptional({ type: Number, nullable: true }) + currentPrice!: number | null; + + @ApiPropertyOptional({ type: Number, nullable: true }) + totalCost!: number | null; + + @ApiPropertyOptional({ type: Number, nullable: true }) + currentValue!: number | null; + + @ApiProperty() + weightPercent!: number; + + @ApiPropertyOptional({ type: Number, nullable: true }) + pnl!: number | null; + + @ApiPropertyOptional({ type: Number, nullable: true }) + pnlPercent!: number | null; + + @ApiPropertyOptional({ type: Number, nullable: true }) + dividendIncome!: number | null; + + @ApiPropertyOptional({ type: Number, nullable: true }) + totalReturn!: number | null; + + @ApiPropertyOptional({ type: Number, nullable: true }) + totalReturnPercent!: number | null; + + @ApiPropertyOptional({ type: Number, nullable: true }) + change?: number | null; + + @ApiPropertyOptional({ type: Number, nullable: true }) + changePercent?: number | null; + + @ApiPropertyOptional({ type: Number, nullable: true }) + yieldToMaturity?: number | null; + + @ApiPropertyOptional({ type: Number, nullable: true }) + duration?: number | null; + + @ApiPropertyOptional({ type: Number, nullable: true }) + couponValue?: number | null; + + @ApiPropertyOptional({ type: Number, nullable: true }) + couponPercent?: number | null; + + @ApiPropertyOptional({ type: String, nullable: true }) + nextCouponDate?: string | null; + + @ApiPropertyOptional({ type: String, nullable: true }) + matDate?: string | null; + + @ApiPropertyOptional({ type: Number, nullable: true }) + accruedInt?: number | null; + + @ApiPropertyOptional({ type: Number, nullable: true }) + bid?: number | null; + + @ApiPropertyOptional({ type: Number, nullable: true }) + offer?: number | null; + + @ApiPropertyOptional({ type: Number, nullable: true }) + couponPeriod?: number | null; + + @ApiPropertyOptional({ type: String, nullable: true }) + bondType?: string | null; + + @ApiPropertyOptional({ type: String, nullable: true }) + offerDate?: string | null; +} diff --git a/apps/backend/src/modules/portfolio/dto/update-position.dto.ts b/apps/backend/src/modules/portfolio/dto/update-position.dto.ts index e168843..2aa0cd7 100644 --- a/apps/backend/src/modules/portfolio/dto/update-position.dto.ts +++ b/apps/backend/src/modules/portfolio/dto/update-position.dto.ts @@ -45,7 +45,7 @@ export class UpdatePositionDto { @MaxLength(500) notes?: string; - @ApiPropertyOptional({ example: ['DIVIDEND'], enum: TAGS }) + @ApiPropertyOptional({ example: ['DIVIDEND'], enum: TAGS, isArray: true }) @IsArray() @IsIn(TAGS, { each: true }) @IsOptional() diff --git a/apps/backend/src/modules/portfolio/portfolio.controller.ts b/apps/backend/src/modules/portfolio/portfolio.controller.ts index 79dd141..777a1d5 100644 --- a/apps/backend/src/modules/portfolio/portfolio.controller.ts +++ b/apps/backend/src/modules/portfolio/portfolio.controller.ts @@ -1,23 +1,51 @@ import { Controller, Get, Post, Patch, Delete, Body, Param, ParseIntPipe } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiBearerAuth, ApiOkResponse } from '@nestjs/swagger'; -import { PortfolioListResponseDto } from './dto/portfolio-list-response.dto'; -import { PortfolioDetailResponseDto } from './dto/portfolio-response.dto'; +import { + ApiTags, + ApiOperation, + ApiBearerAuth, + ApiOkResponse, + ApiCreatedResponse, + ApiExtraModels, + getSchemaPath, +} from '@nestjs/swagger'; import { PortfolioService } from './portfolio.service'; import { CreatePortfolioDto } from './dto/create-portfolio.dto'; import { UpdatePortfolioDto } from './dto/update-portfolio.dto'; import { AddPositionDto } from './dto/add-position.dto'; import { UpdatePositionDto } from './dto/update-position.dto'; import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { + AnalyticsEnvelopeDto, + PortfolioDetailEnvelopeDto, + PortfolioEnvelopeDto, + PortfolioListEnvelopeDto, + PortfolioResponseMetaDto, + PositionEnvelopeDto, +} from './dto/portfolio-envelope.dto'; + +const nullDataEnvelopeSchema = { + type: 'object', + properties: { + data: { + nullable: true, + }, + meta: { + $ref: getSchemaPath(PortfolioResponseMetaDto), + }, + }, + required: ['data', 'meta'], +}; @ApiTags('Portfolios') @ApiBearerAuth() +@ApiExtraModels(PortfolioResponseMetaDto) @Controller('portfolios') export class PortfolioController { constructor(private readonly portfolioService: PortfolioService) {} @Get() @ApiOperation({ summary: 'Get all portfolios for current user' }) - @ApiOkResponse({ type: PortfolioListResponseDto, isArray: true }) + @ApiOkResponse({ type: PortfolioListEnvelopeDto }) async findAll(@CurrentUser() user: { sub: number }) { const portfolios = await this.portfolioService.findAll(user.sub); return { data: portfolios, meta: { cachedAt: null, fromCache: false } }; @@ -25,6 +53,7 @@ export class PortfolioController { @Post() @ApiOperation({ summary: 'Create a new portfolio' }) + @ApiCreatedResponse({ type: PortfolioEnvelopeDto }) async create(@CurrentUser() user: { sub: number }, @Body() dto: CreatePortfolioDto) { const portfolio = await this.portfolioService.create(user.sub, dto); return { data: portfolio, meta: { cachedAt: null, fromCache: false } }; @@ -32,7 +61,7 @@ export class PortfolioController { @Get(':id') @ApiOperation({ summary: 'Get portfolio details with positions and prices' }) - @ApiOkResponse({ type: PortfolioDetailResponseDto }) + @ApiOkResponse({ type: PortfolioDetailEnvelopeDto }) async findOne(@CurrentUser() user: { sub: number }, @Param('id', ParseIntPipe) id: number) { const portfolio = await this.portfolioService.findOne(user.sub, id); return { data: portfolio, meta: { cachedAt: null, fromCache: false } }; @@ -40,6 +69,7 @@ export class PortfolioController { @Patch(':id') @ApiOperation({ summary: 'Update portfolio' }) + @ApiOkResponse({ type: PortfolioEnvelopeDto }) async update( @CurrentUser() user: { sub: number }, @Param('id', ParseIntPipe) id: number, @@ -51,6 +81,7 @@ export class PortfolioController { @Delete(':id') @ApiOperation({ summary: 'Delete portfolio' }) + @ApiOkResponse({ schema: nullDataEnvelopeSchema }) async remove(@CurrentUser() user: { sub: number }, @Param('id', ParseIntPipe) id: number) { await this.portfolioService.remove(user.sub, id); return { data: null, meta: { cachedAt: null, fromCache: false } }; @@ -58,6 +89,7 @@ export class PortfolioController { @Post(':id/positions') @ApiOperation({ summary: 'Add position to portfolio' }) + @ApiCreatedResponse({ type: PositionEnvelopeDto }) async addPosition( @CurrentUser() user: { sub: number }, @Param('id', ParseIntPipe) id: number, @@ -69,6 +101,7 @@ export class PortfolioController { @Patch(':id/positions/:positionId') @ApiOperation({ summary: 'Update position' }) + @ApiOkResponse({ type: PositionEnvelopeDto }) async updatePosition( @CurrentUser() user: { sub: number }, @Param('id', ParseIntPipe) id: number, @@ -81,6 +114,7 @@ export class PortfolioController { @Get(':id/analytics') @ApiOperation({ summary: 'Get portfolio analytics with PnL' }) + @ApiOkResponse({ type: AnalyticsEnvelopeDto }) async getAnalytics(@CurrentUser() user: { sub: number }, @Param('id', ParseIntPipe) id: number) { const result = await this.portfolioService.getAnalytics(user.sub, id); return { data: result, meta: { cachedAt: null, fromCache: false } }; @@ -88,6 +122,7 @@ export class PortfolioController { @Delete(':id/positions/:positionId') @ApiOperation({ summary: 'Remove position from portfolio' }) + @ApiOkResponse({ schema: nullDataEnvelopeSchema }) async removePosition( @CurrentUser() user: { sub: number }, @Param('id', ParseIntPipe) id: number, diff --git a/apps/backend/src/modules/securities/dto/screener-response.dto.ts b/apps/backend/src/modules/securities/dto/screener-response.dto.ts index f2df21d..3c765d8 100644 --- a/apps/backend/src/modules/securities/dto/screener-response.dto.ts +++ b/apps/backend/src/modules/securities/dto/screener-response.dto.ts @@ -13,13 +13,13 @@ export class ScreenerItemDto { @ApiProperty({ enum: ['share', 'bond'] }) type!: 'share' | 'bond'; - @ApiPropertyOptional({ example: 322.35 }) + @ApiPropertyOptional({ type: Number, nullable: true, example: 322.35 }) price!: number | null; - @ApiPropertyOptional({ example: 1.15 }) + @ApiPropertyOptional({ type: Number, nullable: true, example: 1.15 }) change!: number | null; - @ApiPropertyOptional({ example: 0.36 }) + @ApiPropertyOptional({ type: Number, nullable: true, example: 0.36 }) changePercent!: number | null; @ApiProperty({ example: 1925163 }) @@ -28,28 +28,28 @@ export class ScreenerItemDto { @ApiProperty({ example: 1 }) listLevel!: number; - @ApiPropertyOptional({ example: 6958336818320 }) + @ApiPropertyOptional({ type: Number, nullable: true, example: 6958336818320 }) capitalization!: number | null; - @ApiPropertyOptional({ example: 12.71 }) + @ApiPropertyOptional({ type: Number, nullable: true, example: 12.71 }) yieldToMaturity!: number | null; - @ApiPropertyOptional({ example: 4.5 }) + @ApiPropertyOptional({ type: Number, nullable: true, example: 4.5 }) duration!: number | null; - @ApiPropertyOptional({ example: 40.64 }) + @ApiPropertyOptional({ type: Number, nullable: true, example: 40.64 }) couponValue!: number | null; - @ApiPropertyOptional({ example: 8.15 }) + @ApiPropertyOptional({ type: Number, nullable: true, example: 8.15 }) couponPercent!: number | null; - @ApiPropertyOptional({ example: 29.48 }) + @ApiPropertyOptional({ type: Number, nullable: true, example: 29.48 }) accruedInt!: number | null; - @ApiPropertyOptional({ example: '2027-02-03' }) + @ApiPropertyOptional({ type: String, nullable: true, example: '2027-02-03' }) matDate!: string | null; - @ApiPropertyOptional({ example: 'ОФЗ-ПД' }) + @ApiPropertyOptional({ type: String, nullable: true, example: 'ОФЗ-ПД' }) bondType!: string | null; } @@ -69,3 +69,19 @@ export class ScreenerResultDto { @ApiProperty() totalPages!: number; } + +class ScreenerResponseMetaDto { + @ApiProperty({ type: String, nullable: true }) + cachedAt!: string | null; + + @ApiProperty() + fromCache!: boolean; +} + +export class ScreenerResponseDto { + @ApiProperty({ type: ScreenerResultDto }) + data!: ScreenerResultDto; + + @ApiProperty({ type: ScreenerResponseMetaDto }) + meta!: ScreenerResponseMetaDto; +} diff --git a/apps/backend/src/modules/securities/securities.controller.ts b/apps/backend/src/modules/securities/securities.controller.ts index b2a488f..be26469 100644 --- a/apps/backend/src/modules/securities/securities.controller.ts +++ b/apps/backend/src/modules/securities/securities.controller.ts @@ -4,7 +4,7 @@ import { SecuritiesService } from './securities.service'; import { ScreenerService } from './screener.service'; import { SearchQueryDto, SecurityType } from './dto/search-query.dto'; import { ScreenerQueryDto } from './dto/screener-query.dto'; -import { ScreenerResultDto } from './dto/screener-response.dto'; +import { ScreenerResponseDto } from './dto/screener-response.dto'; @ApiTags('Securities') @Controller('securities') @@ -27,7 +27,7 @@ export class SecuritiesController { @Get('screener') @ApiOperation({ summary: 'Фильтр ценных бумаг по параметрам' }) - @ApiOkResponse({ type: ScreenerResultDto }) + @ApiOkResponse({ type: ScreenerResponseDto }) async screener(@Query(ValidationPipe) query: ScreenerQueryDto) { const result = await this.screenerService.screen(query); return { data: result, meta: { cachedAt: null, fromCache: false } }; diff --git a/apps/backend/src/openapi-artifacts.spec.ts b/apps/backend/src/openapi-artifacts.spec.ts index 38caeec..52529a5 100644 --- a/apps/backend/src/openapi-artifacts.spec.ts +++ b/apps/backend/src/openapi-artifacts.spec.ts @@ -1,10 +1,14 @@ import { readFileSync } from 'node:fs'; import { join, resolve } from 'node:path'; +// eslint-disable-next-line @typescript-eslint/no-var-requires +const { load } = require('js-yaml'); + 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 openapi = load(openapiYaml) as any; const requiredPaths = [ '/api/v1/auth/register', @@ -31,4 +35,62 @@ describe('checked-in OpenAPI artifacts', () => { expect(openapiYaml).toContain(`${path}:`); } }); + + it('checked-in artifacts do not leak the local alternate codegen port', () => { + expect(frontendTypes).not.toContain('localhost:3001'); + expect(frontendTypes).not.toContain('3001'); + expect(openapiYaml).not.toContain('localhost:3001'); + expect(openapiYaml).not.toContain('3001'); + }); + + it('nullable primitive schemas are typed explicitly', () => { + const screenerItem = openapi.components.schemas.ScreenerItemDto; + + expect(screenerItem.properties.price).toMatchObject({ + type: 'number', + nullable: true, + }); + expect(screenerItem.properties.matDate).toMatchObject({ + type: 'string', + nullable: true, + }); + }); + + it('position tags request schemas are arrays of known tags', () => { + for (const schemaName of ['AddPositionDto', 'UpdatePositionDto']) { + const tags = openapi.components.schemas[schemaName].properties.tags; + + expect(tags).toMatchObject({ + type: 'array', + }); + expect(tags.items.enum).toContain('DIVIDEND'); + } + }); + + it('current auth, screener and portfolio operations have typed JSON responses', () => { + const requiredJsonResponses = [ + ['post', '/api/v1/auth/register', 201], + ['post', '/api/v1/auth/login', 201], + ['post', '/api/v1/auth/refresh', 200], + ['post', '/api/v1/auth/logout', 200], + ['get', '/api/v1/auth/me', 200], + ['patch', '/api/v1/auth/me', 200], + ['get', '/api/v1/securities/screener', 200], + ['get', '/api/v1/portfolios', 200], + ['post', '/api/v1/portfolios', 201], + ['get', '/api/v1/portfolios/{id}', 200], + ['patch', '/api/v1/portfolios/{id}', 200], + ['delete', '/api/v1/portfolios/{id}', 200], + ['post', '/api/v1/portfolios/{id}/positions', 201], + ['patch', '/api/v1/portfolios/{id}/positions/{positionId}', 200], + ['delete', '/api/v1/portfolios/{id}/positions/{positionId}', 200], + ['get', '/api/v1/portfolios/{id}/analytics', 200], + ] as const; + + for (const [method, path, status] of requiredJsonResponses) { + expect( + openapi.paths[path][method].responses[status].content?.['application/json'], + ).toBeDefined(); + } + }); }); diff --git a/apps/frontend/src/api/types.ts b/apps/frontend/src/api/types.ts index 86eceb2..9d9d3e2 100644 --- a/apps/frontend/src/api/types.ts +++ b/apps/frontend/src/api/types.ts @@ -395,12 +395,41 @@ export interface components { /** @example John */ name?: string; }; + AuthUserDto: { + id: number; + email: string; + name: string | null; + role: string; + }; + AuthTokenDataDto: { + user: components['schemas']['AuthUserDto']; + accessToken: string; + }; + AuthResponseMetaDto: { + cachedAt: string | null; + fromCache: boolean; + }; + AuthTokenResponseDto: { + data: components['schemas']['AuthTokenDataDto']; + meta: components['schemas']['AuthResponseMetaDto']; + }; LoginDto: { /** @example user@example.com */ email: string; /** @example securePass123 */ password: string; }; + LogoutDataDto: { + message: string; + }; + AuthLogoutResponseDto: { + data: components['schemas']['LogoutDataDto']; + meta: components['schemas']['AuthResponseMetaDto']; + }; + AuthProfileResponseDto: { + data: components['schemas']['AuthUserDto']; + meta: components['schemas']['AuthResponseMetaDto']; + }; UpdateProfileDto: { /** @example John Doe */ name?: string; @@ -415,31 +444,31 @@ export interface components { /** @enum {string} */ type: 'share' | 'bond'; /** @example 322.35 */ - price?: Record; + price?: number | null; /** @example 1.15 */ - change?: Record; + change?: number | null; /** @example 0.36 */ - changePercent?: Record; + changePercent?: number | null; /** @example 1925163 */ volume: number; /** @example 1 */ listLevel: number; /** @example 6958336818320 */ - capitalization?: Record; + capitalization?: number | null; /** @example 12.71 */ - yieldToMaturity?: Record; + yieldToMaturity?: number | null; /** @example 4.5 */ - duration?: Record; + duration?: number | null; /** @example 40.64 */ - couponValue?: Record; + couponValue?: number | null; /** @example 8.15 */ - couponPercent?: Record; + couponPercent?: number | null; /** @example 29.48 */ - accruedInt?: Record; + accruedInt?: number | null; /** @example 2027-02-03 */ - matDate?: Record; + matDate?: string | null; /** @example ОФЗ-ПД */ - bondType?: Record; + bondType?: string | null; }; ScreenerResultDto: { items: components['schemas']['ScreenerItemDto'][]; @@ -448,10 +477,22 @@ export interface components { pageSize: number; totalPages: number; }; + ScreenerResponseMetaDto: { + cachedAt: string | null; + fromCache: boolean; + }; + ScreenerResponseDto: { + data: components['schemas']['ScreenerResultDto']; + meta: components['schemas']['ScreenerResponseMetaDto']; + }; + PortfolioResponseMetaDto: { + cachedAt: string | null; + fromCache: boolean; + }; PortfolioListResponseDto: { id: number; name: string; - description?: Record; + description?: string | null; /** @default RUB */ currency: string; createdAt: string; @@ -465,6 +506,10 @@ export interface components { /** @description Number of bond positions */ bondCount: number; }; + PortfolioListEnvelopeDto: { + data: components['schemas']['PortfolioListResponseDto'][]; + meta: components['schemas']['PortfolioResponseMetaDto']; + }; CreatePortfolioDto: { /** @example Мой портфель */ name: string; @@ -476,11 +521,24 @@ export interface components { */ currency: 'RUB' | 'USD' | 'EUR' | 'CNY' | 'KZT' | 'BYN'; }; + PortfolioResponseDto: { + id: number; + name: string; + description?: string | null; + /** @default RUB */ + currency: string; + createdAt: string; + updatedAt: string; + }; + PortfolioEnvelopeDto: { + data: components['schemas']['PortfolioResponseDto']; + meta: components['schemas']['PortfolioResponseMetaDto']; + }; PositionWithPriceDto: { id: number; /** @example SBER */ secid: string; - shortName?: Record; + shortName?: string | null; /** * @example share * @enum {string} @@ -488,49 +546,49 @@ export interface components { type: 'share' | 'bond'; /** @example 10 */ quantity: number; - buyPrice?: Record; - buyDate?: Record; - notes?: Record; - tags?: Record; - currentPrice?: Record; - totalCost?: Record; - currentValue?: Record; + buyPrice?: number | null; + buyDate?: string | null; + notes?: string | null; + tags?: string[] | null; + currentPrice?: number | null; + totalCost?: number | null; + currentValue?: number | null; weightPercent: number; - pnl?: Record; - pnlPercent?: Record; - dividendIncome?: Record; - totalReturn?: Record; - totalReturnPercent?: Record; - change?: Record; - changePercent?: Record; - yieldToMaturity?: Record; - duration?: Record; - couponValue?: Record; - couponPercent?: Record; - nextCouponDate?: Record; - matDate?: Record; - accruedInt?: Record; - bid?: Record; - offer?: Record; - couponPeriod?: Record; - bondType?: Record; - offerDate?: Record; + pnl?: number | null; + pnlPercent?: number | null; + dividendIncome?: number | null; + totalReturn?: number | null; + totalReturnPercent?: number | null; + change?: number | null; + changePercent?: number | null; + yieldToMaturity?: number | null; + duration?: number | null; + couponValue?: number | null; + couponPercent?: number | null; + nextCouponDate?: string | null; + matDate?: string | null; + accruedInt?: number | null; + bid?: number | null; + offer?: number | null; + couponPeriod?: number | null; + bondType?: string | null; + offerDate?: string | null; }; PortfolioSummaryDto: { totalInvested: number; totalValue: number; totalPnl: number; - totalPnlPercent: Record; + totalPnlPercent: number | null; totalDividends: number; totalReturn: number; - totalReturnPercent: Record; + totalReturnPercent: number | null; positionCount: number; - weightedYield: Record; + weightedYield: number | null; }; PortfolioDetailResponseDto: { id: number; name: string; - description?: Record; + description?: string | null; /** @default RUB */ currency: string; createdAt: string; @@ -539,6 +597,10 @@ export interface components { totalValue: number; analytics: components['schemas']['PortfolioSummaryDto']; }; + PortfolioDetailEnvelopeDto: { + data: components['schemas']['PortfolioDetailResponseDto']; + meta: components['schemas']['PortfolioResponseMetaDto']; + }; UpdatePortfolioDto: { /** @example Мой портфель */ name?: string; @@ -566,9 +628,8 @@ export interface components { * "DIVIDEND", * "GROWTH" * ] - * @enum {string} */ - tags?: + tags?: ( | 'DIVIDEND' | 'GROWTH' | 'DEFENSIVE' @@ -576,7 +637,24 @@ export interface components { | 'BOND' | 'ETF' | 'GOVERNMENT' - | 'CASH'; + | 'CASH' + )[]; + }; + PositionResponseDto: { + id: number; + /** @example SBER */ + secid: string; + /** @example 10 */ + quantity: number; + notes?: string | null; + tags?: string[] | null; + portfolioId: number; + createdAt: string; + updatedAt: string; + }; + PositionEnvelopeDto: { + data: components['schemas']['PositionResponseDto']; + meta: components['schemas']['PortfolioResponseMetaDto']; }; UpdatePositionDto: { /** @example 15 */ @@ -591,9 +669,8 @@ export interface components { * @example [ * "DIVIDEND" * ] - * @enum {string} */ - tags?: + tags?: ( | 'DIVIDEND' | 'GROWTH' | 'DEFENSIVE' @@ -601,7 +678,16 @@ export interface components { | 'BOND' | 'ETF' | 'GOVERNMENT' - | 'CASH'; + | 'CASH' + )[]; + }; + AnalyticsResponseDto: { + positions: components['schemas']['PositionWithPriceDto'][]; + summary: components['schemas']['PortfolioSummaryDto']; + }; + AnalyticsEnvelopeDto: { + data: components['schemas']['AnalyticsResponseDto']; + meta: components['schemas']['PortfolioResponseMetaDto']; }; }; responses: never; @@ -646,7 +732,9 @@ export interface operations { headers: { [name: string]: unknown; }; - content?: never; + content: { + 'application/json': components['schemas']['AuthTokenResponseDto']; + }; }; }; }; @@ -667,7 +755,9 @@ export interface operations { headers: { [name: string]: unknown; }; - content?: never; + content: { + 'application/json': components['schemas']['AuthTokenResponseDto']; + }; }; }; }; @@ -684,7 +774,9 @@ export interface operations { headers: { [name: string]: unknown; }; - content?: never; + content: { + 'application/json': components['schemas']['AuthTokenResponseDto']; + }; }; }; }; @@ -701,7 +793,9 @@ export interface operations { headers: { [name: string]: unknown; }; - content?: never; + content: { + 'application/json': components['schemas']['AuthLogoutResponseDto']; + }; }; }; }; @@ -718,7 +812,9 @@ export interface operations { headers: { [name: string]: unknown; }; - content?: never; + content: { + 'application/json': components['schemas']['AuthProfileResponseDto']; + }; }; }; }; @@ -739,7 +835,9 @@ export interface operations { headers: { [name: string]: unknown; }; - content?: never; + content: { + 'application/json': components['schemas']['AuthProfileResponseDto']; + }; }; }; }; @@ -803,7 +901,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['ScreenerResultDto']; + 'application/json': components['schemas']['ScreenerResponseDto']; }; }; }; @@ -1007,7 +1105,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['PortfolioListResponseDto'][]; + 'application/json': components['schemas']['PortfolioListEnvelopeDto']; }; }; }; @@ -1029,7 +1127,9 @@ export interface operations { headers: { [name: string]: unknown; }; - content?: never; + content: { + 'application/json': components['schemas']['PortfolioEnvelopeDto']; + }; }; }; }; @@ -1049,7 +1149,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['PortfolioDetailResponseDto']; + 'application/json': components['schemas']['PortfolioDetailEnvelopeDto']; }; }; }; @@ -1069,7 +1169,12 @@ export interface operations { headers: { [name: string]: unknown; }; - content?: never; + content: { + 'application/json': { + data: unknown; + meta: components['schemas']['PortfolioResponseMetaDto']; + }; + }; }; }; }; @@ -1092,7 +1197,9 @@ export interface operations { headers: { [name: string]: unknown; }; - content?: never; + content: { + 'application/json': components['schemas']['PortfolioEnvelopeDto']; + }; }; }; }; @@ -1115,7 +1222,9 @@ export interface operations { headers: { [name: string]: unknown; }; - content?: never; + content: { + 'application/json': components['schemas']['PositionEnvelopeDto']; + }; }; }; }; @@ -1135,7 +1244,12 @@ export interface operations { headers: { [name: string]: unknown; }; - content?: never; + content: { + 'application/json': { + data: unknown; + meta: components['schemas']['PortfolioResponseMetaDto']; + }; + }; }; }; }; @@ -1159,7 +1273,9 @@ export interface operations { headers: { [name: string]: unknown; }; - content?: never; + content: { + 'application/json': components['schemas']['PositionEnvelopeDto']; + }; }; }; }; @@ -1178,7 +1294,9 @@ export interface operations { headers: { [name: string]: unknown; }; - content?: never; + content: { + 'application/json': components['schemas']['AnalyticsEnvelopeDto']; + }; }; }; }; diff --git a/docs/openapi/openapi.yaml b/docs/openapi/openapi.yaml index c81835a..e122902 100644 --- a/docs/openapi/openapi.yaml +++ b/docs/openapi/openapi.yaml @@ -24,6 +24,10 @@ paths: responses: '201': description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/AuthTokenResponseDto' tags: - Auth /api/v1/auth/login: @@ -40,6 +44,10 @@ paths: responses: '201': description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/AuthTokenResponseDto' tags: - Auth /api/v1/auth/refresh: @@ -50,6 +58,10 @@ paths: responses: '200': description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/AuthTokenResponseDto' tags: - Auth /api/v1/auth/logout: @@ -60,6 +72,10 @@ paths: responses: '200': description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/AuthLogoutResponseDto' tags: - Auth security: @@ -72,6 +88,10 @@ paths: responses: '200': description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/AuthProfileResponseDto' tags: - Auth security: @@ -89,6 +109,10 @@ paths: responses: '200': description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/AuthProfileResponseDto' tags: - Auth security: @@ -258,7 +282,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/ScreenerResultDto' + $ref: '#/components/schemas/ScreenerResponseDto' tags: - Securities /api/v1/securities/shares/{secid}: @@ -471,9 +495,7 @@ paths: content: application/json: schema: - type: array - items: - $ref: '#/components/schemas/PortfolioListResponseDto' + $ref: '#/components/schemas/PortfolioListEnvelopeDto' tags: - Portfolios security: @@ -491,6 +513,10 @@ paths: responses: '201': description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/PortfolioEnvelopeDto' tags: - Portfolios security: @@ -511,7 +537,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/PortfolioDetailResponseDto' + $ref: '#/components/schemas/PortfolioDetailEnvelopeDto' tags: - Portfolios security: @@ -534,6 +560,10 @@ paths: responses: '200': description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/PortfolioEnvelopeDto' tags: - Portfolios security: @@ -550,6 +580,18 @@ paths: responses: '200': description: '' + content: + application/json: + schema: + type: object + properties: + data: + nullable: true + meta: + $ref: '#/components/schemas/PortfolioResponseMetaDto' + required: + - data + - meta tags: - Portfolios security: @@ -573,6 +615,10 @@ paths: responses: '201': description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/PositionEnvelopeDto' tags: - Portfolios security: @@ -601,6 +647,10 @@ paths: responses: '200': description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/PositionEnvelopeDto' tags: - Portfolios security: @@ -622,6 +672,18 @@ paths: responses: '200': description: '' + content: + application/json: + schema: + type: object + properties: + data: + nullable: true + meta: + $ref: '#/components/schemas/PortfolioResponseMetaDto' + required: + - data + - meta tags: - Portfolios security: @@ -639,6 +701,10 @@ paths: responses: '200': description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/AnalyticsEnvelopeDto' tags: - Portfolios security: @@ -672,6 +738,54 @@ components: required: - email - password + AuthUserDto: + type: object + properties: + id: + type: number + email: + type: string + name: + type: string + nullable: true + role: + type: string + required: + - id + - email + - name + - role + AuthTokenDataDto: + type: object + properties: + user: + $ref: '#/components/schemas/AuthUserDto' + accessToken: + type: string + required: + - user + - accessToken + AuthResponseMetaDto: + type: object + properties: + cachedAt: + type: string + nullable: true + fromCache: + type: boolean + required: + - cachedAt + - fromCache + AuthTokenResponseDto: + type: object + properties: + data: + $ref: '#/components/schemas/AuthTokenDataDto' + meta: + $ref: '#/components/schemas/AuthResponseMetaDto' + required: + - data + - meta LoginDto: type: object properties: @@ -684,6 +798,33 @@ components: required: - email - password + LogoutDataDto: + type: object + properties: + message: + type: string + required: + - message + AuthLogoutResponseDto: + type: object + properties: + data: + $ref: '#/components/schemas/LogoutDataDto' + meta: + $ref: '#/components/schemas/AuthResponseMetaDto' + required: + - data + - meta + AuthProfileResponseDto: + type: object + properties: + data: + $ref: '#/components/schemas/AuthUserDto' + meta: + $ref: '#/components/schemas/AuthResponseMetaDto' + required: + - data + - meta UpdateProfileDto: type: object properties: @@ -708,13 +849,16 @@ components: - share - bond price: - type: object + type: number + nullable: true example: 322.35 change: - type: object + type: number + nullable: true example: 1.15 changePercent: - type: object + type: number + nullable: true example: 0.36 volume: type: number @@ -723,28 +867,36 @@ components: type: number example: 1 capitalization: - type: object + type: number + nullable: true example: 6958336818320 yieldToMaturity: - type: object + type: number + nullable: true example: 12.71 duration: - type: object + type: number + nullable: true example: 4.5 couponValue: - type: object + type: number + nullable: true example: 40.64 couponPercent: - type: object + type: number + nullable: true example: 8.15 accruedInt: - type: object + type: number + nullable: true example: 29.48 matDate: - type: object + type: string + nullable: true example: '2027-02-03' bondType: - type: object + type: string + nullable: true example: ОФЗ-ПД required: - secid @@ -774,6 +926,38 @@ components: - page - pageSize - totalPages + ScreenerResponseMetaDto: + type: object + properties: + cachedAt: + type: string + nullable: true + fromCache: + type: boolean + required: + - cachedAt + - fromCache + ScreenerResponseDto: + type: object + properties: + data: + $ref: '#/components/schemas/ScreenerResultDto' + meta: + $ref: '#/components/schemas/ScreenerResponseMetaDto' + required: + - data + - meta + PortfolioResponseMetaDto: + type: object + properties: + cachedAt: + type: string + nullable: true + fromCache: + type: boolean + required: + - cachedAt + - fromCache PortfolioListResponseDto: type: object properties: @@ -782,7 +966,8 @@ components: name: type: string description: - type: object + type: string + nullable: true currency: type: string default: RUB @@ -812,6 +997,18 @@ components: - positionCount - shareCount - bondCount + PortfolioListEnvelopeDto: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/PortfolioListResponseDto' + meta: + $ref: '#/components/schemas/PortfolioResponseMetaDto' + required: + - data + - meta CreatePortfolioDto: type: object properties: @@ -833,6 +1030,39 @@ components: - BYN required: - name + PortfolioResponseDto: + type: object + properties: + id: + type: number + name: + type: string + description: + type: string + nullable: true + currency: + type: string + default: RUB + createdAt: + type: string + updatedAt: + type: string + required: + - id + - name + - currency + - createdAt + - updatedAt + PortfolioEnvelopeDto: + type: object + properties: + data: + $ref: '#/components/schemas/PortfolioResponseDto' + meta: + $ref: '#/components/schemas/PortfolioResponseMetaDto' + required: + - data + - meta PositionWithPriceDto: type: object properties: @@ -842,7 +1072,8 @@ components: type: string example: SBER shortName: - type: object + type: string + nullable: true type: type: string example: share @@ -853,59 +1084,87 @@ components: type: number example: 10 buyPrice: - type: object + type: number + nullable: true buyDate: - type: object + type: string + nullable: true notes: - type: object + type: string + nullable: true tags: - type: object + nullable: true + type: array + items: + type: string currentPrice: - type: object + type: number + nullable: true totalCost: - type: object + type: number + nullable: true currentValue: - type: object + type: number + nullable: true weightPercent: type: number pnl: - type: object + type: number + nullable: true pnlPercent: - type: object + type: number + nullable: true dividendIncome: - type: object + type: number + nullable: true totalReturn: - type: object + type: number + nullable: true totalReturnPercent: - type: object + type: number + nullable: true change: - type: object + type: number + nullable: true changePercent: - type: object + type: number + nullable: true yieldToMaturity: - type: object + type: number + nullable: true duration: - type: object + type: number + nullable: true couponValue: - type: object + type: number + nullable: true couponPercent: - type: object + type: number + nullable: true nextCouponDate: - type: object + type: string + nullable: true matDate: - type: object + type: string + nullable: true accruedInt: - type: object + type: number + nullable: true bid: - type: object + type: number + nullable: true offer: - type: object + type: number + nullable: true couponPeriod: - type: object + type: number + nullable: true bondType: - type: object + type: string + nullable: true offerDate: - type: object + type: string + nullable: true required: - id - secid @@ -922,17 +1181,20 @@ components: totalPnl: type: number totalPnlPercent: - type: object + type: number + nullable: true totalDividends: type: number totalReturn: type: number totalReturnPercent: - type: object + type: number + nullable: true positionCount: type: number weightedYield: - type: object + type: number + nullable: true required: - totalInvested - totalValue @@ -951,7 +1213,8 @@ components: name: type: string description: - type: object + type: string + nullable: true currency: type: string default: RUB @@ -976,6 +1239,16 @@ components: - positions - totalValue - analytics + PortfolioDetailEnvelopeDto: + type: object + properties: + data: + $ref: '#/components/schemas/PortfolioDetailResponseDto' + meta: + $ref: '#/components/schemas/PortfolioResponseMetaDto' + required: + - data + - meta UpdatePortfolioDto: type: object properties: @@ -1014,22 +1287,66 @@ components: type: string example: Покупка на дип tags: - type: string + type: array example: - DIVIDEND - GROWTH - enum: - - DIVIDEND - - GROWTH - - DEFENSIVE - - SPECULATIVE - - BOND - - ETF - - GOVERNMENT - - CASH + items: + type: string + enum: + - DIVIDEND + - GROWTH + - DEFENSIVE + - SPECULATIVE + - BOND + - ETF + - GOVERNMENT + - CASH required: - secid - quantity + PositionResponseDto: + type: object + properties: + id: + type: number + secid: + type: string + example: SBER + quantity: + type: number + example: 10 + notes: + type: string + nullable: true + tags: + nullable: true + type: array + items: + type: string + portfolioId: + type: number + createdAt: + type: string + updatedAt: + type: string + required: + - id + - secid + - quantity + - portfolioId + - createdAt + - updatedAt + PositionEnvelopeDto: + type: object + properties: + data: + $ref: '#/components/schemas/PositionResponseDto' + meta: + $ref: '#/components/schemas/PortfolioResponseMetaDto' + required: + - data + - meta UpdatePositionDto: type: object properties: @@ -1046,15 +1363,39 @@ components: type: string example: Докупка tags: - type: string + type: array example: - DIVIDEND - enum: - - DIVIDEND - - GROWTH - - DEFENSIVE - - SPECULATIVE - - BOND - - ETF - - GOVERNMENT - - CASH + items: + type: string + enum: + - DIVIDEND + - GROWTH + - DEFENSIVE + - SPECULATIVE + - BOND + - ETF + - GOVERNMENT + - CASH + AnalyticsResponseDto: + type: object + properties: + positions: + type: array + items: + $ref: '#/components/schemas/PositionWithPriceDto' + summary: + $ref: '#/components/schemas/PortfolioSummaryDto' + required: + - positions + - summary + AnalyticsEnvelopeDto: + type: object + properties: + data: + $ref: '#/components/schemas/AnalyticsResponseDto' + meta: + $ref: '#/components/schemas/PortfolioResponseMetaDto' + required: + - data + - meta -- 2.47.2 From c062850e83b8e5c4dbf9c4a59a5771cbf9f3d669 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Mon, 15 Jun 2026 19:54:31 +0300 Subject: [PATCH 09/10] docs: refine openapi null response schema --- .../src/modules/portfolio/portfolio.controller.ts | 2 +- apps/backend/src/openapi-artifacts.spec.ts | 12 ++++++++++++ apps/frontend/src/api/types.ts | 4 ++-- docs/openapi/openapi.yaml | 4 ++-- 4 files changed, 17 insertions(+), 5 deletions(-) diff --git a/apps/backend/src/modules/portfolio/portfolio.controller.ts b/apps/backend/src/modules/portfolio/portfolio.controller.ts index 777a1d5..9632fe6 100644 --- a/apps/backend/src/modules/portfolio/portfolio.controller.ts +++ b/apps/backend/src/modules/portfolio/portfolio.controller.ts @@ -27,7 +27,7 @@ const nullDataEnvelopeSchema = { type: 'object', properties: { data: { - nullable: true, + type: 'null', }, meta: { $ref: getSchemaPath(PortfolioResponseMetaDto), diff --git a/apps/backend/src/openapi-artifacts.spec.ts b/apps/backend/src/openapi-artifacts.spec.ts index 52529a5..9a7a962 100644 --- a/apps/backend/src/openapi-artifacts.spec.ts +++ b/apps/backend/src/openapi-artifacts.spec.ts @@ -93,4 +93,16 @@ describe('checked-in OpenAPI artifacts', () => { ).toBeDefined(); } }); + + it('portfolio delete operations document a null data envelope', () => { + for (const [method, path, status] of [ + ['delete', '/api/v1/portfolios/{id}', 200], + ['delete', '/api/v1/portfolios/{id}/positions/{positionId}', 200], + ] as const) { + expect( + openapi.paths[path][method].responses[status].content['application/json'].schema.properties + .data, + ).toMatchObject({ type: 'null' }); + } + }); }); diff --git a/apps/frontend/src/api/types.ts b/apps/frontend/src/api/types.ts index 9d9d3e2..0046607 100644 --- a/apps/frontend/src/api/types.ts +++ b/apps/frontend/src/api/types.ts @@ -1171,7 +1171,7 @@ export interface operations { }; content: { 'application/json': { - data: unknown; + data: null; meta: components['schemas']['PortfolioResponseMetaDto']; }; }; @@ -1246,7 +1246,7 @@ export interface operations { }; content: { 'application/json': { - data: unknown; + data: null; meta: components['schemas']['PortfolioResponseMetaDto']; }; }; diff --git a/docs/openapi/openapi.yaml b/docs/openapi/openapi.yaml index e122902..767391b 100644 --- a/docs/openapi/openapi.yaml +++ b/docs/openapi/openapi.yaml @@ -586,7 +586,7 @@ paths: type: object properties: data: - nullable: true + type: 'null' meta: $ref: '#/components/schemas/PortfolioResponseMetaDto' required: @@ -678,7 +678,7 @@ paths: type: object properties: data: - nullable: true + type: 'null' meta: $ref: '#/components/schemas/PortfolioResponseMetaDto' required: -- 2.47.2 From 117f182851e846274859fa8e6c0344899b957dae Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Mon, 15 Jun 2026 20:02:50 +0300 Subject: [PATCH 10/10] docs: refresh project documentation --- AGENTS.md | 16 ++++++--- README.md | 9 +++++ apps/docs/docs/backend/api.md | 38 ++++++++++++++++++++ apps/docs/docs/backend/portfolio.md | 23 ++++++------ apps/docs/docs/development/codegen.md | 13 +++++++ apps/docs/docs/development/commands.md | 24 ++++++++++--- apps/docs/docs/development/conventions.md | 2 +- apps/docs/docs/development/testing.md | 23 +++++++++++- apps/docs/docs/frontend/api-client.md | 20 +++++++++++ apps/docs/docs/frontend/overview.md | 27 ++++++++++++-- apps/docs/docs/frontend/routes.md | 43 ++++++++++++++++++++--- apps/docs/docs/intro.md | 9 +++-- 12 files changed, 215 insertions(+), 32 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f659aa5..f63193d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ ## Репозиторий -npm workspaces монорепозиторий: `apps/backend` (NestJS), `apps/frontend` (React + Vite). +npm workspaces монорепозиторий: `apps/backend` (NestJS), `apps/frontend` (React + Vite), `apps/docs` (Docusaurus). ## Обязательный подход к разработке @@ -16,13 +16,18 @@ npm workspaces монорепозиторий: `apps/backend` (NestJS), `apps/fr |---|---| | `npm run dev:backend` | Запуск NestJS в режиме watch на :3000 | | `npm run dev:frontend` | Vite dev-сервер на :5173, проксирует `/api` → :3000 | +| `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` | `vitest run` (SWC, не ts-jest) | -| `npm run lint` | ESLint только для бэкенда | +| `npm run test:frontend` | Frontend Vitest suite | +| `npm run lint` | ESLint для backend и frontend | | `npm run format` | Prettier для всех `*.{ts,tsx}` | | `npm run codegen -w apps/frontend` | `openapi-typescript` из локального Swagger → `src/api/types.ts` | +Live MOEX integration tests opt-in: `npm run test:integration -w apps/backend`. + Один тест: `npx vitest run path/to/test.spec.ts -w apps/backend` ## Переменные окружения @@ -46,7 +51,7 @@ npm workspaces монорепозиторий: `apps/backend` (NestJS), `apps/fr ## Архитектура - **Бэкенд** — единственный клиент MOEX. Фронтенд никогда не обращается к MOEX напрямую. -- Feature-модули: `PrismaModule` (глобальный), `MoexClientModule` (глобальный), `CacheModule` (глобальный), `AuthModule`, `SharesModule`, `BondsModule`, `SecuritiesModule`, `CandlesModule`, `HealthModule`. +- Feature-модули: `PrismaModule` (глобальный), `MoexClientModule` (глобальный), `CacheModule` (глобальный), `AuthModule`, `SharesModule`, `BondsModule`, `SecuritiesModule`, `CandlesModule`, `PortfolioModule`, `HealthModule`. - `MoexClientService` использует p-queue (rate limiter) + circuit breaker (5 ошибок → 30s открыт). - In-memory кеш через `@nestjs/cache-manager`. Путь миграции на Redis описан (см. ADR-002). - Аутентификация: JWT access token (15m, в памяти) + refresh token (7d, httpOnly cookie, bcrypt hash в БД). Глобальный `JwtAuthGuard` (`@Public()` для открытых эндпоинтов). @@ -70,5 +75,6 @@ npm workspaces монорепозиторий: `apps/backend` (NestJS), `apps/fr - Prettier: одинарные кавычки, trailing commas, printWidth 100, точки с запятой. - Бэкенд: `const`, PascalCase для модулей/контроллеров/сервисов, DTO в `dto/` внутри каждого модуля. - Бэкенд использует SWC через `unplugin-swc` (vitest config). -- Тесты фронтенда отсутствуют. -- CI/CD в репозитории нет. +- Тесты фронтенда есть: Vitest + Testing Library + MSW. +- CI находится в `.gitea/workflows/ci.yml`. +- Pre-commit checks настроены через Husky и lint-staged. diff --git a/README.md b/README.md index 9663633..c4618bf 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ - **Backend:** NestJS, TypeScript, OpenAPI (Swagger) - **Frontend:** React, TypeScript, Vite, TanStack Query, lightweight-charts +- **Docs:** Docusaurus - **Infrastructure:** Docker, docker-compose ## Quick Start @@ -36,6 +37,13 @@ docker compose up --build ```bash npm run test:backend +npm run test:frontend +``` + +Live MOEX integration checks are opt-in: + +```bash +npm run test:integration -w apps/backend ``` ## Project Structure @@ -44,6 +52,7 @@ npm run test:backend apps/ backend/ — NestJS API (single point of access to MOEX ISS) frontend/ — React SPA with Vite + docs/ — Docusaurus documentation site docs/ architecture/ — ADR documents and diagrams openapi/ — OpenAPI specification diff --git a/apps/docs/docs/backend/api.md b/apps/docs/docs/backend/api.md index 8276499..11af40d 100644 --- a/apps/docs/docs/backend/api.md +++ b/apps/docs/docs/backend/api.md @@ -130,6 +130,25 @@ } ``` +### `GET /securities/screener` + +Скринер ценных бумаг по параметрам цены, объёма, доходности, дюрации, купона и срока погашения. + +**Parameters:** + +| Param | Type | Required | Description | +|---|---|---|---| +| `type` | enum | yes | `share` или `bond` | +| `priceMin`, `priceMax` | number | no | Диапазон цены | +| `volumeMin` | number | no | Минимальный объём | +| `yieldMin`, `yieldMax` | number | no | Диапазон доходности облигаций | +| `durationMin`, `durationMax` | number | no | Диапазон дюрации | +| `sortBy` | string | no | Поле сортировки | +| `sortOrder` | enum | no | `asc` или `desc` | +| `page`, `pageSize` | integer | no | Пагинация | + +**Response:** `{ data: { items, total, page, pageSize, totalPages }, meta }`. + ## Shares ### `GET /securities/shares/:secid` @@ -354,3 +373,22 @@ "meta": { "fromCache": false, "cachedAt": null } } ``` + +## Portfolios + +Все portfolio endpoints защищены JWT и возвращают envelope `{ data, meta }`. + +| Endpoint | Method | Description | +|---|---|---| +| `/api/v1/portfolios` | GET | Список портфелей пользователя | +| `/api/v1/portfolios` | POST | Создать портфель | +| `/api/v1/portfolios/:id` | GET | Детали портфеля с позициями и текущими ценами | +| `/api/v1/portfolios/:id` | PATCH | Обновить портфель | +| `/api/v1/portfolios/:id` | DELETE | Удалить портфель | +| `/api/v1/portfolios/:id/positions` | POST | Добавить позицию | +| `/api/v1/portfolios/:id/positions/:positionId` | PATCH | Обновить позицию | +| `/api/v1/portfolios/:id/positions/:positionId` | DELETE | Удалить позицию | +| `/api/v1/portfolios/:id/analytics` | GET | Аналитика портфеля и PnL | + +`DELETE` endpoints возвращают `{ data: null, meta }`, что отражено в OpenAPI schema и frontend +codegen types. diff --git a/apps/docs/docs/backend/portfolio.md b/apps/docs/docs/backend/portfolio.md index a7fcbde..92914f4 100644 --- a/apps/docs/docs/backend/portfolio.md +++ b/apps/docs/docs/backend/portfolio.md @@ -1,6 +1,7 @@ # Portfolio Module -The Portfolio module allows users to create and manage virtual investment portfolios for tracking purposes. +Portfolio module позволяет пользователям создавать и вести виртуальные инвестиционные портфели для +аналитики и отслеживания позиций. ## Overview @@ -10,19 +11,19 @@ The Portfolio module allows users to create and manage virtual investment portfo ## API Endpoints -All endpoints require JWT authentication (`JwtAuthGuard`). +Все endpoints требуют JWT authentication (`JwtAuthGuard`). | Endpoint | Method | Description | |---|---|---| -| `/api/v1/portfolios` | GET | List user's portfolios | -| `/api/v1/portfolios` | POST | Create portfolio | -| `/api/v1/portfolios/:id` | GET | Portfolio detail with enriched positions and analytics summary | -| `/api/v1/portfolios/:id/analytics` | GET | Detailed portfolio analytics with PnL | -| `/api/v1/portfolios/:id/patch` | PATCH | Update portfolio (name, description, currency) | -| `/api/v1/portfolios/:id` | DELETE | Delete portfolio (cascade deletes positions) | -| `/api/v1/portfolios/:id/positions` | POST | Add position (accepts buyPrice, buyDate) | -| `/api/v1/portfolios/:id/positions/:posId` | PATCH | Update position (quantity, buyPrice, buyDate, notes) | -| `/api/v1/portfolios/:id/positions/:posId` | DELETE | Remove position | +| `/api/v1/portfolios` | GET | Список портфелей пользователя | +| `/api/v1/portfolios` | POST | Создать портфель | +| `/api/v1/portfolios/:id` | GET | Детали портфеля с обогащёнными позициями и analytics summary | +| `/api/v1/portfolios/:id/analytics` | GET | Детальная аналитика портфеля и PnL | +| `/api/v1/portfolios/:id` | PATCH | Обновить портфель (name, description, currency) | +| `/api/v1/portfolios/:id` | DELETE | Удалить портфель вместе с позициями | +| `/api/v1/portfolios/:id/positions` | POST | Добавить позицию (buyPrice, buyDate, notes, tags) | +| `/api/v1/portfolios/:id/positions/:positionId` | PATCH | Обновить позицию | +| `/api/v1/portfolios/:id/positions/:positionId` | DELETE | Удалить позицию | ## Domain Model diff --git a/apps/docs/docs/development/codegen.md b/apps/docs/docs/development/codegen.md index a15dffc..c04c015 100644 --- a/apps/docs/docs/development/codegen.md +++ b/apps/docs/docs/development/codegen.md @@ -24,6 +24,19 @@ openapi-typescript http://localhost:3000/api/docs-json -o src/api/types.ts ### Output - `apps/frontend/src/api/types.ts` — сгенерированные типы `paths` и `operations` +- `docs/openapi/openapi.yaml` — статический snapshot Swagger JSON для ревью и документации + +### Verify Artifacts + +После регенерации OpenAPI artifacts запустите: + +```bash +npm run test -w apps/backend -- src/openapi-artifacts.spec.ts +``` + +Тест проверяет, что checked-in frontend types и YAML содержат актуальные auth, screener и portfolio +paths, не содержат локальный alternate port и сохраняют важные schema metadata для nullable полей, +array enum tags и typed response envelopes. ### Manual Types diff --git a/apps/docs/docs/development/commands.md b/apps/docs/docs/development/commands.md index 9a1dcdb..aee5e8f 100644 --- a/apps/docs/docs/development/commands.md +++ b/apps/docs/docs/development/commands.md @@ -5,13 +5,16 @@ | Command | Description | |---|---| | `npm run dev:backend` | Запуск NestJS в режиме watch на :3000 | -| `npm run dev:frontend` | Vite dev-сервер на :5173, проксирует `/api` → :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 test:backend` | `vitest run` (SWC, не ts-jest) | -| `npm run lint` | ESLint только для бэкенда | +| `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 check для всех `*.{ts,tsx}` | +| `npm run format:check` | Проверка Prettier для всех `*.{ts,tsx}` | ## Backend Workspace @@ -23,6 +26,7 @@ | `npm run lint -w apps/backend` | ESLint для `{src,test}/**/*.ts` | | `npm run test -w apps/backend` | `vitest run` | | `npm run test:watch -w apps/backend` | `vitest` | +| `npm run test:integration -w apps/backend` | Opt-in live MOEX integration tests, требуется network access | ## Frontend Workspace @@ -32,6 +36,16 @@ | `npm run build -w apps/frontend` | `tsc -b && vite build` | | `npm run preview -w apps/frontend` | `vite preview` | | `npm run codegen -w apps/frontend` | `openapi-typescript` из Swagger → `src/api/types.ts` | +| `npm run lint -w apps/frontend` | ESLint для `src/**/*.{ts,tsx}` | +| `npm run test -w apps/frontend` | Frontend Vitest suite | + +## 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 | ## Single Test diff --git a/apps/docs/docs/development/conventions.md b/apps/docs/docs/development/conventions.md index 385bd3b..63b5426 100644 --- a/apps/docs/docs/development/conventions.md +++ b/apps/docs/docs/development/conventions.md @@ -18,7 +18,7 @@ Prettier (`.prettierrc`): ## Linting -ESLint только для бэкенда (`apps/backend`). +ESLint запускается для backend (`apps/backend`) и frontend (`apps/frontend`). Плагины: - `@typescript-eslint/eslint-plugin` diff --git a/apps/docs/docs/development/testing.md b/apps/docs/docs/development/testing.md index c755178..85bcf1f 100644 --- a/apps/docs/docs/development/testing.md +++ b/apps/docs/docs/development/testing.md @@ -39,4 +39,25 @@ npm run test:watch -w apps/backend ## 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 или сетевых +ограничениях окружения. diff --git a/apps/docs/docs/frontend/api-client.md b/apps/docs/docs/frontend/api-client.md index e39a33d..f974de8 100644 --- a/apps/docs/docs/frontend/api-client.md +++ b/apps/docs/docs/frontend/api-client.md @@ -8,6 +8,7 @@ async function request( path: string, params?: Record, + options?: { method?: string; body?: unknown; skipAuth?: boolean }, ): Promise<{ data: T; meta: ApiResponseMeta }> ``` @@ -20,7 +21,14 @@ async function request( | Function | Method | Path | |---|---|---| | `getHealth()` | GET | `/api/v1/health` | +| `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()` / `getMe()` | GET | `/api/v1/auth/me` | +| `updateProfile(data)` | PATCH | `/api/v1/auth/me` | | `searchSecurities(q, type?, limit?)` | GET | `/api/v1/securities/search` | +| `screenSecurities(query)` / `getScreenerResults(query)` | GET | `/api/v1/securities/screener` | | `getShare(secid)` | GET | `/api/v1/securities/shares/:secid` | | `getShareMarketData(secid)` | GET | `/api/v1/securities/shares/:secid/marketdata` | | `getShareDividends(secid)` | GET | `/api/v1/securities/shares/:secid/dividends` | @@ -30,6 +38,15 @@ async function request( | `getBondHistory(secid, from, till)` | GET | `/api/v1/securities/bonds/:secid/history` | | `getShareCandles(secid, interval, from, till)` | GET | `/api/v1/securities/shares/:secid/candles` | | `getBondCandles(secid, interval, from, till)` | GET | `/api/v1/securities/bonds/:secid/candles` | +| `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` | ## Types @@ -41,5 +58,8 @@ async function request( - `BondResponse` — спецификация облигации + `BondMarketData` - `CandleItem`, `DividendItem`, `ShareHistoryItem`, `BondHistoryItem` - `SearchResultItem`, `HealthResponse` +- `UserResponse`, `AuthResponse` +- `Portfolio`, `PortfolioDetail`, `Position`, `PortfolioSummary`, `AnalyticsResponse` +- `ScreenerItem`, `ScreenerResult` Codegen-типы из OpenAPI в `api/types.ts` (генерируются через `npm run codegen`). diff --git a/apps/docs/docs/frontend/overview.md b/apps/docs/docs/frontend/overview.md index b589ad0..3cb6e60 100644 --- a/apps/docs/docs/frontend/overview.md +++ b/apps/docs/docs/frontend/overview.md @@ -9,6 +9,7 @@ React SPA, собранная с Vite. - TanStack Query v5 - lightweight-charts v4 - openapi-fetch (с рукописными типами `responses.ts`) +- Vitest + Testing Library + MSW - Vite 5 ## Source Layout @@ -19,10 +20,22 @@ apps/frontend/src/ ├── App.tsx # BrowserRouter ├── routes.tsx # Маршруты ├── api/ +│ ├── auth.ts # Auth API helpers │ ├── client.ts # HTTP-клиент (fetch) +│ ├── portfolio.ts # Portfolio API helpers │ ├── responses.ts # Типы ответов (ручные) +│ ├── screener.ts # Screener API helpers │ └── types.ts # Типы из openapi-typescript +├── context/ +│ └── AuthContext.tsx ├── hooks/ +│ ├── useAuth.ts +│ ├── usePortfolio.ts +│ ├── usePortfolioAnalytics.ts +│ ├── usePortfolioMutations.ts +│ ├── usePortfolios.ts +│ ├── usePositionMutations.ts +│ ├── useScreener.ts │ ├── useSearch.ts │ ├── useStock.ts │ ├── useStockCandles.ts @@ -31,14 +44,24 @@ apps/frontend/src/ │ └── useBondCandles.ts ├── components/ │ ├── Layout.tsx +│ ├── ProtectedRoute.tsx │ ├── SearchBar.tsx │ ├── PriceChart.tsx │ ├── StockDetails.tsx -│ └── BondDetails.tsx +│ ├── BondDetails.tsx +│ └── portfolios/ ├── pages/ │ ├── HomePage.tsx │ ├── StockPage.tsx -│ └── BondPage.tsx +│ ├── BondPage.tsx +│ ├── LoginPage.tsx +│ ├── RegisterPage.tsx +│ ├── ProfilePage.tsx +│ ├── portfolios/ +│ └── screener/ +├── test/ +│ ├── handlers.ts # MSW handlers +│ └── test-utils.tsx └── styles.css ``` diff --git a/apps/docs/docs/frontend/routes.md b/apps/docs/docs/frontend/routes.md index d4ba323..8fa479d 100644 --- a/apps/docs/docs/frontend/routes.md +++ b/apps/docs/docs/frontend/routes.md @@ -2,11 +2,17 @@ Определены в `apps/frontend/src/routes.tsx`. -| Path | Component | Description | -|---|---|---| -| `/` | `HomePage` | Главная страница с приветствием | -| `/stocks/:secid` | `StockPage` | Страница акции | -| `/bonds/:secid` | `BondPage` | Страница облигации | +| 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 | Детальная страница портфеля | Все страницы обёрнуты в `Layout`, который содержит: @@ -22,6 +28,33 @@ } /> } /> } /> + } /> + } /> + } /> + + + + } + /> + + + + } + /> + + + + } + /> ``` diff --git a/apps/docs/docs/intro.md b/apps/docs/docs/intro.md index 91e2f5f..895392d 100644 --- a/apps/docs/docs/intro.md +++ b/apps/docs/docs/intro.md @@ -1,3 +1,7 @@ +--- +slug: / +--- + # MoexVibe Веб-приложение для анализа ценных бумаг Московской биржи (MOEX). @@ -15,7 +19,8 @@ moex-vibe/ ├── apps/ │ ├── backend/ # NestJS API (единственная точка доступа к MOEX) -│ └── frontend/ # React SPA +│ ├── frontend/ # React SPA +│ └── docs/ # Docusaurus documentation site ├── docs/ │ ├── architecture/ # ADR и диаграммы │ ├── openapi/ # OpenAPI-спецификация @@ -31,6 +36,6 @@ moex-vibe/ ## Key Principles - Backend — единственный клиент MOEX. Frontend никогда не обращается к MOEX напрямую. -- npm workspaces монорепозиторий: `apps/backend` и `apps/frontend`. +- npm workspaces монорепозиторий: `apps/backend`, `apps/frontend` и `apps/docs`. - Глобальный префикс API: `/api/v1`. Swagger: `/api/docs`. - Ответы API обёрнуты в `{ data: T, meta: { fromCache, cachedAt } }`. -- 2.47.2