From 4796d9d3f0160d96004a982f6c2526f199de4b9f Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Sun, 27 Sep 2026 14:09:05 +0300 Subject: [PATCH] fix: harden auth and investment synchronization Resolve A01-A10 and C01-C07 from the backend audit. Preserve auth failures, forced candle reconciliation and forecast freshness. Validate MOEX responses and nested HTTP errors; remove unused legacy paths. Verified: 209 unit tests, 262 SQLite e2e tests on Linux, OpenAPI and durability. Record the remaining macOS native SQLite abort in the audit. --- .ai/architecture.md | 6 +- .ai/conventions/error-handling.md | 4 +- .ai/conventions/http-api.md | 6 +- BACKEND_AUDIT.md | 673 ++++++++++++++++++ .../api-safe-error-response.decorator.ts | 6 + .../filters/http-exception.filter.spec.ts | 8 +- src/common/filters/http-exception.filter.ts | 26 +- .../logging/request-logging.middleware.ts | 3 - .../logging/structured-logger.service.ts | 1 - src/integrations/moex/moex.client.spec.ts | 185 ++++- src/integrations/moex/moex.client.ts | 21 +- .../tbank/tbank-access.service.spec.ts | 162 ----- .../tbank/tbank-access.service.ts | 97 +-- .../tbank/tbank-account.policy.spec.ts | 27 +- .../tbank/tbank-account.policy.ts | 11 - src/integrations/tbank/tbank.api.ts | 3 +- src/main.ts | 15 +- src/modules/auth/application/auth.service.ts | 44 +- .../auth/presentation/auth.controller.spec.ts | 21 - .../auth/presentation/auth.controller.ts | 2 +- .../domain/bank-card.calculations.spec.ts | 15 +- .../domain/bank-card.calculations.ts | 10 +- .../application/candle.service.spec.ts | 113 +++ .../instruments/application/candle.service.ts | 105 ++- .../application/catalog-discovery.service.ts | 2 +- .../application/catalog-enrichment.service.ts | 14 +- .../application/instrument-market.ts | 14 - .../instrument-position.spec.ts} | 37 +- .../infrastructure/candle.repository.ts | 35 +- .../catalog-discovery.repository.ts | 2 +- .../stocks/application/stock-position.ts | 5 - .../investments/investment-sync-plan.spec.ts | 2 - .../investments/investment-sync-plan.ts | 2 +- .../brokerage-operations.service.ts | 10 +- .../brokerage-operation.mapper.spec.ts | 11 + .../brokerage-operation.mapper.ts | 12 +- .../brokerage-operations.repository.ts | 26 +- .../investments/operations/operations.api.ts | 5 +- .../portfolio/application/forecast.service.ts | 23 +- .../application/portfolio.service.ts | 10 +- .../investments/portfolio/portfolio.api.ts | 1 - .../tbank-sync/tbank-sync.controller.ts | 15 +- .../tbank-sync/tbank-sync.repository.ts | 2 +- .../tbank-sync/tbank-sync.service.ts | 21 +- test/app.e2e-spec.ts | 136 +++- test/bank-cards.e2e-spec.ts | 34 + test/fixtures/tbank-sync-crash.cjs | 2 +- test/openapi-contract.e2e-spec.ts | 17 + test/tbank-candles-persistence.e2e-spec.ts | 150 +++- test/tbank-catalog-discovery.e2e-spec.ts | 2 +- test/tbank-ledger-local.e2e-spec.ts | 93 +++ test/tbank-metadata-persistence.e2e-spec.ts | 2 +- test/tbank-operations-sync.e2e-spec.ts | 2 +- test/tbank-screen-sync.e2e-spec.ts | 114 ++- test/upcoming-events.e2e-spec.ts | 158 +++- 55 files changed, 1954 insertions(+), 569 deletions(-) create mode 100644 BACKEND_AUDIT.md delete mode 100644 src/integrations/tbank/tbank-access.service.spec.ts delete mode 100644 src/modules/auth/presentation/auth.controller.spec.ts create mode 100644 src/modules/investments/instruments/application/candle.service.spec.ts rename src/modules/investments/instruments/{stocks/application/stock-position.spec.ts => application/instrument-position.spec.ts} (72%) delete mode 100644 src/modules/investments/instruments/stocks/application/stock-position.ts diff --git a/.ai/architecture.md b/.ai/architecture.md index b89cd57..770f008 100644 --- a/.ai/architecture.md +++ b/.ai/architecture.md @@ -160,8 +160,10 @@ HTTP/e2e naming и расположение — в [Testing](conventions/testing `*.module.ts` задаёт DI boundary; `*.api.ts` перечисляет используемые другими модулями providers, функции и типы. Внешний production import идёт через этот -API или файл Nest module. Чистый `integrations/tbank/decimal.ts` — отдельный -публичный вход для protobuf conversion: импорт арифметики не загружает DI/Prisma. +API или файл Nest module. Чистые `integrations/tbank/decimal.ts` и +`modules/investments/operations/domain/operations-sync-plan.ts` — отдельные +публичные входы для protobuf conversion и interval rules: импорт расчётов +не загружает DI/Prisma. Внутри модуля используй прямые локальные импорты. Tests могут импортировать внутренний owner для проверки поведения и mocks. Новый export требует текущего потребителя; controller и persistence остаются diff --git a/.ai/conventions/error-handling.md b/.ai/conventions/error-handling.md index 7996c42..dfc072e 100644 --- a/.ai/conventions/error-handling.md +++ b/.ai/conventions/error-handling.md @@ -8,8 +8,8 @@ application/domain outcome → application mapping → HttpExceptionFilter ``` Сохраняй принятый профиль RFC 9457: `status`, стабильный публичный `code`, -для validation — `errors: [{ pointer, detail }]`. Точный контракт и известное -расхождение validation описаны в [HTTP API](http-api.md#источник-контракта). +для validation — `errors: [{ pointer, detail }]`. Точный контракт validation +описан в [HTTP API](http-api.md#источник-контракта). Не добавляй `type/title/instance` как обязательные поля без contract change. Чистый domain не импортирует Nest HTTP exceptions. Существующий application diff --git a/.ai/conventions/http-api.md b/.ai/conventions/http-api.md index 7e24e75..988a756 100644 --- a/.ai/conventions/http-api.md +++ b/.ai/conventions/http-api.md @@ -83,6 +83,6 @@ artifact отдельно; backend не читает их репозитории HTTP prefix — `/api/v1`; Swagger доступен согласно `src/config/swagger.config.ts`. Общий error response — `application/problem+json` со `status` и публичным `code`. Validation требует `400 / VALIDATION_ERROR` и `errors: [{ pointer, detail }]`. -Известное ограничение: ValidationPipe сейчас формирует строковые сообщения, -а filter ожидает структурированные field errors; schema parity не доказывает -правильность `errors`. Изменение требует проверки реального HTTP pipeline. +ValidationPipe сохраняет структурированные field errors без `target/value`, +а filter формирует JSON pointers, включая вложенные поля. Schema parity не +доказывает правильность `errors`; изменения проверяются через реальный HTTP pipeline. diff --git a/BACKEND_AUDIT.md b/BACKEND_AUDIT.md new file mode 100644 index 0000000..27c27cb --- /dev/null +++ b/BACKEND_AUDIT.md @@ -0,0 +1,673 @@ +# Аудит backend WealthLedger и план исправлений + +Дата аудита: 2026-09-27. Репозиторий: `/Users/ksv741/Project/vibe/WealthLedger/backend`. +База: `516e43203eb3fee8e6a0c7018be2fee11f1b900c`, ветка `codex/investment-contracts`. +При аудите рабочее дерево было чистым; production-код не изменялся. +Этот документ сохраняет результаты аудита и задаёт проверяемый план исправлений. +Само сохранение документа не означает выполнения исправлений. + +Номера строк относятся к указанному commit. Перед изменением повторно найти +symbol и проверить актуальный flow: после предыдущих исправлений строки сдвинутся. +Все относительные пути и ссылки относятся только к backend. + +## Общая оценка + +Backend имеет понятных владельцев ответственности: контроллеры тонкие, +денежные расчёты преимущественно точные, транзакции и очереди сосредоточены +у своих владельцев. Массовое дробление файлов не требуется. +Наиболее проблемные области — обработка ошибок auth, проверка ответов MOEX, +согласование состояния фоновой синхронизации и временных признаков кешированных данных. + +Изучены локальные [AGENTS.md](AGENTS.md), [порядок работы](.ai/conventions/engineering.md), +[архитектура](.ai/architecture.md) и активированные темы HTTP, errors, data, +testing, security, integrations и observability. Соседние репозитории не использовались. + +Инвентаризированы 135 production-файлов TypeScript, 16 004 строки, 17 Nest module-файлов. +Generated Prisma, vendor protocols, миграции и тесты отделены от production-рейтинга. + +Приоритеты: **высокий** — существенный риск безопасности или искажения данных; +**средний** — ограниченный дефект поведения, контракта или доказанная стоимость связности; +**низкий** — локальное упрощение. Уверенность всех перечисленных находок высокая +для описанного механизма; ограничения конкретного воспроизведения указаны отдельно. + +## План выполнения и границы + +1. Прочитать действующий backend AGENTS и активированные conventions; зафиксировать + текущие HEAD/status. Сохранить существующие staged/unstaged/untracked изменения. +2. Работать по находкам A01–A10 и cleanup C01–C07 ниже, в порядке пакетов из таблицы. + Перед каждым исправлением перечитать владельца целиком, callers, связанные tests + и 1–3 релевантных примера. Если факт больше не воспроизводится, отметить это + с доказательством вместо механического применения рекомендации. +3. Для дефекта сначала получить падающую проверку наблюдаемого результата на самой + дешёвой надёжной boundary. Для concurrency/atomicity использовать реальную + изолированную SQLite; mock race не доказывает DB-инвариант. +4. Исправлять у существующего владельца минимальным связанным diff. Сохранять + precision, ownership, public error codes, transaction/fencing, cache isolation, + limiter и выполняющиеся promises. Предложения ниже задают результат, а не + обязательную форму реализации. +5. После каждого пакета выполнить targeted checks; в конце — актуальные обязательные + gates по backend conventions. Перед каждой Node-командой выполнить `nvm use --silent`. +6. Сохранить исходный снимок аудита; результаты реализации записывать в таблицу + «Статус исправлений». Для каждого ID указать diff/решение, команды и результат, + остаточный риск либо точную причину невыполнения. + +| Пакет | Пункты | Условие завершения | +| --- | --- | --- | +| 1. Auth и безопасные ошибки | A01, A02 | Отказы хранилища не маскируются; секреты не попадают в completion log | +| 2. MOEX trust boundary | A03 | Malformed response не публикуется как полный результат; pagination завершается | +| 3. Статус синхронизации | A04 | Screen учитывает состояние successor через владельца operations | +| 4. Свечи | A05 | Force не теряется при coalescing; forced publication остаётся атомарной | +| 5. Свежесть и время | A06, A07 | Stale-флаг актуален; wire timestamp совпадает с используемым при фильтрации | +| 6. HTTP validation | A08 | Реальный pipeline возвращает структурированные field errors | +| 7. Связность и удаление legacy | A09, A10, C01–C07 | Живые boundaries сохранены; лишние реализации и exports удалены | + +Пакеты независимы по цели, но могут пересекаться файлами и общими fixtures. +Параллелить только области с непересекающимся владением файлами, затем выполнить +общую проверку. A02 и A08 изменяют один HTTP filter и должны быть согласованы. + +В scope входят исправления подтверждённых находок и их regression tests. +Гипотезы в конце документа не являются задачами на изменение кода. +Новые зависимости, generic repositories, scheduler framework, интерфейсы ради +паттерна, schema migrations и size-only splits для этих исправлений не требуются. +Отдельную необходимость такого расширения следует доказать до реализации. +Коммиты, push и PR выполняются только по отдельному запросу пользователя. + +## Подтверждённые находки + +### A01. Auth lifecycle смешивает invalid credentials и operational failure + +**Приоритет:** высокий. **Уверенность:** высокая. + +**Места и потребители:** [AuthService.logout, строки 104–126](src/modules/auth/application/auth.service.ts), +[AuthController.logout, 95–104; refresh, 85–91](src/modules/auth/presentation/auth.controller.ts). + +Общий catch в logout подавляет JWT verification errors и отказ Prisma при записи +`revokedAt`. Controller затем очищает cookie и возвращает 204, хотя серверная +сессия осталась действительной. Сохранённая копия refresh token продолжает работать. +Смежное проявление: refresh controller удаляет cookie при любой ошибке, включая +временный отказ БД, без доказательства недействительности токена. + +**Доказательство:** inline fault injection на настоящих AuthService, JwtService +и AuthController с синтетической сессией подтвердил: + +```text +logoutResolved=true +cookieCleared=true +sessionNotRevoked=true +sameRefreshTokenStillUsable=true +unexpectedRefreshErrorPropagated=true +validCookieRemoved=true +``` + +Это доказательство обработки injected boundary failure, а не реального отказа БД. +[Auth HTTP suite](test/app.e2e-spec.ts) проверяет обычный logout, но не этот отказ. +Нарушен [контракт отзыва сессии](.ai/conventions/security-auth.md): идемпотентность +не превращает несостоявшуюся запись в успешную. + +**Минимальное действие:** ограничить подавление ошибок logout недействительным JWT; +ошибки хранилища пробрасывать. При refresh очищать cookie только при установленном +auth failure. Существующий AuthService.refresh уже отделяет verification от DB reads; +BankCardsRepository.write также пробрасывает неизвестные ошибки. + +**Приёмка:** + +- Отсутствующий/недействительный/уже отозванный refresh сохраняет идемпотентный logout. +- Ошибка записи revocation не возвращает фиктивный 204. +- Успешный logout делает последующий refresh невозможным. +- Временный отказ refresh не удаляет действующую cookie, повтор после восстановления возможен. +- Проверены status/code/cookie через configured HTTP; успешный отзыв — с SQLite. + +**Риск:** сохранить существующую семантику недействительных токенов. Изменение +access-JWT lifetime или введение replay history не входят в этот пункт. + +### A02. Многострочное сообщение исключения попадает в completion log + +**Приоритет:** высокий. **Уверенность:** высокая. + +**Места:** [HttpExceptionFilter, 30–37](src/common/filters/http-exception.filter.ts). +**Потребители:** [RequestLoggingMiddleware, 43–57](src/common/logging/request-logging.middleware.ts), +[StructuredLogger, 47–63](src/common/logging/structured-logger.service.ts). + +Фильтр удаляет только первую строку `exception.stack`. Остальные строки +многострочного Error.message сохраняются. Поле stack разрешено logger allowlist; +redaction HTTP body к нему не применяется. + +**Воспроизведение:** configured HTTP app; AuthService.login выбрасывает синтетический +`Error('operation failed\npassword: SYNTHETIC_AUDIT_SECRET')`. Ответ остаётся +`500 / INTERNAL_SERVER_ERROR`, но completion log содержит вторую строку сообщения. +Реальные секреты не использовались. Существующий filter spec проверяет однострочное сообщение. +Нарушены [observability](.ai/conventions/observability.md) и security rules. + +**Минимальное действие:** убрать передачу сырого stack через общую HTTP boundary, +сохранив code/request context. Если stack необходим, нужна проверенная форма кадров +без произвольного текста исключения; удаление только первой строки недостаточно. + +**Приёмка:** multiline synthetic secrets отсутствуют в response и в конечном +payload logger, а status/code/correlation сохраняются. Проверить настоящий +filter → middleware → logger, не только mock вызова filter. + +**Риск:** диагностическая подробность может уменьшиться; raw message возвращать нельзя. + +### A03. MOEX malformed response превращается в полный пустой прогноз + +**Приоритет:** высокий. **Уверенность:** высокая. + +**Место:** [MoexClient, 122–142 и 163–165](src/integrations/moex/moex.client.ts). +Отсутствующий запрошенный ISS block преобразуется в `[]`. Неполный cursor может +завершить чтение с частичными данными; cursor с нулевым pageSize может зациклить его. + +**Реальная цепочка:** [CatalogEnrichmentService, 444 и 509–548](src/modules/investments/instruments/application/catalog-enrichment.service.ts) +публикует `bondEvents: []`; readMany считает существующий row complete; +[UpcomingEventsService, 137–197](src/modules/investments/upcoming-events/upcoming-events.service.ts) +строит пустые events/totals; [ForecastRepository, 124–150](src/modules/investments/portfolio/infrastructure/forecast.repository.ts) +может заменить прежний непустой forecast. Владелец protocol validation — integration client. + +**Воспроизведение:** настоящий MoexClient с mock fetch HTTP 200 без coupons/offers +вернул `[]`. Cursor `INDEX=0,TOTAL=1,PAGESIZE=0` дал пять запросов `start=0`; +проба остановлена искусственной ошибкой. Timeout каждого fetch не ограничивает весь цикл. + +**Расхождение тестов:** [moex.client.spec, 97–107](src/integrations/moex/moex.client.spec.ts) +считает отсутствующий block пустым результатом; +[upcoming-events.e2e, 439–451](test/upcoming-events.e2e-spec.ts) обещает обратное, +но подменяет весь клиент готовым rejection и не проверяет parser. + +**Минимальное действие:** различать отсутствующий/повреждённый block и корректный +`data: []`; проверять структуру строк и cursor, положительный pageSize и строгое +продвижение start. Нарушение протокола → `MOEX_UNAVAILABLE`, без частичного success. + +**Приёмка:** + +- Malformed block/cursor не меняет сохранённый полный календарь/forecast. +- Корректный пустой block остаётся успешным пустым результатом. +- Непродвигающаяся pagination завершается ошибкой, без бесконечных fetch. +- Семантика HTTP 404 проверена отдельно и не изменена случайно. +- Downstream regression использует настоящий MoexClient с mock fetch, а не mock rejection клиента. + +**Риск:** перепутать законное отсутствие данных с ошибкой protocol shape. + +### A04. Screen sync сообщает succeeded до завершения successor + +**Приоритет:** средний. **Уверенность:** высокая. + +**Места:** [TbankSyncService.status, 330–345](src/modules/investments/tbank-sync/tbank-sync.service.ts), +[TbankSyncRepository.read, 70–80](src/modules/investments/tbank-sync/tbank-sync.repository.ts). +[Operations repository, 441–445](src/modules/investments/operations/infrastructure/brokerage-operations.repository.ts) +помечает объединяемую группу `superseded` и сохраняет successorId. Владелец операций +умеет разрешать цепочку в observe, строки 564–590, но screen читает прямого child. + +**Сценарий:** две активные screen-группы на части января; третья объединяет интервалы. +Screen вытесненной группы сообщает succeeded, хотя successor ещё running; его +последующая ошибка также теряется. + +**Воспроизведение настоящего status с mocked persistence:** + +```text +screenState=succeeded +resource.availability=missing +resource.sync.state=running +actualOperationState=running +``` + +SQLite bridge покрыт [operations-sync e2e, 455–474](test/tbank-operations-sync.e2e-spec.ts), +но существующая проверка наблюдает operations.observe, а не screen status. + +**Минимальное действие:** разрешать operations successors через существующего +владельца перед агрегацией screen state/progress. Не копировать второй алгоритм +разрешения цепочки в screen service. + +**Приёмка:** configured HTTP + SQLite bridge; задержанный successor → running, +затем успех → succeeded или отказ → failed; progress согласован, чужая revision +не видна. **Риск:** fencing, цепочки successors и корректный подсчёт progress. + +### A05. Force обновления свечей теряется при coalescing + +**Приоритет:** средний. **Уверенность:** высокая. + +**Места:** [CandleService.request/load, 326–365](src/modules/investments/instruments/application/candle.service.ts), +[CandleRepository.reserve, 76–82](src/modules/investments/instruments/infrastructure/candle.repository.ts). +Публичный caller: [TbankSyncService, 288–306](src/modules/investments/tbank-sync/tbank-sync.service.ts) +вызывает candles.request с force=true; candleRange необязателен, ключ может совпасть +с ordinary GET/prefetch. + +При launch=false service возвращает существующий id без сохранения новых force/priority. +При присоединении cross-user observer требования также не повышаются. + +**Воспроизведение настоящего класса с mocked repository/upstream:** январь покрыт, +ordinary period=all заблокирован перед загрузкой detail, затем приходит force all. +После release единственный RPC идёт от `2026-02-01T00:00:00.000Z` до +`2026-02-02T12:00:00.000Z`; январь не перечитан. Исправленные исторические значения +остаются прежними. Existing candle suite отдельно проверяет force и coalescing. + +**Минимальное действие:** сохранить forced требование и выполнить полную reconciliation. +Если gap-план уже начат, безопасно перепланировать или выполнить следующую forced +попытку. Простого `force ||= true` после публикации первых окон недостаточно. +Аналог повышения требований есть в CatalogDiscoveryService, но его алгоритм нельзя +механически переносить на staged свечи. + +**Приёмка:** ordinary load + force того же scope действительно обновляют уже +закрытую свечу. Возвращённый work ID завершается только после выполнения forced +требования с актуальным диапазоном запроса. Проверить join до запуска и после +публикации первого обычного окна, включая observer другого пользователя; +экранный запрос получает экранный приоритет. Ошибка следующего forced окна сохраняет +прежнюю историю и timestamps; user/revision fencing сохраняется. +Proof атомарности — существующая SQLite boundary. + +**Риск:** высокий риск неверного локального исправления из-за staged/atomic publication. + +### A06. Payout snapshot замораживает positionIsStale + +**Приоритет:** средний. **Уверенность:** высокая. + +**Место:** [ForecastRepository.resolve, 132–137](src/modules/investments/portfolio/infrastructure/forecast.repository.ts). +**Потребители:** [StockIncomeService, 67–87 и 161–163](src/modules/investments/instruments/stocks/application/stock-income.service.ts), +[BondIncomeService, 180–182](src/modules/investments/instruments/bonds/application/bond-income.service.ts). + +Истечение 60-секундного portfolio TTL меняет isStale, но не source revision/timestamps. +Repository возвращает прежний payload вместо нового candidate. Возможны одновременно +`meta.freshness: stale` и `data.positionIsStale: false`. + +**Воспроизведение:** настоящий repository с mocked transaction; candidate false, +затем true при тех же версиях → повторный результат false, всего одна запись. +Pure payout tests принимают stale input, но не проверяют aging persisted forecast. + +**Минимальное действие:** вычислять возраст сохранённой позиции при выдаче по её +positionAt и действующей freshness policy. Учитывать именно позицию сохранённого +fallback, а не случайно подставлять свежесть более нового несовместимого портфеля. +Не сравнивать весь candidate без разбора: текущий calculatedAt у events вызовет +запись на каждом чтении. Не менять financial revision только из-за хода времени. + +**Приёмка:** controllable clock, TTL истёк без изменения источников, sync=observe: +stale=true, суммы и source timestamps прежние, новых RPC нет; fallback остаётся +согласованным. **Риск:** смешение возраста сохранённой и текущей позиции. + +### A07. Timestamp операций расходится между persistence и wire + +**Приоритет:** средний. **Уверенность:** высокая. + +**Место:** [brokerage-operation.mapper, 85–90](src/modules/investments/operations/infrastructure/brokerage-operation.mapper.ts) +игнорирует nanos; [repository, 653–657 и publish](src/modules/investments/operations/infrastructure/brokerage-operations.repository.ts) +учитывает миллисекунды. Consumers mapper: BrokerageOperationsService, обычный ledger +и income reads (строки 359 и 619 на базе аудита). + +**Воспроизведение:** seconds=1768000000, nanos=600000000 → persistence +`2026-01-09T23:06:40.600Z`, wire `2026-01-09T23:06:40.000Z`. +Запрос from=`…40.500Z` возвращает строку с видимым временем раньше from. +Existing mapper specs проверяют money/quantity, но не это соответствие. + +**Минимальное действие:** согласовать конвертацию protobuf timestamp; при возможности +использовать уже сохранённый occurredAt для wire. Предотвратить дальнейшее расхождение +реализаций без создания универсальной библиотеки дат. + +**Приёмка:** subsecond mapper case и HTTP boundary внутри секунды; порядок/cursor, +обычный ledger и income используют согласованное время. **Риск:** исправляется +публичное значение timestamp; формат и точность Money сохраняются. + +### A08. Configured validation response теряет ошибки полей + +**Приоритет:** средний. **Уверенность:** высокая. + +**Места:** [configureApp, 29](src/main.ts), +[HttpExceptionFilter, 44–48 и 70–78](src/common/filters/http-exception.filter.ts). +ValidationPipe выдаёт message:string[], filter приводит его к ValidationError[] +и ищет constraints. children вложенных ошибок также не обходятся. + +**Configured HTTP reproduction:** invalid email и отсутствующий password: + +```json +{"status":400,"code":"VALIDATION_ERROR","errors":[]} +``` + +Ожидаемый [HTTP contract](.ai/conventions/http-api.md) требует pointer/detail. +Conventions уже фиксируют это известное расхождение. OpenAPI parity проверяет +schema, но не наблюдаемый response pipeline. + +**Минимальное действие:** согласовать exceptionFactory и filter, сформировать +структурированные ошибки, включая вложенные поля, без target/value исходного DTO. + +**Приёмка:** реальные requests credentials и nested Money дают непустые корректные +pointer/detail; status/code/content-type сохраняются; неизвестные исключения +по-прежнему безопасны. Проверить согласование с A02 и публичной metadata. +**Риск:** public error shape и корректность вложенных путей. + +### A09. Planner зависит от service barrel и образует runtime import cycle + +**Приоритет:** средний. **Уверенность:** высокая. + +**Места:** [investment-sync-plan, 3–6](src/modules/investments/investment-sync-plan.ts) +→ [operations.api](src/modules/investments/operations/operations.api.ts) +→ operations services → investment-sync-plan. + +Алгоритму нужны interval rules, но barrel загружает services, TbankClient и Prisma. +[Planner spec, строка 1](src/modules/investments/investment-sync-plan.spec.ts) уже +вынужден mock Prisma. Это доказанная стоимость связности; production crash из-за +цикла не обнаружен. Type-only цикл T-Bank records — отдельный безвредный случай. + +**Минимальное действие:** предоставить interval rules чистый публичный вход, +аналогичный разрешённому integrations/tbank/decimal.ts; service API оставить отдельно. +Не заменять cycle на forwardRef и не вводить новый DI слой. + +**Приёмка:** planner импортируется/тестируется без Prisma mock; cycle устранён, +interval semantics и DI initialization сохранены. **Риск:** низкий, import/export surface. + +### A10. Неиспользуемый legacy T-Bank access flow + +**Приоритет:** низкий. **Уверенность:** высокая. + +**Место:** [TbankAccessService, 255–330](src/integrations/tbank/tbank-access.service.ts). +listSupportedAccounts/withSupportedAccounts/withConnection/withAccount/ +withAccountForConnection/private connection вызываются только внутри собственной +цепочки и tests. Проверены production consumers, DI и decorators. + +Legacy flow поддерживает generation=hash(ciphertext), тогда как живой persisted +flow использует DB revision, local и tokenForAttempt. Поддерживаются две модели +без production-потребности; около 76 строк, context types и TbankClient dependency. + +**Минимальное действие:** повторно проверить callers на актуальном HEAD, удалить +legacy-цепочку, неиспользуемые context exports и зависимость; отдельно проверить +оставшихся consumers accessibleTbankAccount до его удаления. + +**Приёмка:** нет мёртвой цепочки, живые access/invalid-token/revision/fencing cases +сохранены. Перенести полезные security assertions, если они были только в удаляемых +specs. **Риск:** удалить общее правило или полезную проверку вместе с legacy. + +## Локальные упрощения + +Все пункты: низкий приоритет, высокая уверенность. Перед удалением повторить поиск +callers; отсутствие production callers в базе аудита не гарантирует его на новом HEAD. + +| ID | Место и доказательство | Минимальное действие и приёмка | +| --- | --- | --- | +| C01 | [portfolio.service, 515–522](src/modules/investments/portfolio/application/portfolio.service.ts): локальный decimalFromString повторяет common parser без validation; callers position/value | Импортировать существующий common owner; удалить дубликат. Exact-money tests и typecheck. Текущая ошибка сумм не доказана; проверить допустимые входы. | +| C02 | [tbank.api](src/integrations/tbank/tbank.api.ts): TbankSyncAttempt импортируют напрямую семь внешних файлов sync/instruments; enrichment также обходит API для market types | Экспортировать используемые типы, перенаправить imports через API. TbankCoupon уже экспортируется. Typecheck и поиск обходов; runtime-риск минимален. | +| C03 | [auth.service, 190–192](src/modules/auth/application/auth.service.ts): normalizeEmail возвращает аргумент; register/login вызывают его, реальная нормализация в CredentialsDto | Удалить no-op, сохранить DTO transform. Existing HTTP case/whitespace tests; дополнительную нормализацию без необходимости не вводить. | +| C04 | [bank-card.calculations, 56–76](src/modules/banking/bank-cards/domain/bank-card.calculations.ts): string period используется только tests; production summary передаёт from/to | Сузить API до рабочего диапазона; перевести полезные unit cases. Проверить границы месяца/range. | +| C05 | [instrument-market, 39–51](src/modules/investments/instruments/application/instrument-market.ts): optional без consumers; [stock-position wrapper](src/modules/investments/instruments/stocks/application/stock-position.ts) нужен только своему spec | Удалить optional, перенаправить полезные wrapper tests на instrumentPosition. Сохранить fractional/mixed/offset holdings cases. | +| C06 | [portfolio.api, 2](src/modules/investments/portfolio/portfolio.api.ts): totalsByCurrency не имеет внешнего production consumer; внутренний использует mapper напрямую | Удалить только лишний export, mapper сохранить; typecheck. | +| C07 | [auth.controller.spec, 9–20](src/modules/auth/presentation/auth.controller.spec.ts): проверяет HTTP_CODE_METADATA через prototype; те же 200 проверяются configured HTTP suite | Удалить дублирующий wiring spec только при сохранении реальных HTTP status/cookie assertions. | + +## Карта ответственности и проверенные сценарии + +| Область / владелец | Интерфейс и consumers | Состояние, зависимости, инварианты и эффекты | +| --- | --- | --- | +| App/config/common/Prisma | Composition root, configureApp, Money/decimal, logging | Env validation, HTTP pipeline, DB lifecycle, request context; общая арифметика без Nest/Prisma | +| Auth | Auth HTTP; guard/AccessPayload API для protected controllers | User/Session, bcrypt/JWT, refresh hash/CAS; cookies у controller | +| Bank cards/operations/categories | BankCardsController → профильные services | Ownership, archive/version/replay/tombstones; refund budget и перевод в общей transaction boundary | +| Bank summary | Controller → use case → repository → pure calculation | Согласованное чтение и BigInt-сводка по периоду | +| Brokerage accounts | HTTP/read API для screen → service → PortfolioService | Собственной persistence нет; missing membership отличается от confirmed empty | +| Portfolio/forecast | HTTP и API для instruments/events/accounts | Private user/revision/account snapshots; mapping/decimal; aggregate completeness; atomic dependency version checks | +| Operations | HTTP/API для income/screen | Local journal, signed cursor binding; durable work/staging, intervals, recovery, atomic reconciliation | +| Income analytics | HTTP/read API → Operations API | Period roles/completeness, grouping и точные signed totals по валютам; своей DB нет | +| Upcoming events | HTTP/read API → portfolio/calendars → forecast | Events по positions; missing calendar не равен пустому; redemption исключён из income totals | +| Catalog discovery/enrichment | Stock/bond services и public source APIs | Shared identity/aliases/source rows; private work, TTL, batching, coalescing и generation-fenced publication | +| Candles | Market services/screen → CandleService/Repository | Shared candles/coverage; mutable tail, gaps, windows, очередь и staged force replacement | +| Stock/bond position/income | Controllers → application services | UID/type matching, exact weighted cost, mixed currencies, payout estimates; Portfolio/Catalog/Forecast APIs | +| T-Bank connection | Guarded GET/PUT/DELETE | Проверка токена до replacement, AES-GCM, новая revision/cascade; failed validation сохраняет connection | +| T-Bank client/access/membership/metadata | Integration API для business modules | gRPC/TLS, единые limiter/retry/deadline; credentials, account access, membership и publication fencing | +| Screen sync | POST descriptor / GET status | Orchestration через APIs; durable children/attempts; passive observation | +| MOEX/NCR | Integration APIs → enrichment | ISS parser/pagination; NCR strict registry parser/exact ISIN/shared cache | + +Прослежены auth/session flows; создание/изменение карт, операций, переводов, +возвратов и summary; ledger→sync→staging→publication; portfolio→mapping→snapshot; +payouts/events→forecast; catalog/candles и screen orchestration. + +Повторной регистрации владельцев caches/queues и циклов Nest modules не обнаружено. +InstrumentSourcesModule позволяет portfolio пользоваться источниками без DI-цикла +с InstrumentsModule. Type-only цикл T-Bank records не является finding. + +## Размеры production-файлов + +Количество строк включает comments/blank lines. Размер — основание для чтения, +а не самостоятельный дефект. Обязательные splits не обоснованы. + +| Файл | Строк | Решение и причина | +| --- | ---: | --- | +| [brokerage-operations.repository](src/modules/investments/operations/infrastructure/brokerage-operations.repository.ts) | 924 | Оставить: overlap groups, staging, recovery и publication держат общий transactional invariant | +| [catalog-enrichment.service](src/modules/investments/instruments/application/catalog-enrichment.service.ts) | 665 | Упростить локально: protocol boundary/imports; сохранить producers, batching, completions вместе | +| [brokerage-operations.service](src/modules/investments/operations/application/brokerage-operations.service.ts) | 636 | Оставить: ledger/income имеют различную completeness; generic builder добавит скрытые режимы | +| [tbank-sync.service](src/modules/investments/tbank-sync/tbank-sync.service.ts) | 595 | Упростить: разрешение successors вернуть operations owner, generic job framework не нужен | +| [tbank.client](src/integrations/tbank/tbank.client.ts) | 560 | Оставить: records/RPC methods и единый limiter/retry owner | +| [portfolio.service](src/modules/investments/portfolio/application/portfolio.service.ts) | 522 | Упростить: удалить duplicate parser, scheduling state не разрывать | +| [candle.service](src/modules/investments/instruments/application/candle.service.ts) | 481 | Оставить: исправить force join, сохранить queue/publication invariant | +| [catalog-discovery.repository](src/modules/investments/instruments/infrastructure/catalog-discovery.repository.ts) | 480 | Оставить: identity/aliases/source publication и CAS связны | +| [catalog-discovery.service](src/modules/investments/instruments/application/catalog-discovery.service.ts) | 390 | Оставить: search/detail и producer lifecycle имеют одного owner | +| [bank-operations.service](src/modules/banking/bank-cards/application/bank-operations.service.ts) | 343 | Оставить: разрыв validation/refund/transfer writes повышает риск | +| [tbank-access.service](src/integrations/tbank/tbank-access.service.ts) | 331 | Упростить удалением legacy flow | +| [investment-sync-plan](src/modules/investments/investment-sync-plan.ts) | 327 | Упростить зависимость: убрать service barrel из алгоритма | +| [instrument-metadata.repository](src/integrations/tbank/instrument-metadata.repository.ts) | 320 | Оставить: identity, batching и publication согласованы | +| [income-analytics.service](src/modules/investments/income/income-analytics.service.ts) | 318 | Оставить: связные projections, выгода дополнительных моделей не доказана | +| [candle.repository](src/modules/investments/instruments/infrastructure/candle.repository.ts) | 306 | Оставить: reconciliation, coverage, fencing и stage cleanup | +| [upcoming-events.service](src/modules/investments/upcoming-events/upcoming-events.service.ts) | 297 | Оставить: один сценарий, основной дефект у MOEX boundary | +| [brokerage-operations-sync.service](src/modules/investments/operations/application/brokerage-operations-sync.service.ts) | 294 | Оставить: связный владелец durable work execution | + +Отдельные категории: 37 unit specs / 3319 строк; 21 HTTP/SQLite specs / 7878 строк; +10 test support/fixtures / 1993 строки; 6 vendor proto / 3588 строк; +5 SQL migrations / 345 строк. Generated Prisma и build artifacts не ранжировались. + +## Удачные решения, которые требуется сохранить + +- BigInt/decimal и string Money; разные валюты не складываются неявно. +- Refresh CAS, ownership queries, версии операций, replay fingerprints/tombstones. +- Network calls отделены от DB transactions. +- Revision/attempt fencing, late-result discard, staging перед force reconciliation. +- Missing/partial/confirmed empty/saved complete различаются явно. +- Concrete providers, компактные плоские модули и разрешённый прямой Prisma + в связных CRUD/transaction services не являются автоматическими дефектами. +- SQLite behavior tests проверяют реальные денежные границы, races и rollback. + +## Выполненные проверки и пределы доказательства + +Перед успешными Node-командами загружалась `~/.nvm/nvm.sh`, затем `nvm use --silent`. +Первый вызов nvm без загрузки shell function завершился command not found; +после загрузки проверок это ограничение не осталось. + +| Команда / проверка | Результат аудита | +| --- | --- | +| git status, git rev-parse HEAD, git diff --check | Чистое дерево, неизменный commit, diff errors отсутствуют | +| npm run lint | Успешно | +| npm run typecheck:app; npm run typecheck:test | Успешно | +| npm test -- --runInBand | 37 suites / 200 tests passed | +| Build в npm run test:e2e | Prisma generation и Nest build успешны | +| Полный e2e в sandbox | listen EPERM на localhost; не засчитан | +| LOG_LEVEL=error node test/sqlite.cjs --all, с разрешением localhost | all-accounts: 18 passed; auth-suite затем завершилась native SIGABRT | +| node test/sqlite.cjs tbank-screen-sync.e2e-spec.ts tbank-operations-persistence.e2e-spec.ts tbank-candles-persistence.e2e-spec.ts tbank-metadata-persistence.e2e-spec.ts tbank-local-reads.e2e-spec.ts openapi-contract.e2e-spec.ts | Первые три: 27 + 5 + 9 passed; metadata native abort; последующие не достигнуты | +| node test/sqlite.cjs openapi-contract.e2e-spec.ts, отдельно | 3 tests passed | +| Inline reproductions | Подтверждены описанные случаи; validation/logging через configureApp, остальные с указанными mocked boundaries | + +Native abort произошёл в `node::RemoveEnvironmentCleanupHook` / `better_sqlite3.node` +на Node 24.21.0. Причина не установлена; связывать его с конкретной source-находкой +нельзя. Полный e2e gate не подтверждён. При реализации сначала повторить в штатном +окружении; если проблема остаётся, диагностировать отдельно и явно сообщить, +не отключая падающие suites и не выдавая неполную проверку за зелёную. + +Логи текущей сессии (временные файлы, могут исчезнуть): +`/tmp/wealthledger-audit-e2e.log`, `/tmp/wealthledger-audit-targeted-e2e.log`, +`/tmp/wealthledger-audit-openapi.log`. Документ не зависит от сохранности этих файлов. + +**Проверено:** все production-модули, composition/API, основные consumers, +значимые flows, связанные tests кандидатов. Все production auth/banking, +integrations/sync/operations и остальные investment slices прочитаны. + +**Частично проверено:** весь набор HTTP fixtures и integration suites не проходил +одинаково глубокий построчный аудит; реальное выполнение e2e ограничено указанным +native abort. Schema прочитана; история миграций не проходила полный compatibility audit. + +**Не проверено:** clean npm ci, полный verify с coverage как единая команда, +durability/restart/backup, container/deployment, production performance, +реальные внешние API и полная сверка vendor proto с upstream. + +### Финальная проверка реализации + +Использовать актуальные scripts/conventions backend. На базе аудита для code changes +требуются npm ci, npm run verify, relevant SQLite/HTTP checks; для этих HTTP изменений +также OpenAPI generation/parity. Полный npm run test:e2e нужен для итоговой проверки +совокупных изменений. Перед Node — nvm use --silent. + +Автоисправления lint/format не являются доказательством. Не запускать миграции, +reset/drop или tests на пользовательской БД. Штатный SQLite runner создаёт временные +базы и защищает от использования внешнего DATABASE_URL. + +По завершении повторить routing по staged/unstaged/new files, выполнить Boy Scout +и semantic review: callers, owners, money/access/atomicity/error contracts, +generation/cache/async lifecycle. Указать точные команды, outcomes и ограничения. + +## Неподтверждённые гипотезы — вне scope исправлений + +- Logout/refresh interleaving и browser cookie overwrite требуют явных ordering + semantics и отдельного HTTP/browser proof. +- N+1 в карточных views виден, но измеренная latency/timeout проблема не установлена. +- JSON casts внутренних сохранённых payload сами по себе не доказывают небезопасную boundary. +- Cash/accrued-interest valuation: локального контракта недостаточно для обвинения в ошибке. +- Native SQLite abort требует отдельной диагностики runtime/dependency lifecycle. + +## Статус исправлений + +Начало исправлений: HEAD `516e43203eb3fee8e6a0c7018be2fee11f1b900c`, +ветка `codex/investment-contracts`; `git status --short`: только +`?? BACKEND_AUDIT.md`, staged/unstaged tracked changes отсутствовали. +Исходные разделы аудита сохранены. `npm ci` успешно: 1065 packages, 44s; +manifest/lockfile не менялись. Перед Node-командами загружается `~/.nvm/nvm.sh` +и выполняется `nvm use --silent`. + +Все A01–A10 и C01–C07 повторно подтверждены и реализованы в порядке пакетов. +Статусы ниже разделяют готовность исправления и полноту проверки: native abort +не засчитывается как успешный behavioral test. Неподтвердившихся и уже исправленных +на исходном HEAD пунктов не обнаружено. + +| ID | Статус | Изменение / доказательство | Проверки и ограничения | +| --- | --- | --- | --- | +| A01 | Исправлено и проверено | Logout подавляет только JWT verification failure; refresh удаляет cookie только при UnauthorizedException. Configured HTTP red: logout 204 вместо 500, refresh удалил cookie при отказе БД. | `node test/sqlite.cjs app.e2e-spec.ts`: regression воспроизведена на временной SQLite; macOS green повтор и первый полный e2e столкнулись с native SIGABRT. Итоговый Linux e2e: auth 12/12 passed, включая revocation failure и сохранение cookie для retry. | +| A02 | Исправлено и проверено | Удалены raw stack и его неиспользуемый transport/allowlist; regression проверяет multiline synthetic secret в конечном Pino payload и correlation. | Targeted filter/logging: 3 suites / 4 tests passed, unit red → green; macOS configured HTTP runner завершился native SIGABRT; итоговый Linux auth 12/12 passed, включая настоящий filter → middleware → Pino payload. | +| A03 | Исправлено и проверено | MoexClient отклоняет отсутствующий/повреждённый block, неверную ширину row, malformed/nonprogressing cursor. Пустые блоки и 404 сохранены. | Unit red 12 failures → 21 passed; `node test/sqlite.cjs upcoming-events.e2e-spec.ts`: downstream red 2 failures → 18 passed, настоящий client + mock fetch сохраняют calendar/forecast payload/revisions/timestamps. Typecheck и scoped lint passed. | +| A04 | Исправлено и проверено | Screen status разрешает superseded child через operations.observe перед агрегацией state/progress; attempt/revision fencing сохранён. | `node test/sqlite.cjs tbank-screen-sync.e2e-spec.ts`: HTTP/SQLite red succeeded вместо running → 29 passed; successor success/failure, progress, чужой user и удалённая revision. | +| A05 | Исправлено и проверено | Каждый join повышает force/priority при более сильном требовании; идентичный join не меняет updatedAt. Load фиксирует force/now на проход, при новой forced потребности выполняет полную reconciliation. Финальная публикация и observers сериализованы с reservations; screen priority=0. | Service unit red → green (полный исторический диапазон и более поздний now); `node test/sqlite.cjs tbank-candles-persistence.e2e-spec.ts`: 14 passed на финальном коде, same/cross-user join до загрузки и после обычного окна, rollback второго forced окна, timestamps и fencing. Native abort в первом финальном запуске; единственный повтор без изменений прошёл. | +| A06 | Исправлено и проверено | ForecastService вычисляет возвращаемый positionIsStale из сохранённого positionAt через существующую portfolio TTL policy; не меняет финансовый snapshot. | `node test/sqlite.cjs upcoming-events.e2e-spec.ts`: red stock/bond false на TTL → 20 passed; TTL−1/TTL, observe без RPC, прежние суммы/revisions/timestamps, fallback при несовместимом свежем candidate. Review дополнительно воспроизвёл meta.freshness=fresh для старого fallback (2 red); freshForMs теперь учитывает сохранённый stale flag, repeat 20 passed, stateVersion рассчитывается штатно. | +| A07 | Исправлено и проверено | Одна operationTimestamp conversion у mapper для stage/publish; ledger/income wire используют сохранённый occurredAt. Missing seconds по-прежнему отклоняется. | Unit red .600→.000 → 9 passed; `node test/sqlite.cjs tbank-ledger-local.e2e-spec.ts`: HTTP red → 11 passed (from=.500, cursor .800/.600, income timestamp/exact total). Оба typecheck/lint passed. На macOS operations-persistence завершилась native SIGABRT и не засчитана. Итоговый Linux полный e2e: ledger 11/11 и operations-persistence 5/5 passed. | +| A08 | Исправлено и проверено | Global/screen ValidationPipe сохраняют structured errors без target/value; filter обходит children и экранирует JSON pointers. Удалён descriptorErrors; OpenAPI errors.items согласован с object shape. | Nested Money HTTP red errors:[] → bank22 / screen29 passed; escaped pointer и отсутствие дублей/secret проверены. Unit7 и scoped lint passed. Metadata red items:string → финальный OpenAPI parity 3 passed. На macOS auth suite native SIGABRT; итоговый Linux auth 12/12 passed, включая credential field errors без input leak. | +| A09 | Исправлено и проверено | Planner импортирует существующий чистый operations/domain/operations-sync-plan как явно документированный публичный вход; service barrel больше не экспортирует runtime interval rules. | Planner и CandleService tests выполняются без Prisma mock; targeted пакет 7 suites / 49 tests passed. DI подтверждается configured HTTP и итоговым build. | +| A10 | Исправлено и проверено | Удалены шесть legacy methods, context types, TbankClient constructor dependency и accessibleTbankAccount без живых callers; legacy-only spec удалён. | Поиск src/test не находит legacy symbols; 5 unit suites / 11 tests, оба typecheck, scoped lint/build; connection HTTP/SQLite4 passed: missing/invalid/unsupported/cross-user, replacement/deletion, revision/fencing. Token error/crypto assertions сохранены. MacOS metadata-persistence15 passed, operations-sync/catalog-discovery native SIGABRT. Итоговый Linux e2e: metadata15, operations-sync17 и catalog-discovery10 passed. | +| C01 | Исправлено и проверено | PortfolioService использует common decimalFromString; локальный parser удалён. Входы из money/formatDecimal уже canonical; текущая ошибка сумм не заявляется. | Exact-money portfolio tests в targeted49 passed; итоговые types/verify ниже. | +| C02 | Исправлено и проверено | TbankSyncAttempt/TbankLastPrice экспортируются type-only через tbank.api; семь production consumers используют API, TbankCoupon переиспользован. | Поиск прямых imports tbank-access.service/tbank-market.types в src/modules пуст; оба typecheck и scoped lint passed. | +| C03 | Исправлено и проверено | Удалён no-op normalizeEmail; CredentialsDto transform trim/lowercase сохранён. | Auth unit passed; реальные case/whitespace HTTP assertions сохранены. После macOS native SIGABRT полный Linux auth gate прошёл: 12/12. | +| C04 | Исправлено и проверено | bankSummary принимает только рабочий диапазон from/to; строковый month overload удалён, полезные unit cases переведены на диапазоны. | Targeted49 passed: month start/end, outside range, signed64/BigInt суммы и refunds. | +| C05 | Исправлено и проверено | Удалены optional без callers и stockPosition wrapper; spec перенесён к instrumentPosition, проверяет напрямую владельца. | Targeted49 passed: fractional/large quantity, weighted costs, mixed/unknown currencies, no position и offset long/short. | +| C06 | Исправлено и проверено | Удалён неиспользуемый внешний totalsByCurrency export; живой mapper и внутренний caller сохранены. | Поиск imports подтверждает единственный внутренний caller; exact-money targeted tests passed, итоговый typecheck ниже. | +| C07 | Исправлено и проверено | Удалён HTTP_CODE_METADATA wiring spec; реальные register/login/refresh status200 и logout/cookie assertions сохранены в app.e2e. | Проверены callers/оставшиеся HTTP assertions; полный Linux auth gate прошёл: 12/12. Предыдущий macOS native SIGABRT остаётся отдельным ограничением среды. | + +### Gates первого прогона (macOS) + +Все команды выполнены из backend на Node `v24.21.0` после `nvm use --silent`. +Проверки данных использовали штатный runner, fresh temporary SQLite и штатные +migrations; рабочая БД не использовалась. Для localhost/Prisma cache запуски +получили разрешение вне sandbox; native abort повторялся и там. + +| Команда | Итог | +| --- | --- | +| `npm ci` | PASS; manifest и lockfile без изменений. | +| `npm run verify` | PASS: lint, app/test typecheck, 36 suites / 209 unit tests с coverage, build. | +| `npm run openapi:generate` | PASS; актуальный игнорируемый `openapi.json` сформирован. | +| `npm run test:openapi-contract` | PASS: 1 suite / 3 tests, включая object shape validation errors. | +| `npx prisma validate` | PASS. Schema и migrations не менялись. | +| `npm run test:durability -- /tmp/wealthledger-fix-baseline-current-runtime-fjftdF` | PASS. Baseline `0862d56`, до последней T-Bank migration, отдельно установлен и собран на текущем Node; результат `/var/folders/j_/h2ft23gd3qq35ps3v5bcftfm0000gn/T/wealthledger-bank-durability-SIeCq0`. | +| `LOG_LEVEL=error npm run test:e2e` | INCOMPLETE, exit 1: all-accounts 18/18 passed, затем app.e2e native SIGABRT до Jest summary. Остальные suites общий gate не выполнил. | +| `node test/sqlite.cjs tbank-candles-persistence.e2e-spec.ts` | PASS на финальном коде: 14/14 после одного native abort и повтора без edits. | +| `node test/sqlite.cjs tbank-metadata-persistence.e2e-spec.ts` | PASS на финальном коде: 15/15. | +| `node test/sqlite.cjs tbank-operations-sync.e2e-spec.ts` | INCOMPLETE: native SIGABRT до summary. | +| `node test/sqlite.cjs tbank-operations-persistence.e2e-spec.ts` | INCOMPLETE: native SIGABRT до summary. | +| `node test/sqlite.cjs tbank-catalog-discovery.e2e-spec.ts` | INCOMPLETE: native SIGABRT до summary. | +| `git diff --check` | PASS. | + +Логи итоговых gates: `/tmp/wealthledger-fix-verify.log`, +`/tmp/wealthledger-fix-openapi-generate.log`, `/tmp/wealthledger-fix-openapi-contract.log`, +`/tmp/wealthledger-fix-prisma-validate.log`, `/tmp/wealthledger-fix-durability.log`, +`/tmp/wealthledger-fix-final-e2e.log`. Дополнительные SQLite логи: +`/tmp/wealthledger-fix-a05-latest-sqlite-retry.log`, +`/tmp/wealthledger-fix-final-metadata-persistence.log`, +`/tmp/wealthledger-final-operations-sync.log`, +`/tmp/wealthledger-final-operations-persistence.log`, +`/tmp/wealthledger-fix-final-catalog-discovery.log`. + +Native failure: assertion `(env) != nullptr` в `node::RemoveEnvironmentCleanupHook`, +stack содержит destructor из `better_sqlite3.node`. Это конкретное ограничение +текущего native runtime, уже наблюдавшееся при исходном аудите; его первопричина +в рамках исправлений не установлена. Тесты не отключались, runner/dependencies +не изменялись. Эти macOS запуски остаются INCOMPLETE. Полный Linux прогон ниже +закрыл e2e gate и auth/operations/catalog acceptance; локальный native abort +на macOS при этом не исправлен. + +### Повторная полная проверка (Linux, 2026-09-27) + +**PASS: 21 e2e suites / 262 tests, exit 0**, ни одного skipped/disabled test. +Выполнена вся штатная `npm run test:e2e`: build и `node test/sqlite.cjs --all`. +Каждая suite получила отдельную temporary SQLite со всеми пятью migrations. +Закрыты прежде неполные auth12, operations-persistence5, operations-sync17, +catalog-discovery10; также прошли candles14, forecast/upcoming20, screen29, +OpenAPI3 и большой read-baseline с 300 000 операций / 1 000 000 свечей. + +Перед повтором зафиксирован тот же HEAD и hashes всех tracked/untracked files; +до завершения тестов ни один исходник, тест, config или audit не менялся. +Поэтому предыдущие `verify` (209 unit tests), OpenAPI и durability сохраняют +актуальность. В этом повторе изменён только статус данного документа. + +MacOS `LOG_LEVEL=error node test/sqlite.cjs app.e2e-spec.ts` снова завершился +native SIGABRT. Для Linux использован существующий Dockerfile target +`development`, Node `v24.21.0` (проверен против `.nvmrc`) и неизменённый lockfile: +SHA-256 `f986979b7922d684fead3a4d32532d11f1e469cec0e35669509343be41cbb2cd`. +Image `wealthledger-backend:audit-verification-20260927`, +ID `sha256:69264cf3dba23566d99fded450104bbe56f92755604e88232beb97825155ae73`. +Сборка выполнила `npm ci`; зависимости и runner не подменялись. Test/scripts +подключались read-only, внешняя сеть отключена, host ports не публиковались; +одноразовый контейнер удалён после завершения, рабочая БД не подключалась. + +Команды воспроизведения из backend (на host предварительно `source ~/.nvm/nvm.sh` +и `nvm use --silent`; внутри image закреплён тот же Node): + +```bash +docker build --target development --tag wealthledger-backend:audit-verification-20260927 . +docker run --rm --network none \ + --mount type=bind,source=/Users/ksv741/Project/vibe/WealthLedger/backend/test,target=/app/test,readonly \ + --mount type=bind,source=/Users/ksv741/Project/vibe/WealthLedger/backend/scripts,target=/app/scripts,readonly \ + -e LOG_LEVEL=error wealthledger-backend:audit-verification-20260927 npm run test:e2e +``` + +Логи: `/tmp/wealthledger-recheck-auth.log` (macOS abort), +`/tmp/wealthledger-recheck-linux-build.log` (Linux image build), +`/tmp/wealthledger-recheck-linux-e2e.log` (полный PASS). + +Read-only диагностика локализовала macOS crash в GC finalizer Statement/Database +вложенного `better-sqlite3 12.11.1`, используемого Prisma adapter 7.10.0. +Node 24.21 ObjectWrap destructor вызывает RemoveEnvironmentCleanupHook, который +падает на `env != nullptr`. Связь с Jest VM context и происхождением native +binary остаётся гипотезой, а не доказанной первопричиной. Ошибка application +lifecycle не обнаружена: close/disconnect ожидаются. Linux PASS доказывает +поведение backend в этом окружении, но не устранение native crash на macOS. + +### Semantic / Boy Scout review и состояние дерева + +Повторный routing по staged/unstaged/new files активировал Architecture, +HTTP/errors, Data, Testing, Security, Integrations и Observability. Проверены +owners/callers и связанная документация; независимый review охватил auth/logging, +candle coalescing/publication, forecast fallback и cleanup. После устранения +найденной при review stale metadata у старого forecast fallback оставшихся +semantic замечаний нет. Проверены точная денежная арифметика, ownership, +transactions, generation fencing, сохранение cache/pending promises и отсутствие +новых дублирующих helpers. + +На момент завершения проверки, до запроса публикации, HEAD оставался +`516e43203eb3fee8e6a0c7018be2fee11f1b900c`, ветка +`codex/investment-contracts`; staged files отсутствуют. Все изменения локальные, +без commit/push/PR. Новые untracked files: исходный `BACKEND_AUDIT.md`, +`src/modules/investments/instruments/application/candle.service.spec.ts` и +`src/modules/investments/instruments/application/instrument-position.spec.ts`. +Остальные изменения — tracked source/tests и три связанных convention-файла. +Новые зависимости, scripts, migrations и разделение production-файлов не добавлены. +Исходный текст до раздела «Статус исправлений» побайтово совпадает со снимком +`/tmp/wealthledger-backend-audit-before-fixes.md`. + +### Подготовка публикации + +После успешной полной проверки пользователь разрешил commit, PR и merge. +Ветка публикации — `codex/backend-audit-fixes` от актуального `origin/develop` +`5d3835e`; его tree совпадает с исходным HEAD проверки, поэтому исходники и +результаты gates не изменились. Pre-commit review не обнаружил посторонних +файлов, секретов или незакрытых semantic findings. PR направляется в `develop`; +merge выполняется после обязательных `verify`, `security`, `commits`. diff --git a/src/common/decorators/api-safe-error-response.decorator.ts b/src/common/decorators/api-safe-error-response.decorator.ts index f62ab49..d9f8947 100644 --- a/src/common/decorators/api-safe-error-response.decorator.ts +++ b/src/common/decorators/api-safe-error-response.decorator.ts @@ -15,6 +15,12 @@ export class ProblemDetailsDto { @ApiProperty({ required: false, + type: 'array', + items: { + type: 'object', + required: ['pointer', 'detail'], + properties: { pointer: { type: 'string' }, detail: { type: 'string' } }, + }, example: [{ pointer: '#/email', detail: 'must be a valid email address' }], }) errors?: { pointer: string; detail: string }[]; diff --git a/src/common/filters/http-exception.filter.spec.ts b/src/common/filters/http-exception.filter.spec.ts index f916897..0a10828 100644 --- a/src/common/filters/http-exception.filter.spec.ts +++ b/src/common/filters/http-exception.filter.spec.ts @@ -14,7 +14,10 @@ describe('HttpExceptionFilter', () => { }), } as unknown as ArgumentsHost; - filter.catch(new Error('database password: secret'), host); + filter.catch( + new Error('operation failed\npassword: SYNTHETIC_AUDIT_SECRET'), + host, + ); expect(status).toHaveBeenCalledWith(500); expect(type).toHaveBeenCalledWith('application/problem+json'); @@ -23,7 +26,6 @@ describe('HttpExceptionFilter', () => { code: 'INTERNAL_SERVER_ERROR', }); expect(locals.publicErrorCode).toBe('INTERNAL_SERVER_ERROR'); - expect(typeof locals.errorStack).toBe('string'); - expect(locals.errorStack).not.toContain('database password: secret'); + expect(locals.errorStack).toBeUndefined(); }); }); diff --git a/src/common/filters/http-exception.filter.ts b/src/common/filters/http-exception.filter.ts index 5962da6..0703a83 100644 --- a/src/common/filters/http-exception.filter.ts +++ b/src/common/filters/http-exception.filter.ts @@ -27,14 +27,6 @@ export class HttpExceptionFilter implements ExceptionFilter { ? 'VALIDATION_ERROR' : this.codeFor(status, payload); (response.locals ??= {}).publicErrorCode = code; - if ( - status >= Number(HttpStatus.INTERNAL_SERVER_ERROR) && - exception instanceof Error - ) - response.locals.errorStack = exception.stack - ?.split('\n') - .slice(1) - .join('\n'); response .status(status) .type('application/problem+json') @@ -69,12 +61,18 @@ export class HttpExceptionFilter implements ExceptionFilter { private validationErrors( errors: ValidationError[], + prefix = '#', ): { pointer: string; detail: string }[] { - return errors.flatMap((error) => - Object.values(error.constraints ?? {}).map((detail) => ({ - pointer: `#/${error.property}`, - detail, - })), - ); + return errors.flatMap((error) => { + if (!error || typeof error.property !== 'string') return []; + const pointer = `${prefix}/${error.property.replaceAll('~', '~0').replaceAll('/', '~1')}`; + return [ + ...Object.values(error.constraints ?? {}).map((detail) => ({ + pointer, + detail, + })), + ...this.validationErrors(error.children ?? [], pointer), + ]; + }); } } diff --git a/src/common/logging/request-logging.middleware.ts b/src/common/logging/request-logging.middleware.ts index fdfaf04..cb6952c 100644 --- a/src/common/logging/request-logging.middleware.ts +++ b/src/common/logging/request-logging.middleware.ts @@ -42,9 +42,6 @@ export class RequestLoggingMiddleware implements NestMiddleware { ...(response.locals.publicErrorCode ? { code: response.locals.publicErrorCode as string } : {}), - ...(response.locals.errorStack - ? { stack: response.locals.errorStack as string } - : {}), }; const level = response.statusCode >= httpStatus.HTTP_STATUS_INTERNAL_SERVER_ERROR diff --git a/src/common/logging/structured-logger.service.ts b/src/common/logging/structured-logger.service.ts index 7968da5..6ec49cf 100644 --- a/src/common/logging/structured-logger.service.ts +++ b/src/common/logging/structured-logger.service.ts @@ -17,7 +17,6 @@ const allowedFields = new Set([ 'status', 'durationMs', 'code', - 'stack', 'operation', 'grpcCode', 'attempts', diff --git a/src/integrations/moex/moex.client.spec.ts b/src/integrations/moex/moex.client.spec.ts index bc01d6a..46c2e58 100644 --- a/src/integrations/moex/moex.client.spec.ts +++ b/src/integrations/moex/moex.client.spec.ts @@ -1,5 +1,11 @@ import { MoexClient } from './moex.client'; +const emptyEvents = { + coupons: { columns: ['coupondate', 'value', 'faceunit'], data: [] }, + offers: { columns: ['offerdate'], data: [] }, + description: { columns: ['name', 'value'], data: [] }, +}; + describe('MoexClient', () => { afterEach(() => jest.restoreAllMocks()); it('uses effective yield only for the exact ISIN and board and preserves its own price and date', async () => { @@ -92,17 +98,174 @@ describe('MoexClient', () => { expect(result?.value ?? null).toBe(value === null ? null : '0'); }, ); - it('treats a successful response without the requested block as no events', async () => { - const fetchMock = jest.spyOn(global, 'fetch').mockResolvedValue({ - ok: true, - json: () => - Promise.resolve({ - description: { columns: ['name', 'value'], data: [] }, - boards: { columns: ['secid'], data: [] }, - }), - } as Response); - + it('accepts confirmed empty blocks', async () => { + jest + .spyOn(global, 'fetch') + .mockImplementation(() => Promise.resolve(Response.json(emptyEvents))); await expect(new MoexClient().bondEvents('BOND')).resolves.toEqual([]); - fetchMock.mockRestore(); }); + + it('preserves not-found semantics independently of malformed HTTP 200', async () => { + jest + .spyOn(global, 'fetch') + .mockImplementation(() => + Promise.resolve(new Response(null, { status: 404 })), + ); + const client = new MoexClient(); + await expect(client.bondEvents('MISSING')).resolves.toEqual([]); + await expect( + client.bondYield('MISSING', 'ISIN', 'TQOB'), + ).resolves.toBeNull(); + }); + + it.each([ + ['missing block', { coupons: undefined }], + ['missing columns', { coupons: { data: [] } }], + ['invalid column', { coupons: { columns: [null], data: [] } }], + ['invalid row', { coupons: { columns: ['coupondate'], data: [null] } }], + [ + 'short row', + { coupons: { columns: ['coupondate', 'value'], data: [['2099-02-01']] } }, + ], + [ + 'long row', + { coupons: { columns: ['coupondate'], data: [['2099-02-01', 2]] } }, + ], + [ + 'empty cursor', + { + 'coupons.cursor': { columns: ['INDEX', 'TOTAL', 'PAGESIZE'], data: [] }, + }, + ], + [ + 'missing cursor field', + { 'coupons.cursor': { columns: ['INDEX', 'TOTAL'], data: [[0, 1]] } }, + ], + [ + 'null cursor index', + { + 'coupons.cursor': { + columns: ['INDEX', 'TOTAL', 'PAGESIZE'], + data: [[null, 0, 1]], + }, + }, + ], + [ + 'boolean cursor total', + { + 'coupons.cursor': { + columns: ['INDEX', 'TOTAL', 'PAGESIZE'], + data: [[0, false, 1]], + }, + }, + ], + [ + 'empty cursor total', + { + 'coupons.cursor': { + columns: ['INDEX', 'TOTAL', 'PAGESIZE'], + data: [[0, '', 1]], + }, + }, + ], + [ + 'short page', + { + 'coupons.cursor': { + columns: ['INDEX', 'TOTAL', 'PAGESIZE'], + data: [[0, 1, 1]], + }, + }, + ], + ])( + 'rejects %s without returning a partial calendar', + async (_name, invalid) => { + jest + .spyOn(global, 'fetch') + .mockImplementation(() => + Promise.resolve(Response.json({ ...emptyEvents, ...invalid })), + ); + await expect(new MoexClient().bondEvents('BOND')).rejects.toMatchObject({ + response: { code: 'MOEX_UNAVAILABLE' }, + }); + }, + ); + + it.each([0, -1])( + 'rejects nonpositive page size %s before repeating a fetch', + async (pageSize) => { + const starts: string[] = []; + jest.spyOn(global, 'fetch').mockImplementation((input) => { + const url = new URL(input instanceof Request ? input.url : input); + if (url.searchParams.get('iss.only')?.startsWith('coupons,')) { + starts.push(url.searchParams.get('start') ?? ''); + if (starts.length > 1) + return Promise.reject(new Error('Repeated page')); + } + return Promise.resolve( + Response.json({ + ...emptyEvents, + 'coupons.cursor': { + columns: ['INDEX', 'TOTAL', 'PAGESIZE'], + data: [[0, 1, pageSize]], + }, + }), + ); + }); + await expect(new MoexClient().bondEvents('BOND')).rejects.toMatchObject({ + response: { code: 'MOEX_UNAVAILABLE' }, + }); + expect(starts).toEqual(['0']); + }, + ); + + it.each([false, true])( + 'reads all cursor pages and rejects a repeated index: %s', + async (repeatIndex) => { + const starts: string[] = []; + jest.spyOn(global, 'fetch').mockImplementation((input) => { + const url = new URL(input instanceof Request ? input.url : input); + const start = Number(url.searchParams.get('start')); + if (url.searchParams.get('iss.only')?.startsWith('coupons,')) { + starts.push(String(start)); + if (starts.length > 2) + return Promise.reject(new Error('Repeated page')); + } + return Promise.resolve( + Response.json({ + ...emptyEvents, + coupons: { + columns: ['coupondate', 'value', 'faceunit'], + data: [[`2099-02-0${start + 1}`, '2.000000001', 'SUR']], + }, + 'coupons.cursor': { + columns: ['INDEX', 'TOTAL', 'PAGESIZE'], + data: [[repeatIndex ? 0 : start, 2, 1]], + }, + }), + ); + }); + const result = new MoexClient().bondEvents('BOND'); + if (repeatIndex) + await expect(result).rejects.toMatchObject({ + response: { code: 'MOEX_UNAVAILABLE' }, + }); + else + await expect(result).resolves.toEqual([ + { + type: 'coupon', + date: '2099-02-01', + value: '2.000000001', + currency: 'RUB', + }, + { + type: 'coupon', + date: '2099-02-02', + value: '2.000000001', + currency: 'RUB', + }, + ]); + expect(starts).toEqual(['0', '1']); + }, + ); }); diff --git a/src/integrations/moex/moex.client.ts b/src/integrations/moex/moex.client.ts index 85351e6..d00fbbf 100644 --- a/src/integrations/moex/moex.client.ts +++ b/src/integrations/moex/moex.client.ts @@ -123,22 +123,26 @@ export class MoexClient { rows.push(...current); if (!paginated) return rows; const cursorBlock = response[`${block}.cursor`]; - if (!cursorBlock) { + if (cursorBlock === undefined) { if (current.length < FALLBACK_PAGE_SIZE) return rows; start += FALLBACK_PAGE_SIZE; continue; } - const cursor = parseRows(cursorBlock)[0]; + const cursors = parseRows(cursorBlock); + const cursor = cursors[0]; const index = integer(cursor?.index); const total = integer(cursor?.total); const pageSize = integer(cursor?.pagesize); if ( - index === null || + cursors.length !== 1 || + index !== start || total === null || pageSize === null || - index + pageSize >= total + pageSize === 0 || + current.length !== Math.min(pageSize, total - start) ) - return rows; + throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' }); + if (index + pageSize >= total) return rows; start = index + pageSize; } } @@ -161,14 +165,13 @@ export class MoexClient { } function parseRows(block: IssBlock | undefined): Record[] { - if (!block) return []; - if (!Array.isArray(block.columns) || !Array.isArray(block.data)) + if (!Array.isArray(block?.columns) || !Array.isArray(block?.data)) throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' }); const columns = block.columns; if (!columns.every((column) => typeof column === 'string')) throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' }); return block.data.map((values) => { - if (!Array.isArray(values)) + if (!Array.isArray(values) || values.length !== columns.length) throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' }); return Object.fromEntries( columns.map((column, index) => [column.toLowerCase(), values[index]]), @@ -191,6 +194,6 @@ function currencyText(value: unknown): string | null { } function integer(value: unknown): number | null { - const result = Number(value); + const result = Number(numberText(value) ?? NaN); return Number.isSafeInteger(result) && result >= 0 ? result : null; } diff --git a/src/integrations/tbank/tbank-access.service.spec.ts b/src/integrations/tbank/tbank-access.service.spec.ts deleted file mode 100644 index f0b94e0..0000000 --- a/src/integrations/tbank/tbank-access.service.spec.ts +++ /dev/null @@ -1,162 +0,0 @@ -import { - BadRequestException, - ConflictException, - NotFoundException, - ServiceUnavailableException, -} from '@nestjs/common'; - -jest.mock('../../prisma/prisma.service', () => ({ - PrismaService: class PrismaService {}, -})); - -import type { TbankAccountContext } from './tbank-access.service'; -import { TbankAccessService } from './tbank-access.service'; -import { TbankTokenService } from './tbank-token.service'; - -describe('TbankAccessService', () => { - const prisma = { tbankConnection: { findUnique: jest.fn() } }; - const token = { - decrypt: jest.fn(), - translateInvalidToken: jest.fn((operation: () => Promise) => - new TbankTokenService({ - getOrThrow: () => 'MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=', - } as never).translateInvalidToken(operation), - ), - }; - const tbank = { getOpenAccounts: jest.fn() }; - const service = new TbankAccessService( - prisma as never, - token as never, - tbank as never, - ); - - beforeEach(() => { - jest.clearAllMocks(); - prisma.tbankConnection.findUnique.mockResolvedValue({ - encryptedToken: 'encrypted', - }); - token.decrypt.mockReturnValue('plain-token'); - tbank.getOpenAccounts.mockResolvedValue([ - { - id: 'account', - type: 'ACCOUNT_TYPE_TINKOFF', - name: ' Основной ', - openedDate: { seconds: '1723507200' }, - }, - { id: 'debit', type: 'ACCOUNT_TYPE_DEBIT', name: 'Debit' }, - ]); - }); - - it('lists supported accounts through the saved decrypted token', async () => { - await expect(service.listSupportedAccounts('user')).resolves.toEqual([ - { - id: 'account', - type: 'BROKERAGE', - name: 'Основной', - openedOn: '2024-08-13', - }, - ]); - - expect(prisma.tbankConnection.findUnique).toHaveBeenCalledWith({ - where: { userId: 'user' }, - }); - expect(token.decrypt).toHaveBeenCalledWith('encrypted'); - expect(tbank.getOpenAccounts).toHaveBeenCalledWith('plain-token'); - }); - - it('rejects missing connections before calling T-Bank', async () => { - prisma.tbankConnection.findUnique.mockResolvedValue(null); - - await expect(service.listSupportedAccounts('user')).rejects.toEqual( - expect.any(ConflictException), - ); - expect(tbank.getOpenAccounts).not.toHaveBeenCalled(); - }); - - it('resolves the persisted connection generation without an upstream account call', async () => { - const context = await service.withConnection('user', (value) => value); - - expect(context).toEqual({ - token: 'plain-token', - generation: expect.any(String) as string, - }); - expect(tbank.getOpenAccounts).not.toHaveBeenCalled(); - }); - - it('translates invalid tokens and preserves unavailable upstream errors', async () => { - tbank.getOpenAccounts.mockRejectedValueOnce( - new Error('INVALID_TBANK_TOKEN'), - ); - await expect(service.listSupportedAccounts('user')).rejects.toEqual( - expect.any(BadRequestException), - ); - - const unavailable = new ServiceUnavailableException({ - code: 'TBANK_UNAVAILABLE', - }); - tbank.getOpenAccounts.mockRejectedValueOnce(unavailable); - await expect(service.listSupportedAccounts('user')).rejects.toBe( - unavailable, - ); - }); - - it('wraps account-scoped handlers with resolved account access', async () => { - const handler = jest.fn((context: TbankAccountContext) => - Promise.resolve({ - resolvedToken: context.token, - generation: context.generation, - accountId: context.account.id, - }), - ); - - await expect( - service.withAccount('user', 'account', handler), - ).resolves.toEqual({ - resolvedToken: 'plain-token', - generation: expect.any(String) as string, - accountId: 'account', - }); - }); - - it('derives a stable opaque generation from each persisted encrypted payload', async () => { - const generation = () => - service.withAccount('user', 'account', ({ generation: value }) => - Promise.resolve(value), - ); - prisma.tbankConnection.findUnique - .mockResolvedValueOnce({ encryptedToken: 'encrypted:first-nonce' }) - .mockResolvedValueOnce({ encryptedToken: 'encrypted:first-nonce' }) - .mockResolvedValueOnce({ encryptedToken: 'encrypted:second-nonce' }); - - const first = await generation(); - const sameRecord = await generation(); - const replaced = await generation(); - - expect(sameRecord).toBe(first); - expect(replaced).not.toBe(first); - expect(first).not.toContain('plain-token'); - expect(first).not.toContain('encrypted:first-nonce'); - }); - - it('rejects inaccessible accounts before running the handler', async () => { - const handler = jest.fn(); - - await expect( - service.withAccount('user', 'missing', handler), - ).rejects.toEqual(expect.any(NotFoundException)); - expect(handler).not.toHaveBeenCalled(); - }); - - it('translates invalid-token handler failures and preserves unrelated handler failures', async () => { - await expect( - service.withAccount('user', 'account', () => - Promise.reject(new Error('INVALID_TBANK_TOKEN')), - ), - ).rejects.toEqual(expect.any(BadRequestException)); - - const failure = new Error('source failed'); - await expect( - service.withAccount('user', 'account', () => Promise.reject(failure)), - ).rejects.toBe(failure); - }); -}); diff --git a/src/integrations/tbank/tbank-access.service.ts b/src/integrations/tbank/tbank-access.service.ts index 5d3a6c6..48be690 100644 --- a/src/integrations/tbank/tbank-access.service.ts +++ b/src/integrations/tbank/tbank-access.service.ts @@ -5,24 +5,11 @@ import { Injectable, NotFoundException, } from '@nestjs/common'; -import { createHash, randomUUID } from 'node:crypto'; +import { randomUUID } from 'node:crypto'; import { PrismaService } from '../../prisma/prisma.service'; -import { - SupportedTbankAccount, - accessibleTbankAccount, - supportedTbankAccounts, -} from './tbank-account.policy'; +import { supportedTbankAccounts } from './tbank-account.policy'; import { TbankTokenService } from './tbank-token.service'; -import { TbankAccount, TbankClient } from './tbank.client'; - -export type TbankConnectionContext = { - token: string; - generation: string; -}; - -export type TbankAccountContext = TbankConnectionContext & { - account: TbankAccount; -}; +import type { TbankAccount } from './tbank.client'; export type TbankSyncAttempt = { userId: string; @@ -36,7 +23,6 @@ export class TbankAccessService { constructor( private readonly prisma: PrismaService, private readonly tokens: TbankTokenService, - private readonly tbank: TbankClient, ) {} async reserveMembership( @@ -251,81 +237,4 @@ export class TbankAccessService { throw new NotFoundException(); return connection; } - - async listSupportedAccounts( - userId: string, - ): Promise { - return this.withSupportedAccounts(userId, ({ accounts }) => accounts); - } - - async withSupportedAccounts( - userId: string, - handler: (context: { - token: string; - generation: string; - accounts: SupportedTbankAccount[]; - }) => Promise | T, - ): Promise { - return this.withConnection(userId, async ({ token, generation }) => - handler({ - token, - generation, - accounts: supportedTbankAccounts( - await this.tbank.getOpenAccounts(token), - ), - }), - ); - } - - async withConnection( - userId: string, - handler: (context: TbankConnectionContext) => Promise | T, - ): Promise { - const connection = await this.connection(userId); - return this.tokens.translateInvalidToken(async () => handler(connection)); - } - - async withAccount( - userId: string, - accountId: string, - handler: (context: TbankAccountContext) => Promise, - ): Promise { - return this.withConnection(userId, (connection) => - this.withAccountForConnection(connection, accountId, handler), - ); - } - - async withAccountForConnection( - connection: TbankConnectionContext, - accountId: string, - handler: (context: TbankAccountContext) => Promise, - ): Promise { - const { token, generation } = connection; - return this.tokens.translateInvalidToken(async () => { - const account = accessibleTbankAccount( - await this.tbank.getOpenAccounts(token), - accountId, - ); - if (!account) throw new NotFoundException(); - return handler({ token, generation, account }); - }); - } - - private async connection( - userId: string, - ): Promise<{ token: string; generation: string }> { - const connection = await this.prisma.tbankConnection.findUnique({ - where: { userId }, - }); - if (!connection) - throw new ConflictException({ code: 'TBANK_NOT_CONNECTED' }); - if (connection.invalidToken) - throw new BadRequestException({ code: 'INVALID_TBANK_TOKEN' }); - return { - token: this.tokens.decrypt(connection.encryptedToken), - generation: createHash('sha256') - .update(connection.encryptedToken) - .digest('base64url'), - }; - } } diff --git a/src/integrations/tbank/tbank-account.policy.spec.ts b/src/integrations/tbank/tbank-account.policy.spec.ts index 7b582e8..8cb0915 100644 --- a/src/integrations/tbank/tbank-account.policy.spec.ts +++ b/src/integrations/tbank/tbank-account.policy.spec.ts @@ -1,7 +1,4 @@ -import { - accessibleTbankAccount, - supportedTbankAccounts, -} from './tbank-account.policy'; +import { supportedTbankAccounts } from './tbank-account.policy'; describe('tbank-account-policy', () => { it('normalizes supported account types and fallback names', () => { @@ -45,26 +42,4 @@ describe('tbank-account-policy', () => { }, ]); }); - - it('matches account-scoped access only for supported account types', () => { - expect( - accessibleTbankAccount( - [ - { id: 'account', type: 'ACCOUNT_TYPE_DEBIT', name: 'Debit' }, - { id: 'other', type: 'ACCOUNT_TYPE_TINKOFF', name: 'Other' }, - ], - 'account', - ), - ).toBeNull(); - expect( - accessibleTbankAccount( - [{ id: 'account', type: 'ACCOUNT_TYPE_TINKOFF', name: 'Brokerage' }], - 'account', - ), - ).toEqual({ - id: 'account', - type: 'ACCOUNT_TYPE_TINKOFF', - name: 'Brokerage', - }); - }); }); diff --git a/src/integrations/tbank/tbank-account.policy.ts b/src/integrations/tbank/tbank-account.policy.ts index 22c3aec..0589da5 100644 --- a/src/integrations/tbank/tbank-account.policy.ts +++ b/src/integrations/tbank/tbank-account.policy.ts @@ -39,17 +39,6 @@ export function supportedTbankAccounts( }); } -export function accessibleTbankAccount( - accounts: TbankAccount[], - accountId: string, -): TbankAccount | null { - return ( - accounts.find( - (account) => account.id === accountId && account.type in accountTypes, - ) ?? null - ); -} - function openedOn(seconds: string | number | undefined): string | null { if (seconds === undefined) return null; const date = new Date(Number(seconds) * MILLISECONDS_PER_SECOND); diff --git a/src/integrations/tbank/tbank.api.ts b/src/integrations/tbank/tbank.api.ts index 6d67a1d..d260155 100644 --- a/src/integrations/tbank/tbank.api.ts +++ b/src/integrations/tbank/tbank.api.ts @@ -2,13 +2,14 @@ export { InstrumentMetadataService } from './instrument-metadata.service'; export { TbankAccessService, - type TbankConnectionContext, + type TbankSyncAttempt, } from './tbank-access.service'; export { type TbankBond, type TbankCandle, type TbankCoupon, type TbankFundamentals, + type TbankLastPrice, type TbankShare, } from './tbank-market.types'; export { TbankTokenService } from './tbank-token.service'; diff --git a/src/main.ts b/src/main.ts index e80447a..61c4511 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,4 +1,8 @@ -import { INestApplication, ValidationPipe } from '@nestjs/common'; +import { + BadRequestException, + INestApplication, + ValidationPipe, +} from '@nestjs/common'; import { NestFactory } from '@nestjs/core'; import cookieParser from 'cookie-parser'; import { ConfigService } from '@nestjs/config'; @@ -26,7 +30,14 @@ export function configureApp(app: INestApplication): void { app.use(cookieParser()); const requestLogging = app.get(RequestLoggingMiddleware); app.use(requestLogging.use.bind(requestLogging)); - app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true })); + app.useGlobalPipes( + new ValidationPipe({ + transform: true, + whitelist: true, + validationError: { target: false, value: false }, + exceptionFactory: (message) => new BadRequestException({ message }), + }), + ); app.useGlobalFilters(new HttpExceptionFilter()); app.enableShutdownHooks(); } diff --git a/src/modules/auth/application/auth.service.ts b/src/modules/auth/application/auth.service.ts index 2e611c2..61babc2 100644 --- a/src/modules/auth/application/auth.service.ts +++ b/src/modules/auth/application/auth.service.ts @@ -29,9 +29,8 @@ export class AuthService { email: string, password: string, ): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> { - const normalizedEmail = this.normalizeEmail(email); const existing = await this.prisma.user.findUnique({ - where: { email: normalizedEmail }, + where: { email }, }); if (existing) throw new ConflictException({ code: 'EMAIL_ALREADY_REGISTERED' }); @@ -41,7 +40,7 @@ export class AuthService { user = await this.prisma.user.create({ data: { id: randomUUID(), - email: normalizedEmail, + email, passwordHash: await bcrypt.hash(password, PASSWORD_HASH_ROUNDS), }, }); @@ -58,7 +57,7 @@ export class AuthService { password: string, ): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> { const user = await this.prisma.user.findUnique({ - where: { email: this.normalizeEmail(email) }, + where: { email }, }); const valid = await bcrypt.compare( password, @@ -103,26 +102,27 @@ export class AuthService { async logout(token?: string): Promise { if (!token) return; + let payload: RefreshPayload; try { - const payload = await this.jwt.verifyAsync(token, { + payload = await this.jwt.verifyAsync(token, { algorithms: ['HS256'], }); - if (payload.type !== 'refresh') return; - const session = await this.prisma.session.findUnique({ - where: { id: payload.sid }, - }); - if ( - session && - !session.revokedAt && - session.refreshTokenHash === this.hash(token) - ) { - await this.prisma.session.update({ - where: { id: session.id }, - data: { revokedAt: new Date() }, - }); - } } catch { - // Logout is intentionally idempotent. + return; + } + if (payload.type !== 'refresh') return; + const session = await this.prisma.session.findUnique({ + where: { id: payload.sid }, + }); + if ( + session && + !session.revokedAt && + session.refreshTokenHash === this.hash(token) + ) { + await this.prisma.session.update({ + where: { id: session.id }, + data: { revokedAt: new Date() }, + }); } } @@ -187,10 +187,6 @@ export class AuthService { }; } - private normalizeEmail(email: string): string { - return email; - } - private hash(token: string): string { return createHash('sha256').update(token).digest('hex'); } diff --git a/src/modules/auth/presentation/auth.controller.spec.ts b/src/modules/auth/presentation/auth.controller.spec.ts deleted file mode 100644 index 5c8dc1e..0000000 --- a/src/modules/auth/presentation/auth.controller.spec.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { HTTP_CODE_METADATA } from '@nestjs/common/constants'; - -jest.mock('../application/auth.service', () => ({ - AuthService: class AuthService {}, -})); - -import { AuthController } from './auth.controller'; - -describe('AuthController response statuses', () => { - it.each(['register', 'login', 'refresh'] as const)( - '%s explicitly returns HTTP 200', - (handler) => { - // Nest stores @HttpCode metadata on the unbound handler function. - const handlerTarget = Object.getOwnPropertyDescriptor( - AuthController.prototype, - handler, - )?.value as object; - expect(Reflect.getMetadata(HTTP_CODE_METADATA, handlerTarget)).toBe(200); - }, - ); -}); diff --git a/src/modules/auth/presentation/auth.controller.ts b/src/modules/auth/presentation/auth.controller.ts index 3b44ca6..45a35dc 100644 --- a/src/modules/auth/presentation/auth.controller.ts +++ b/src/modules/auth/presentation/auth.controller.ts @@ -87,7 +87,7 @@ export class AuthController { if (!token) throw new UnauthorizedException({ code: 'UNAUTHORIZED' }); return this.respond(response, await this.auth.refresh(token)); } catch (error) { - this.clearCookie(response); + if (error instanceof UnauthorizedException) this.clearCookie(response); throw error; } } diff --git a/src/modules/banking/bank-cards/domain/bank-card.calculations.spec.ts b/src/modules/banking/bank-cards/domain/bank-card.calculations.spec.ts index 9585110..6e4aa88 100644 --- a/src/modules/banking/bank-cards/domain/bank-card.calculations.spec.ts +++ b/src/modules/banking/bank-cards/domain/bank-card.calculations.spec.ts @@ -116,7 +116,9 @@ describe('bank card calculations', () => { amountKopecks: 5000n, }, ]; - expect(bankSummary('2026-09', operations)).toEqual({ + expect( + bankSummary({ from: '2026-09-01', to: '2026-09-30' }, operations), + ).toEqual({ expenses: 120n, refunds: 500n, net: -380n, @@ -125,7 +127,9 @@ describe('bank card calculations', () => { { categoryId: 'travel', expenses: 20n, refunds: 0n, net: 20n }, ], }); - expect(bankSummary('2026-07', operations)).toEqual({ + expect( + bankSummary({ from: '2026-07-01', to: '2026-07-31' }, operations), + ).toEqual({ expenses: 0n, refunds: 0n, net: 0n, @@ -141,7 +145,12 @@ describe('bank card calculations', () => { amountKopecks: 2n ** 63n - 1n, }; const total = 2n * (2n ** 63n - 1n); - expect(bankSummary('2026-09', [operation, operation])).toEqual({ + expect( + bankSummary({ from: '2026-09-01', to: '2026-09-30' }, [ + operation, + operation, + ]), + ).toEqual({ expenses: total, refunds: 0n, net: total, diff --git a/src/modules/banking/bank-cards/domain/bank-card.calculations.ts b/src/modules/banking/bank-cards/domain/bank-card.calculations.ts index 4ee045a..e3a5ae6 100644 --- a/src/modules/banking/bank-cards/domain/bank-card.calculations.ts +++ b/src/modules/banking/bank-cards/domain/bank-card.calculations.ts @@ -1,6 +1,5 @@ import { BankOperationKind } from './bank-operation-kind'; -const ISO_MONTH_LENGTH = 7; export function moscowToday(now = new Date()): string { return new Intl.DateTimeFormat('en-CA', { timeZone: 'Europe/Moscow', @@ -54,7 +53,7 @@ export function bankBalance( } export function bankSummary( - period: string | { from: string; to: string }, + period: { from: string; to: string }, operations: Array<{ kind: string; date: string; @@ -70,12 +69,7 @@ export function bankSummary( >(); // ponytail: O(N) history fold; optimize only after measuring growing histories. for (const operation of operations) { - if ( - typeof period === 'string' - ? operation.date.slice(0, ISO_MONTH_LENGTH) !== period - : operation.date < period.from || operation.date > period.to - ) - continue; + if (operation.date < period.from || operation.date > period.to) continue; if ( operation.kind !== BankOperationKind.Expense && operation.kind !== BankOperationKind.Refund diff --git a/src/modules/investments/instruments/application/candle.service.spec.ts b/src/modules/investments/instruments/application/candle.service.spec.ts new file mode 100644 index 0000000..0660470 --- /dev/null +++ b/src/modules/investments/instruments/application/candle.service.spec.ts @@ -0,0 +1,113 @@ +jest.mock('../infrastructure/candle.repository', () => ({ + CandleRepository: class {}, +})); +jest.mock('./catalog-enrichment.service', () => ({ + CatalogEnrichmentService: class {}, +})); +jest.mock('../../../../integrations/tbank/tbank.api', () => ({ + TbankAccessService: class {}, + TbankClient: class {}, +})); + +import { CandleService } from './candle.service'; + +describe('CandleService forced demand', () => { + it.each([false, true])( + 'reconciles the full range through the latest forced timestamp while force=%s is loading', + async (initialForce) => { + let entered = () => {}; + let release = () => {}; + let complete = () => {}; + const started = new Promise((resolve) => (entered = resolve)); + const blocked = new Promise((resolve) => (release = resolve)); + const finished = new Promise((resolve) => (complete = resolve)); + let completed = 0; + const work = { id: 'work', attemptId: 'attempt' }; + const repository = { + reserve: jest + .fn() + .mockResolvedValueOnce({ work, launch: true }) + .mockResolvedValueOnce({ + work: { id: 'other', attemptId: 'other-attempt' }, + launch: true, + }) + .mockResolvedValue({ work, launch: false }), + read: jest.fn().mockResolvedValue({ + coverage: [ + { + from: new Date('2026-01-01Z'), + to: new Date('2026-02-01Z'), + isComplete: true, + fetchedAt: new Date('2026-02-01Z'), + }, + ], + }), + stage: jest.fn().mockResolvedValue(true), + publish: jest.fn((_attempt, _intent, done = true) => { + if (done && ++completed === 2) complete(); + return Promise.resolve(true); + }), + fail: jest.fn(), + }; + const sources = { + ensureDetail: jest.fn().mockResolvedValue({ + first_1dayCandleDate: { + seconds: String(Date.parse('2026-01-01Z') / 1000), + }, + }), + }; + const upstream = { getCandles: jest.fn().mockResolvedValue([]) }; + upstream.getCandles.mockImplementationOnce(async () => { + entered(); + await blocked; + return []; + }); + const service = new CandleService( + repository as never, + sources as never, + { tokenForAttempt: jest.fn().mockResolvedValue('token') } as never, + upstream as never, + { event: jest.fn() } as never, + ); + const input = { + uid: 'uid', + instrumentType: 'share' as const, + period: 'all' as const, + }; + await service.request( + 'owner', + 'revision', + input, + new Date('2026-02-02T12:00:00Z'), + initialForce, + 2, + ); + await started; + const latest = new Date('2026-02-02T13:00:00Z'); + await service.request( + 'owner', + 'revision', + { ...input, uid: 'other' }, + latest, + false, + 1, + ); + expect( + await service.request('owner', 'revision', input, latest, true, 0), + ).toBe('work'); + release(); + await finished; + + expect(upstream.getCandles).toHaveBeenCalledWith( + 'token', + 'uid', + new Date('2026-01-01Z'), + latest, + ); + expect(repository.fail).not.toHaveBeenCalled(); + expect( + upstream.getCandles.mock.calls.map((call: unknown[]) => call[1]), + ).toEqual(['uid', 'uid', 'other']); + }, + ); +}); diff --git a/src/modules/investments/instruments/application/candle.service.ts b/src/modules/investments/instruments/application/candle.service.ts index f6830e6..aacc939 100644 --- a/src/modules/investments/instruments/application/candle.service.ts +++ b/src/modules/investments/instruments/application/candle.service.ts @@ -13,8 +13,8 @@ import { TbankAccessService, TbankClient, type TbankShare, + type TbankSyncAttempt, } from '../../../../integrations/tbank/tbank.api'; -import type { TbankSyncAttempt } from '../../../../integrations/tbank/tbank-access.service'; import { investmentResourceMeta } from '../../investment-resource.mapper'; import { CatalogEnrichmentService } from './catalog-enrichment.service'; import { CandleRepository } from '../infrastructure/candle.repository'; @@ -39,8 +39,12 @@ type Producer = { force: boolean; priority: number; observers: TbankSyncAttempt[]; - ranges?: CandleRange[]; - nextWindow?: number; + pass?: { + now: Date; + force: boolean; + ranges: CandleRange[]; + nextWindow: number; + }; }; const months = { month: 1, halfYear: 6, year: 12 }; const tailFreshForMs = 60_000; @@ -323,9 +327,14 @@ export class CandleService { id: reserved.work.id, attemptId: reserved.work.attemptId, }; + const producer = this.producers.get(scope(input)); + if (producer) { + producer.force ||= force; + producer.priority = Math.min(producer.priority, priority); + if (force && now > producer.now) producer.now = now; + } if (reserved.launch) { this.active.add(attempt.attemptId); - const producer = this.producers.get(scope(input)); if (producer) producer.observers.push(attempt); else { const next = { input, attempt, now, force, priority, observers: [] }; @@ -346,7 +355,7 @@ export class CandleService { } private async load(producer: Producer) { - const { input, attempt, now } = producer; + const { input, attempt } = producer; const instrument = await this.sources.ensureDetail( attempt.userId, attempt.revision, @@ -354,20 +363,24 @@ export class CandleService { input.uid, ); if (!instrument) throw new NotFoundException(); - const range = rangeFor(input, instrument, now); - if (!producer.ranges) { + let pass = producer.pass; + if (!pass || (producer.force && (!pass.force || producer.now > pass.now))) { + const { now, force } = producer; + const range = rangeFor(input, instrument, now); const saved = await this.repository.read(input.uid, range); - producer.ranges = ( - producer.force ? [range] : candleGaps(range, saved.coverage, now) - ) - .flatMap(candleWindows) - .reverse(); - producer.nextWindow = 0; + pass = producer.pass = { + now, + force, + ranges: (force ? [range] : candleGaps(range, saved.coverage, now)) + .flatMap(candleWindows) + .reverse(), + nextWindow: 0, + }; } - const ranges = producer.ranges; - const index = producer.nextWindow ?? 0; + const { ranges, now, force } = pass; + const index = pass.nextWindow; let window = ranges[index]; - if (window && !producer.force) { + if (window && !force) { const latest = await this.repository.read(input.uid, window); const gaps = candleGaps(window, latest.coverage, now) .flatMap(candleWindows) @@ -398,7 +411,7 @@ export class CandleService { if (!(await this.repository.stage(attempt, window, rows, now))) return false; if ( - !producer.force && + !force && !(await this.repository.publish( attempt, { uid: input.uid, ranges: [window] }, @@ -406,13 +419,10 @@ export class CandleService { )) ) return false; - producer.nextWindow = index + 1; - if (producer.nextWindow < ranges.length) return null; + pass.nextWindow = index + 1; + if (pass.nextWindow < ranges.length) return null; } - return this.repository.publish(attempt, { - uid: input.uid, - ranges: producer.force ? ranges : [], - }); + return true; } private async drain() { @@ -443,20 +453,43 @@ export class CandleService { code = response.code; } } - if (!success) await this.repository.fail(producer.attempt, code); - this.active.delete(producer.attempt.attemptId); - for (const observer of producer.observers) { - if ( - !success || - !(await this.repository.completeObserver( - observer, - producer.attempt, - )) - ) - await this.repository.fail(observer, code); - this.active.delete(observer.attemptId); + // Seal publication with reservations so a late forced join cannot be + // acknowledged after the final demand check but before work completion. + const finished = this.reservations.then(async () => { + const pass = producer.pass; + if (success && pass) { + if (producer.force && (!pass.force || producer.now > pass.now)) + return false; + try { + success = await this.repository.publish(producer.attempt, { + uid: producer.input.uid, + ranges: pass.force ? pass.ranges : [], + }); + } catch { + success = false; + } + } + if (!success) await this.repository.fail(producer.attempt, code); + this.active.delete(producer.attempt.attemptId); + for (const observer of producer.observers) { + if ( + !success || + !(await this.repository.completeObserver( + observer, + producer.attempt, + )) + ) + await this.repository.fail(observer, code); + this.active.delete(observer.attemptId); + } + this.producers.delete(scope(producer.input)); + return true; + }); + this.reservations = finished.catch(() => undefined); + if (!(await finished)) { + this.queue.push(producer); + continue; } - this.producers.delete(scope(producer.input)); if (success && !producer.force && producer.input.period !== 'all') await this.request( producer.attempt.userId, diff --git a/src/modules/investments/instruments/application/catalog-discovery.service.ts b/src/modules/investments/instruments/application/catalog-discovery.service.ts index 1905550..ae06df5 100644 --- a/src/modules/investments/instruments/application/catalog-discovery.service.ts +++ b/src/modules/investments/instruments/application/catalog-discovery.service.ts @@ -11,8 +11,8 @@ import { TbankClient, tbankSourceNeedsRefresh, tbankSourceTtlMs, + type TbankSyncAttempt, } from '../../../../integrations/tbank/tbank.api'; -import type { TbankSyncAttempt } from '../../../../integrations/tbank/tbank-access.service'; import { investmentResourceMeta } from '../../investment-resource.mapper'; import { CatalogDiscoveryRepository, diff --git a/src/modules/investments/instruments/application/catalog-enrichment.service.ts b/src/modules/investments/instruments/application/catalog-enrichment.service.ts index 785347c..2d29778 100644 --- a/src/modules/investments/instruments/application/catalog-enrichment.service.ts +++ b/src/modules/investments/instruments/application/catalog-enrichment.service.ts @@ -1,4 +1,3 @@ -import type { TbankDividend } from '../../../../integrations/tbank/tbank.api'; import type { MoexBondEvent } from '../../../../integrations/moex/moex.api'; import { CandleRepository } from '../infrastructure/candle.repository'; import { candleGaps, utcDay } from '../domain/candle-coverage'; @@ -14,11 +13,6 @@ import { } from '../../../../common/time'; import { MoexClient } from '../../../../integrations/moex/moex.api'; import { NcrRatingsClient } from '../../../../integrations/ncr/ncr.api'; -import type { - TbankCandle, - TbankFundamentals, -} from '../../../../integrations/tbank/tbank.api'; -import type { TbankCoupon } from '../../../../integrations/tbank/tbank-market.types'; import { previousClose, timestamp } from './instrument-market'; import { Injectable, NotFoundException } from '@nestjs/common'; import { randomUUID } from 'node:crypto'; @@ -29,10 +23,14 @@ import { tbankSourceNeedsRefresh, tbankSourceTtlMs, type TbankBond, + type TbankCandle, + type TbankCoupon, + type TbankDividend, + type TbankFundamentals, + type TbankLastPrice, type TbankShare, + type TbankSyncAttempt, } from '../../../../integrations/tbank/tbank.api'; -import type { TbankLastPrice } from '../../../../integrations/tbank/tbank-market.types'; -import type { TbankSyncAttempt } from '../../../../integrations/tbank/tbank-access.service'; import { investmentResourceMeta } from '../../investment-resource.mapper'; import { CatalogDiscoveryRepository, diff --git a/src/modules/investments/instruments/application/instrument-market.ts b/src/modules/investments/instruments/application/instrument-market.ts index a74e55e..b09f461 100644 --- a/src/modules/investments/instruments/application/instrument-market.ts +++ b/src/modules/investments/instruments/application/instrument-market.ts @@ -1,5 +1,4 @@ import { MILLISECONDS_PER_SECOND } from '../../../../common/time'; -import { ServiceUnavailableException } from '@nestjs/common'; import type { TbankCandle, TbankTimestamp, @@ -36,16 +35,3 @@ export function previousClose( null ); } -export async function optional( - operation: () => Promise, - name: string, - unavailable: string[], -): Promise { - try { - return await operation(); - } catch (error) { - if (!(error instanceof ServiceUnavailableException)) throw error; - unavailable.push(name); - return null; - } -} diff --git a/src/modules/investments/instruments/stocks/application/stock-position.spec.ts b/src/modules/investments/instruments/application/instrument-position.spec.ts similarity index 72% rename from src/modules/investments/instruments/stocks/application/stock-position.spec.ts rename to src/modules/investments/instruments/application/instrument-position.spec.ts index 4ecb8a5..fdf43ce 100644 --- a/src/modules/investments/instruments/stocks/application/stock-position.spec.ts +++ b/src/modules/investments/instruments/application/instrument-position.spec.ts @@ -1,8 +1,8 @@ import { PortfolioDto, PortfolioPositionDto, -} from '../../../portfolio/presentation/portfolio.dto'; -import { stockPosition } from './stock-position'; +} from '../../portfolio/presentation/portfolio.dto'; +import { instrumentPosition } from './instrument-position'; const money = (value: string, currency = 'RUB') => ({ value, currency }); const row = ( @@ -34,13 +34,14 @@ const portfolio = (positions: PortfolioPositionDto[]): PortfolioDto => ({ describe('stock position', () => { it('weights costs exactly and keeps preferred shares separate', () => { - const result = stockPosition( + const result = instrumentPosition( portfolio([ row(), row('3', '20', '70'), row('100', '1', '100', 'preferred'), ]), 'common', + 'STOCK', ); expect(result.quantity).toBe('5'); expect(result.averagePurchasePrice).toEqual(money('16')); @@ -49,14 +50,18 @@ describe('stock position', () => { expect(result.returnPercent).toBe('25'); expect(result.accounts[0].returnPercent).toBe('50'); }); - it('does not round large amounts through floating point', () => { - const result = stockPosition( - portfolio([row('9007199254740993', '0.1', '900719925474099.3')]), - 'common', - ); - expect(result.quantity).toBe('9007199254740993'); - expect(result.averagePurchasePrice?.value).toBe('0.1'); - }); + it.each(['9007199254740993', '0.25'])( + 'preserves exact quantity %s', + (quantity) => { + const result = instrumentPosition( + portfolio([row(quantity, '0.1', '900719925474099.3')]), + 'common', + 'STOCK', + ); + expect(result.quantity).toBe(quantity); + expect(result.averagePurchasePrice?.value).toBe('0.1'); + }, + ); it('does not sum currencies or treat unknown values as zero', () => { const second = { ...row(), @@ -64,7 +69,11 @@ describe('stock position', () => { averagePurchasePrice: null, absoluteReturn: null, }; - const result = stockPosition(portfolio([row(), second]), 'common'); + const result = instrumentPosition( + portfolio([row(), second]), + 'common', + 'STOCK', + ); expect(result.quantity).toBe('4'); expect(result.currentValue).toBeNull(); expect(result.averagePurchasePrice).toBeNull(); @@ -72,14 +81,14 @@ describe('stock position', () => { expect(result.returnPercent).toBeNull(); }); it('keeps no-position and offsetting long/short holdings honest', () => { - expect(stockPosition(portfolio([]), 'common')).toMatchObject({ + expect(instrumentPosition(portfolio([]), 'common', 'STOCK')).toMatchObject({ quantity: '0', accounts: [], currentValue: null, returnPercent: null, }); expect( - stockPosition(portfolio([row(), row('-2')]), 'common') + instrumentPosition(portfolio([row(), row('-2')]), 'common', 'STOCK') .averagePurchasePrice, ).toBeNull(); }); diff --git a/src/modules/investments/instruments/infrastructure/candle.repository.ts b/src/modules/investments/instruments/infrastructure/candle.repository.ts index 5b205bb..d3c4273 100644 --- a/src/modules/investments/instruments/infrastructure/candle.repository.ts +++ b/src/modules/investments/instruments/infrastructure/candle.repository.ts @@ -3,7 +3,7 @@ import { randomUUID } from 'node:crypto'; import { createHash } from 'node:crypto'; import type { Prisma } from '../../../../../generated/prisma/client'; import { PrismaService } from '../../../../prisma/prisma.service'; -import type { TbankSyncAttempt } from '../../../../integrations/tbank/tbank-access.service'; +import type { TbankSyncAttempt } from '../../../../integrations/tbank/tbank.api'; import { confirmedCandleCoverage, type CandleRange, @@ -78,8 +78,37 @@ export class CandleRepository { previous?.state === 'running' && previous.processRunId === runId && active.has(previous.attemptId) - ) - return { work: previous, launch: false }; + ) { + const previousIntent = previous.intent as Prisma.JsonObject; + const previousNow = + typeof previousIntent.now === 'string' ? previousIntent.now : ''; + const nextPriority = Math.min(previous.priority, priority); + if ( + nextPriority === previous.priority && + (!intent.force || + (previousIntent.force === true && + (!intent.now || intent.now <= previousNow))) + ) + return { work: previous, launch: false }; + const work = await tx.tbankSyncWork.update({ + where: { id: previous.id }, + data: { + priority: nextPriority, + intent: { + ...previousIntent, + ...(intent.force + ? { + force: true, + ...(intent.now && intent.now > previousNow + ? { now: intent.now } + : {}), + } + : {}), + }, + }, + }); + return { work, launch: false }; + } if (previous) await tx.tbankSyncStage.deleteMany({ where: { workId: previous.id } }); const data = { diff --git a/src/modules/investments/instruments/infrastructure/catalog-discovery.repository.ts b/src/modules/investments/instruments/infrastructure/catalog-discovery.repository.ts index b7bc444..2c2d183 100644 --- a/src/modules/investments/instruments/infrastructure/catalog-discovery.repository.ts +++ b/src/modules/investments/instruments/infrastructure/catalog-discovery.repository.ts @@ -3,10 +3,10 @@ import { randomUUID } from 'node:crypto'; import { isDeepStrictEqual } from 'node:util'; import type { Prisma } from '../../../../../generated/prisma/client'; import { PrismaService } from '../../../../prisma/prisma.service'; -import type { TbankSyncAttempt } from '../../../../integrations/tbank/tbank-access.service'; import type { TbankBond, TbankShare, + TbankSyncAttempt, } from '../../../../integrations/tbank/tbank.api'; export type CatalogQuery = { diff --git a/src/modules/investments/instruments/stocks/application/stock-position.ts b/src/modules/investments/instruments/stocks/application/stock-position.ts deleted file mode 100644 index 37402d4..0000000 --- a/src/modules/investments/instruments/stocks/application/stock-position.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { PortfolioDto } from '../../../portfolio/portfolio.api'; -import { instrumentPosition } from '../../application/instrument-position'; -export function stockPosition(portfolio: PortfolioDto, uid: string) { - return instrumentPosition(portfolio, uid, 'STOCK'); -} diff --git a/src/modules/investments/investment-sync-plan.spec.ts b/src/modules/investments/investment-sync-plan.spec.ts index 82b2b00..7dd5b03 100644 --- a/src/modules/investments/investment-sync-plan.spec.ts +++ b/src/modules/investments/investment-sync-plan.spec.ts @@ -1,5 +1,3 @@ -jest.mock('../../prisma/prisma.service', () => ({ PrismaService: class {} })); - import { planInvestmentScreen, ledgerRange, diff --git a/src/modules/investments/investment-sync-plan.ts b/src/modules/investments/investment-sync-plan.ts index d54de6b..42a638b 100644 --- a/src/modules/investments/investment-sync-plan.ts +++ b/src/modules/investments/investment-sync-plan.ts @@ -3,7 +3,7 @@ import { dateOnlyBounds, moscowDate } from './investment-calendar'; import { operationIntervals, type OperationInterval, -} from './operations/operations.api'; +} from './operations/domain/operations-sync-plan'; export type InvestmentScreen = { screen: diff --git a/src/modules/investments/operations/application/brokerage-operations.service.ts b/src/modules/investments/operations/application/brokerage-operations.service.ts index 5db1783..b29231e 100644 --- a/src/modules/investments/operations/application/brokerage-operations.service.ts +++ b/src/modules/investments/operations/application/brokerage-operations.service.ts @@ -356,7 +356,10 @@ export class BrokerageOperationsService { : { accountId, items: rows.map((row) => ({ - ...mapBrokerageOperation(row.payload as TbankOperationItem), + ...mapBrokerageOperation( + row.payload as TbankOperationItem, + row.occurredAt, + ), ...(accountId === 'all' ? { account: { @@ -616,7 +619,10 @@ export class BrokerageOperationsService { ), ) .map((row) => ({ - ...mapBrokerageOperation(row.payload as TbankOperationItem), + ...mapBrokerageOperation( + row.payload as TbankOperationItem, + row.occurredAt, + ), ...(accountId === 'all' ? { account: { diff --git a/src/modules/investments/operations/infrastructure/brokerage-operation.mapper.spec.ts b/src/modules/investments/operations/infrastructure/brokerage-operation.mapper.spec.ts index cf569ca..0c20319 100644 --- a/src/modules/investments/operations/infrastructure/brokerage-operation.mapper.spec.ts +++ b/src/modules/investments/operations/infrastructure/brokerage-operation.mapper.spec.ts @@ -4,6 +4,17 @@ import { } from './brokerage-operation.mapper'; describe('mapBrokerageOperation', () => { + it('keeps subsecond timestamps at persisted millisecond precision and the standalone epoch default', () => { + expect( + mapBrokerageOperation({ + date: { seconds: '1768000000', nanos: 600999999 }, + }).occurredAt, + ).toBe('2026-01-09T23:06:40.600Z'); + expect(mapBrokerageOperation({}).occurredAt).toBe( + '1970-01-01T00:00:00.000Z', + ); + }); + it.each([ ['OPERATION_TYPE_COUPON', 'income'], ['OPERATION_TYPE_DIVIDEND', 'income'], diff --git a/src/modules/investments/operations/infrastructure/brokerage-operation.mapper.ts b/src/modules/investments/operations/infrastructure/brokerage-operation.mapper.ts index 35fc796..3fe3966 100644 --- a/src/modules/investments/operations/infrastructure/brokerage-operation.mapper.ts +++ b/src/modules/investments/operations/infrastructure/brokerage-operation.mapper.ts @@ -10,6 +10,8 @@ import { OperationCategory, } from '../presentation/brokerage-operation.dto'; +const NANOSECONDS_PER_MILLISECOND = 1_000_000; + const types: Record = { OPERATION_TYPE_BUY: 'trade', OPERATION_TYPE_BUY_CARD: 'trade', @@ -42,10 +44,11 @@ export function operationCategory(type: string | undefined): OperationCategory { export function mapBrokerageOperation( operation: TbankOperationItem, + occurredAt = operationTimestamp(operation.date), ): BrokerageOperationDto { return { id: operation.id ?? operation.cursor ?? '', - occurredAt: timestamp(operation.date), + occurredAt: occurredAt.toISOString(), type: operation.type ?? 'OPERATION_TYPE_UNSPECIFIED', category: operationCategory(operation.type), description: operation.description?.trim() || operation.name?.trim() || '', @@ -82,10 +85,11 @@ function money(value: TbankMoney | undefined) { : null; } -function timestamp( +export function operationTimestamp( value: { seconds?: string | number; nanos?: number } | undefined, ) { return new Date( - Number(value?.seconds ?? 0) * MILLISECONDS_PER_SECOND, - ).toISOString(); + Number(value?.seconds ?? 0) * MILLISECONDS_PER_SECOND + + Math.floor((value?.nanos ?? 0) / NANOSECONDS_PER_MILLISECOND), + ); } diff --git a/src/modules/investments/operations/infrastructure/brokerage-operations.repository.ts b/src/modules/investments/operations/infrastructure/brokerage-operations.repository.ts index d4b8abc..bd86956 100644 --- a/src/modules/investments/operations/infrastructure/brokerage-operations.repository.ts +++ b/src/modules/investments/operations/infrastructure/brokerage-operations.repository.ts @@ -5,7 +5,6 @@ import { } from '@nestjs/common'; import { createHash, randomUUID } from 'node:crypto'; import { isDeepStrictEqual } from 'node:util'; -import { MILLISECONDS_PER_SECOND } from '../../../../common/time'; import { PrismaService } from '../../../../prisma/prisma.service'; import type { Prisma } from '../../../../../generated/prisma/client'; import type { @@ -17,7 +16,10 @@ import { dateOnlyBounds, moscowDate, } from '../../investment-calendar'; -import { operationCategory } from './brokerage-operation.mapper'; +import { + operationCategory, + operationTimestamp, +} from './brokerage-operation.mapper'; import { type OperationInterval, operationIntervals, @@ -45,7 +47,6 @@ type PageProgress = { pages: number; }; const progressItem = ''; -const NANOSECONDS_PER_MILLISECOND = 1_000_000; @Injectable() export class BrokerageOperationsRepository { @@ -650,13 +651,11 @@ export class BrokerageOperationsRepository { for (const operation of response.items ?? []) { if (!operation.id?.trim()) throw new Error('Missing broker operation identity'); - const occurredAt = new Date( - Number(operation.date?.seconds) * MILLISECONDS_PER_SECOND + - Math.floor( - (operation.date?.nanos ?? 0) / NANOSECONDS_PER_MILLISECOND, - ), - ); - if (!Number.isFinite(occurredAt.valueOf())) + const occurredAt = operationTimestamp(operation.date); + if ( + operation.date?.seconds === undefined || + !Number.isFinite(occurredAt.valueOf()) + ) throw new Error('Invalid operation timestamp'); if ( occurredAt < interval.from || @@ -793,12 +792,7 @@ export class BrokerageOperationsRepository { if (previous && isDeepStrictEqual(previous.payload, operation)) continue; const data = { - occurredAt: new Date( - Number(operation.date?.seconds) * MILLISECONDS_PER_SECOND + - Math.floor( - (operation.date?.nanos ?? 0) / NANOSECONDS_PER_MILLISECOND, - ), - ), + occurredAt: operationTimestamp(operation.date), instrumentUid: operation.instrumentUid ?? null, category: operationCategory(operation.type), payload: row.payload as Prisma.InputJsonObject, diff --git a/src/modules/investments/operations/operations.api.ts b/src/modules/investments/operations/operations.api.ts index 6706b81..69bf304 100644 --- a/src/modules/investments/operations/operations.api.ts +++ b/src/modules/investments/operations/operations.api.ts @@ -2,10 +2,7 @@ export { BrokerageOperationDto } from './presentation/brokerage-operation.dto'; export { BrokerageOperationsService } from './application/brokerage-operations.service'; -export { - operationIntervals, - type OperationInterval, -} from './domain/operations-sync-plan'; +export type { OperationInterval } from './domain/operations-sync-plan'; export { operationCategories } from './presentation/brokerage-operation.dto'; export { BrokerageOperationsSyncService } from './application/brokerage-operations-sync.service'; diff --git a/src/modules/investments/portfolio/application/forecast.service.ts b/src/modules/investments/portfolio/application/forecast.service.ts index 07b1686..2f379d9 100644 --- a/src/modules/investments/portfolio/application/forecast.service.ts +++ b/src/modules/investments/portfolio/application/forecast.service.ts @@ -1,4 +1,5 @@ import { Injectable } from '@nestjs/common'; +import { tbankSourceNeedsRefresh } from '../../../../integrations/tbank/tbank.api'; import { investmentResourceMeta } from '../../investment-resource.mapper'; import type { InvestmentResourceMetaDto } from '../../investment-resource.dto'; import { @@ -67,8 +68,23 @@ export class ForecastService { dependencies .map((dependency) => dependency.sync) .find((sync) => sync.state === 'failed'); + const payload = stored ? (stored.payload as T) : null; + const data = + payload && + 'positionIsStale' in payload && + 'positionAt' in payload && + typeof payload.positionAt === 'string' + ? { + ...payload, + positionIsStale: tbankSourceNeedsRefresh( + 'portfolio', + new Date(payload.positionAt), + new Date(Date.now()), + ), + } + : payload; return { - data: stored ? (stored.payload as T) : null, + data, meta: investmentResourceMeta({ resourceKey: input.scopeKey, dataRevision: String(stored?.dataRevision ?? 0), @@ -80,7 +96,10 @@ export class ForecastService { ? 'partial' : 'missing', fetchedAt: first ? new Date(first) : null, - freshForMs: Number.POSITIVE_INFINITY, + freshForMs: + data && 'positionIsStale' in data && data.positionIsStale === true + ? 0 + : Number.POSITIVE_INFINITY, work: work?.id ? { ...work, id: work.id } : null, dependencies, }), diff --git a/src/modules/investments/portfolio/application/portfolio.service.ts b/src/modules/investments/portfolio/application/portfolio.service.ts index 42798ca..a0aeb79 100644 --- a/src/modules/investments/portfolio/application/portfolio.service.ts +++ b/src/modules/investments/portfolio/application/portfolio.service.ts @@ -14,6 +14,7 @@ import { } from '@nestjs/common'; import { add, + decimalFromString, formatDecimal, isZero, multiply, @@ -511,12 +512,3 @@ export class PortfolioService { : null; } } - -function decimalFromString(value: string) { - const negative = value.startsWith('-'); - const [whole, fraction = ''] = (negative ? value.slice(1) : value).split('.'); - return { - value: (negative ? -1n : 1n) * BigInt(`${whole}${fraction}`), - scale: fraction.length, - }; -} diff --git a/src/modules/investments/portfolio/portfolio.api.ts b/src/modules/investments/portfolio/portfolio.api.ts index 15fb504..eb62409 100644 --- a/src/modules/investments/portfolio/portfolio.api.ts +++ b/src/modules/investments/portfolio/portfolio.api.ts @@ -1,5 +1,4 @@ // Public cross-module surface. Nest providers are exported by the matching module. -export { totalsByCurrency } from './infrastructure/tbank-portfolio.mapper'; export { PortfolioDto, PortfolioPositionDto, diff --git a/src/modules/investments/tbank-sync/tbank-sync.controller.ts b/src/modules/investments/tbank-sync/tbank-sync.controller.ts index ee8bf85..c4985c0 100644 --- a/src/modules/investments/tbank-sync/tbank-sync.controller.ts +++ b/src/modules/investments/tbank-sync/tbank-sync.controller.ts @@ -10,7 +10,6 @@ import { Req, UseGuards, ValidationPipe, - type ValidationError, } from '@nestjs/common'; import { ApiAcceptedResponse, @@ -58,8 +57,8 @@ export class TbankSyncController { transform: true, whitelist: true, forbidNonWhitelisted: true, - exceptionFactory: (message) => - new BadRequestException({ message: descriptorErrors(message) }), + validationError: { target: false, value: false }, + exceptionFactory: (message) => new BadRequestException({ message }), }), ) input: unknown, @@ -75,13 +74,3 @@ export class TbankSyncController { return this.sync.status(request.user.sub, id); } } - -function descriptorErrors( - errors: ValidationError[], - prefix = '', -): ValidationError[] { - return errors.flatMap((error) => [ - { ...error, property: `${prefix}${error.property}` }, - ...descriptorErrors(error.children ?? [], `${prefix}${error.property}/`), - ]); -} diff --git a/src/modules/investments/tbank-sync/tbank-sync.repository.ts b/src/modules/investments/tbank-sync/tbank-sync.repository.ts index d5b53bc..5add3a8 100644 --- a/src/modules/investments/tbank-sync/tbank-sync.repository.ts +++ b/src/modules/investments/tbank-sync/tbank-sync.repository.ts @@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common'; import { createHash, randomUUID } from 'node:crypto'; import type { Prisma } from '../../../../generated/prisma/client'; import { PrismaService } from '../../../prisma/prisma.service'; -import type { TbankSyncAttempt } from '../../../integrations/tbank/tbank-access.service'; +import type { TbankSyncAttempt } from '../../../integrations/tbank/tbank.api'; import type { InvestmentScreenDto } from './tbank-sync.dto'; export type ScreenIntent = { scope: InvestmentScreenDto; now: string }; export type ScreenProgress = { diff --git a/src/modules/investments/tbank-sync/tbank-sync.service.ts b/src/modules/investments/tbank-sync/tbank-sync.service.ts index 99788d1..826830c 100644 --- a/src/modules/investments/tbank-sync/tbank-sync.service.ts +++ b/src/modules/investments/tbank-sync/tbank-sync.service.ts @@ -9,6 +9,7 @@ import { TbankAccessService, TbankMembershipService, InstrumentMetadataService, + type TbankSyncAttempt, } from '../../../integrations/tbank/tbank.api'; import { PortfolioService } from '../portfolio/portfolio.api'; import { @@ -30,7 +31,6 @@ import { IncomeAnalyticsService } from '../income/income-read.api'; import { UpcomingEventsService } from '../upcoming-events/upcoming-events-read.api'; import { planInvestmentScreen } from '../investment-sync-plan'; import type { InvestmentResourceMetaDto } from '../investment-resource.dto'; -import type { TbankSyncAttempt } from '../../../integrations/tbank/tbank-access.service'; import { TbankSyncRepository, type ScreenIntent, @@ -303,6 +303,7 @@ export class TbankSyncService { }, now, true, + 0, ), ]); } @@ -327,10 +328,17 @@ export class TbankSyncService { return this.sourceStatus(userId, saved.work); const intent = saved.work.intent as unknown as ScreenIntent; await this.access.local(userId, intent.scope.accountId); - const running = saved.children.some((child) => child.state === 'running'); + const children = await Promise.all( + saved.children.map((child) => + child.state === 'superseded' + ? this.operations.observe(userId, child.id) + : Promise.resolve(child), + ), + ); + const running = children.some((child) => child.state === 'running'); const failed = saved.work.errorCode ?? - saved.children.find((child) => child.errorCode)?.errorCode ?? + children.find((child) => child.errorCode)?.errorCode ?? null; const state = saved.work.processRunId !== this.runId && @@ -340,7 +348,7 @@ export class TbankSyncService { ? 'running' : failed ? 'failed' - : saved.children.some((child) => child.state === 'cancelled') + : children.some((child) => child.state === 'cancelled') ? 'cancelled' : 'succeeded'; const resources = await this.observe(userId, intent.scope); @@ -355,9 +363,8 @@ export class TbankSyncService { state, errorCode: failed, progress: { - completed: saved.children.filter((child) => child.state !== 'running') - .length, - total: saved.children.length, + completed: children.filter((child) => child.state !== 'running').length, + total: children.length, discovering: !saved.progress.dispatched, }, resources, diff --git a/test/app.e2e-spec.ts b/test/app.e2e-spec.ts index cb756c2..d1ed34f 100644 --- a/test/app.e2e-spec.ts +++ b/test/app.e2e-spec.ts @@ -1,6 +1,7 @@ import { INestApplication } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { Test, TestingModule } from '@nestjs/testing'; +import { PinoLogger } from 'nestjs-pino'; import request from 'supertest'; import { App } from 'supertest/types'; import { AppModule } from '../src/app.module'; @@ -28,7 +29,10 @@ describe('Authentication (e2e)', () => { await app.get(PrismaService).user.deleteMany(); }); - afterEach(() => app.close()); + afterEach(async () => { + await app.close(); + jest.restoreAllMocks(); + }); it('registers, guards profile, rotates refresh and logs out', async () => { const agent = request.agent(app.getHttpServer()); @@ -50,14 +54,94 @@ describe('Authentication (e2e)', () => { ); const firstCookie = registration.headers['set-cookie'][0]; - await agent.post('/api/v1/auth/refresh').expect(200); + const refreshed = await agent.post('/api/v1/auth/refresh').expect(200); await request(app.getHttpServer()) .post('/api/v1/auth/refresh') .set('Cookie', firstCookie) .expect(401) .expect('Content-Type', /application\/problem\+json/); + const logout = await agent.post('/api/v1/auth/logout').expect(204); + expect(logout.headers['set-cookie'][0]).toContain('refresh_token=;'); + await request(app.getHttpServer()) + .post('/api/v1/auth/refresh') + .set('Cookie', refreshed.headers['set-cookie'][0]) + .expect(401) + .expect(({ body }: { body: unknown }) => + expect(body).toEqual({ status: 401, code: 'UNAUTHORIZED' }), + ); + await request(app.getHttpServer()) + .post('/api/v1/auth/logout') + .set('Cookie', refreshed.headers['set-cookie'][0]) + .expect(204); await agent.post('/api/v1/auth/logout').expect(204); - await agent.post('/api/v1/auth/logout').expect(204); + }); + + it('keeps logout idempotent for an invalid refresh token', async () => { + const logout = await request(app.getHttpServer()) + .post('/api/v1/auth/logout') + .set('Cookie', 'refresh_token=invalid') + .expect(204); + + expect(logout.headers['set-cookie'][0]).toContain('refresh_token=;'); + }); + + it('reports a failed revocation and preserves the refresh cookie for retry', async () => { + const registration = await request(app.getHttpServer()) + .post('/api/v1/auth/register') + .send({ email: 'logout@example.test', password: 'a secure password' }) + .expect(200); + const cookie = registration.headers['set-cookie'][0]; + const revoke = jest + .spyOn(app.get(PrismaService).session, 'update') + .mockRejectedValueOnce(new Error('Storage unavailable')); + + const failed = await request(app.getHttpServer()) + .post('/api/v1/auth/logout') + .set('Cookie', cookie) + .expect(500) + .expect('Content-Type', /application\/problem\+json/); + revoke.mockRestore(); + + expect(failed.body).toEqual({ + status: 500, + code: 'INTERNAL_SERVER_ERROR', + }); + expect(failed.headers['set-cookie']).toBeUndefined(); + expect( + await app.get(PrismaService).session.findFirstOrThrow(), + ).toMatchObject({ revokedAt: null }); + await request(app.getHttpServer()) + .post('/api/v1/auth/refresh') + .set('Cookie', cookie) + .expect(200); + }); + + it('preserves a valid refresh cookie during a storage failure', async () => { + const registration = await request(app.getHttpServer()) + .post('/api/v1/auth/register') + .send({ email: 'refresh@example.test', password: 'a secure password' }) + .expect(200); + const cookie = registration.headers['set-cookie'][0]; + const read = jest + .spyOn(app.get(PrismaService).session, 'findUnique') + .mockRejectedValueOnce(new Error('Storage unavailable')); + + const failed = await request(app.getHttpServer()) + .post('/api/v1/auth/refresh') + .set('Cookie', cookie) + .expect(500) + .expect('Content-Type', /application\/problem\+json/); + read.mockRestore(); + + expect(failed.body).toEqual({ + status: 500, + code: 'INTERNAL_SERVER_ERROR', + }); + expect(failed.headers['set-cookie']).toBeUndefined(); + await request(app.getHttpServer()) + .post('/api/v1/auth/refresh') + .set('Cookie', cookie) + .expect(200); }); it('rotates one concurrent refresh on SQLite and keeps the winner usable', async () => { @@ -141,6 +225,26 @@ describe('Authentication (e2e)', () => { ); }); + it('returns field errors for invalid credentials without exposing input', async () => { + const response = await request(app.getHttpServer()) + .post('/api/v1/auth/register') + .send({ email: 'SYNTHETIC_VALIDATION_SECRET' }) + .expect(400) + .expect('Content-Type', /application\/problem\+json/); + expect(response.body).toEqual({ + status: 400, + code: 'VALIDATION_ERROR', + errors: expect.arrayContaining([ + { pointer: '#/email', detail: 'must be a valid email address' }, + { pointer: '#/password', detail: 'password must be a string' }, + ]) as unknown, + }); + expect(JSON.stringify(response.body).toLowerCase()).not.toContain( + 'synthetic_validation_secret', + ); + expect(await app.get(PrismaService).user.count()).toBe(0); + }); + it('guards T-Bank connection and never returns a token', async () => { const registration = await request(app.getHttpServer()) .post('/api/v1/auth/register') @@ -218,27 +322,37 @@ describe('Authentication (e2e)', () => { ); }); - it('records the public code and stack for an unhandled error', async () => { - const logger = app.get(StructuredLogger); - const event = jest.spyOn(logger, 'event'); + it('logs an unhandled error with safe public context and correlation', async () => { + const error = jest.spyOn(PinoLogger.prototype, 'error'); + const clientRequestId = 'a2dc6fbb-5a48-4c3c-9e3e-4fbf4d638a43'; jest .spyOn(app.get(AuthService), 'login') - .mockRejectedValue(new Error('password: secret')); + .mockRejectedValue( + new Error('operation failed\npassword: SYNTHETIC_AUDIT_SECRET'), + ); - await request(app.getHttpServer()) + const response = await request(app.getHttpServer()) .post('/api/v1/auth/login') + .set('X-Request-ID', clientRequestId) .send({ email: 'user@example.com', password: 'a secure password' }) .expect(500) + .expect('Content-Type', /application\/problem\+json/) .expect(({ body }: { body: unknown }) => expect(body).toEqual({ status: 500, code: 'INTERNAL_SERVER_ERROR' }), ); - expect(event).toHaveBeenCalledWith( - 'error', - 'http.request.completed', + expect(error).toHaveBeenCalledTimes(1); + expect(error).toHaveBeenCalledWith( expect.objectContaining({ + requestId: response.headers['x-request-id'], + clientRequestId, + status: 500, code: 'INTERNAL_SERVER_ERROR', }), + 'http.request.completed', + ); + expect(JSON.stringify(error.mock.calls)).not.toContain( + 'SYNTHETIC_AUDIT_SECRET', ); }); }); diff --git a/test/bank-cards.e2e-spec.ts b/test/bank-cards.e2e-spec.ts index 57cae6c..1585a47 100644 --- a/test/bank-cards.e2e-spec.ts +++ b/test/bank-cards.e2e-spec.ts @@ -275,6 +275,40 @@ describe('Manual bank cards configured HTTP and real SQLite', () => { }) .expect(400); }); + it('returns nested money field errors without exposing input or writing a card', async () => { + const before = await db.bankCard.count(); + const response = await auth('post', '') + .send({ + id: randomUUID(), + name: 'Card', + openingDate: '2026-01-01', + openingBalance: { + currency: 'USD', + value: 'SYNTHETIC_VALIDATION_SECRET', + }, + }) + .expect(400) + .expect('Content-Type', /application\/problem\+json/); + expect(response.body).toEqual({ + status: 400, + code: 'VALIDATION_ERROR', + errors: [ + { + pointer: '#/openingBalance/currency', + detail: expect.stringContaining('RUB') as unknown, + }, + { + pointer: '#/openingBalance/value', + detail: expect.any(String) as unknown, + }, + ], + }); + expect(JSON.stringify(response.body)).not.toContain( + 'SYNTHETIC_VALIDATION_SECRET', + ); + expect(await db.bankCard.count()).toBe(before); + }); + it('scopes every card/category/expense link and edit to owner', async () => { const own = await createCard(), foreign = await createCard('0', stranger); diff --git a/test/fixtures/tbank-sync-crash.cjs b/test/fixtures/tbank-sync-crash.cjs index 3f1d6a4..8e13110 100644 --- a/test/fixtures/tbank-sync-crash.cjs +++ b/test/fixtures/tbank-sync-crash.cjs @@ -57,7 +57,7 @@ repository.publish = async (...args) => { }; const service = new BrokerageOperationsSyncService( repository, - new TbankAccessService(prisma, tokens, upstream), + new TbankAccessService(prisma, tokens), upstream, { event() { diff --git a/test/openapi-contract.e2e-spec.ts b/test/openapi-contract.e2e-spec.ts index e8dbac7..2df4936 100644 --- a/test/openapi-contract.e2e-spec.ts +++ b/test/openapi-contract.e2e-spec.ts @@ -127,6 +127,23 @@ describe('OpenAPI contract (integration)', () => { }); it('generates the runtime contract without server startup or deployment configuration', async () => { + expect( + createOpenApiDocument(app).components?.schemas?.ProblemDetailsDto, + ).toMatchObject({ + properties: { + errors: { + type: 'array', + items: { + type: 'object', + required: ['pointer', 'detail'], + properties: { + pointer: { type: 'string' }, + detail: { type: 'string' }, + }, + }, + }, + }, + }); const directory = await mkdtemp(resolve(tmpdir(), 'wealthledger-openapi-')); try { const networkGuard = resolve(directory, 'no-network.cjs'); diff --git a/test/tbank-candles-persistence.e2e-spec.ts b/test/tbank-candles-persistence.e2e-spec.ts index 73e4b0d..6b5740a 100644 --- a/test/tbank-candles-persistence.e2e-spec.ts +++ b/test/tbank-candles-persistence.e2e-spec.ts @@ -22,7 +22,7 @@ describe('atomic shared candle reconciliation (SQLite)', () => { }) as never, ); const upstream = { getCandles: jest.fn() }; - const access = new TbankAccessService(prisma, tokens, upstream as never); + const access = new TbankAccessService(prisma, tokens); const instrument = { uid: 'uid', currency: 'rub', @@ -46,10 +46,10 @@ describe('atomic shared candle reconciliation (SQLite)', () => { }, ]), }; - const createService = () => + const createService = (selectedSources = sources) => new CandleService( repository, - sources as never, + selectedSources as never, access, upstream as never, { event: jest.fn() } as never, @@ -112,6 +112,7 @@ describe('atomic shared candle reconciliation (SQLite)', () => { }); }); afterAll(async () => prisma.$disconnect()); + afterEach(() => jest.restoreAllMocks()); it('deduplicates seams, excludes outside rows, persists exact decimals and closed empty coverage after reopen', async () => { const attempt = await reserve(); await repository.stage( @@ -302,6 +303,149 @@ describe('atomic shared candle reconciliation (SQLite)', () => { expect(read.meta.sync.state).toBe('failed'); expect(read.data?.candles).toEqual([]); }); + it.each([ + ['owner', 'before loading', false], + ['observer', 'before loading', false], + ['owner', 'after ordinary publication', false], + ['observer', 'after ordinary publication', false], + ['observer', 'after ordinary publication', true], + ] as const)( + 'honors force from %s joining %s (later failure=%s)', + async (userId, phase, laterFailure) => { + if (userId !== owner.userId) { + await prisma.user.upsert({ + where: { id: userId }, + create: { + id: userId, + email: `${userId}@example.invalid`, + passwordHash: 'synthetic', + }, + update: {}, + }); + await prisma.tbankConnection.create({ + data: { + userId, + revision: 'revision', + encryptedToken: tokens.encrypt('synthetic'), + connectedAt: new Date(), + }, + }); + } + const history = { + ...instrument, + first_1dayCandleDate: { + seconds: String(Date.parse('2024-01-01Z') / 1000), + }, + }; + const covered = { + from: new Date('2024-01-01Z'), + to: new Date('2025-01-01Z'), + }; + const old = { ...candle, time: '2024-01-02T00:00:00.000Z' }; + const initial = await reserve([covered]); + await repository.stage(initial, covered, [old], new Date('2025-01-02Z')); + await repository.publish(initial, { uid: 'uid', ranges: [covered] }); + let entered = () => {}; + let release = () => {}; + const started = new Promise((resolve) => (entered = resolve)); + const blocked = new Promise((resolve) => (release = resolve)); + const selectedSources = { + ...sources, + ensureDetail: jest.fn().mockResolvedValue(history), + }; + if (phase === 'before loading') + selectedSources.ensureDetail.mockImplementationOnce(async () => { + entered(); + await blocked; + return history; + }); + else { + const publish = repository.publish.bind(repository); + jest + .spyOn(repository, 'publish') + .mockImplementationOnce(async (...args) => { + const result = await publish(...args); + entered(); + await blocked; + return result; + }); + } + const forcedAt = new Date('2026-02-03T13:00:00Z'); + const forcedStates: string[] = []; + const fullRange = { from: covered.from, to: new Date('2026-02-04Z') }; + let beforeForced: + Awaited> | undefined; + upstream.getCandles.mockImplementation( + async (_token, _uid, _from, to: Date) => { + if (laterFailure && forcedStates.length) + throw new Error('Forced reconciliation second window unavailable'); + if (to.valueOf() === forcedAt.valueOf()) { + beforeForced = await repository.read('uid', fullRange); + const work = await repository.work( + userId, + 'revision', + 'candles:uid:all', + ); + forcedStates.push(work?.state ?? 'missing'); + } + return [ + { + time: { seconds: String(Date.parse(old.time) / 1000) }, + close: { units: '200', nano: 1 }, + isComplete: true, + }, + ]; + }, + ); + const service = createService(selectedSources); + const input = { + uid: 'uid', + instrumentType: 'share' as const, + period: 'all' as const, + }; + const ordinaryId = await service.request( + 'owner', + 'revision', + input, + new Date('2026-02-02T12:00:00Z'), + false, + 2, + ); + await started; + const forcedId = await service.request( + userId, + 'revision', + input, + forcedAt, + true, + 0, + ); + release(); + await settled(); + + if (laterFailure) { + expect(beforeForced).toBeDefined(); + expect(await repository.read('uid', fullRange)).toEqual(beforeForced); + } else + expect((await repository.read('uid', covered)).data).toEqual([ + { ...old, close: '200.000000001' }, + ]); + expect(forcedStates).toEqual(['running']); + expect( + await repository.work(userId, 'revision', 'candles:uid:all'), + ).toMatchObject({ + id: forcedId, + state: laterFailure ? 'failed' : 'complete', + priority: 0, + intent: { + force: true, + now: forcedAt.toISOString(), + }, + }); + if (userId === owner.userId) expect(forcedId).toBe(ordinaryId); + else expect(forcedId).not.toBe(ordinaryId); + }, + ); it('preserves source incompleteness and bond percentage precision without nominal conversion', async () => { const now = new Date(); now.setUTCHours(0, 0, 0, 0); diff --git a/test/tbank-catalog-discovery.e2e-spec.ts b/test/tbank-catalog-discovery.e2e-spec.ts index 23f66ff..0868b8e 100644 --- a/test/tbank-catalog-discovery.e2e-spec.ts +++ b/test/tbank-catalog-discovery.e2e-spec.ts @@ -22,7 +22,7 @@ describe('shared catalog discovery (SQLite)', () => { }) as never, ); const upstream = { findShares: jest.fn(), findBonds: jest.fn() }; - const access = new TbankAccessService(prisma, tokens, upstream as never); + const access = new TbankAccessService(prisma, tokens); const repository = new CatalogDiscoveryRepository(prisma); const createService = () => new CatalogDiscoveryService( diff --git a/test/tbank-ledger-local.e2e-spec.ts b/test/tbank-ledger-local.e2e-spec.ts index 5f46ea6..e2fb2dd 100644 --- a/test/tbank-ledger-local.e2e-spec.ts +++ b/test/tbank-ledger-local.e2e-spec.ts @@ -11,6 +11,8 @@ import { PrismaService } from '../src/prisma/prisma.service'; import { InstrumentMetadataRepository } from '../src/integrations/tbank/instrument-metadata.repository'; import { TbankClient } from '../src/integrations/tbank/tbank.client'; import { BrokerageOperationsResourceDto } from '../src/modules/investments/operations/presentation/brokerage-operation.dto'; +import { BrokerageOperationsRepository } from '../src/modules/investments/operations/infrastructure/brokerage-operations.repository'; +import type { IncomeAnalyticsResourceDto } from '../src/modules/investments/income/income-analytics.dto'; const uid = '11111111-1111-4111-8111-111111111111'; const period = 'from=2025-01-01&to=2025-01-31'; @@ -127,6 +129,97 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => { expect(upstream.getOperationsByCursor).not.toHaveBeenCalled(); expect(upstream.getOpenAccounts).not.toHaveBeenCalled(); }); + it('uses the persisted subsecond timestamp for filtering, cursor order and income entries', async () => { + await prisma.tbankOperation.deleteMany(); + const { revision } = await prisma.tbankConnection.findUniqueOrThrow({ + where: { userId: 'owner' }, + }); + const repository = app.get(BrokerageOperationsRepository); + const interval = { + accountId: 'a', + from: new Date('2025-01-02T00:00:00.500Z'), + to: new Date('2025-01-02T00:00:01.000Z'), + }; + const reserved = await repository.request( + 'owner', + revision, + [interval], + 'subsecond-test', + true, + new Set(), + 1, + ); + assert(reserved); + const attempt = { + userId: 'owner', + revision, + id: reserved.work.id, + attemptId: reserved.work.attemptId, + }; + await expect( + repository.stagePage(attempt, interval, undefined, { + hasNext: false, + items: [{ id: 'invalid', date: {} }], + }), + ).rejects.toThrow('Invalid operation timestamp'); + await repository.stagePage(attempt, interval, undefined, { + hasNext: false, + items: [400, 600, 800].map((milliseconds) => ({ + id: String(milliseconds), + date: { seconds: '1735776000', nanos: milliseconds * 1_000_000 }, + type: 'OPERATION_TYPE_COUPON', + payment: { currency: 'rub', units: '1', nano: 1 }, + })), + }); + await repository.publish(attempt, [interval], new Date(), true); + const saved = await prisma.tbankOperation.findMany({ + orderBy: { occurredAt: 'desc' }, + }); + const expected = [ + { id: '800', occurredAt: '2025-01-02T00:00:00.800Z' }, + { id: '600', occurredAt: '2025-01-02T00:00:00.600Z' }, + ]; + expect( + saved.map((row) => ({ + id: row.brokerOperationId, + occurredAt: row.occurredAt.toISOString(), + })), + ).toEqual(expected); + const items: { id: string; occurredAt: string }[] = []; + let cursor: string | null = null; + do { + const response = await request(app.getHttpServer()) + .get( + `/api/v1/brokerage-accounts/a/operations?from=${interval.from.toISOString()}&to=2025-01-02T00:00:00.900Z&limit=1&sync=observe${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`, + ) + .set('Authorization', auth()) + .expect(200); + const result = response.body as BrokerageOperationsResourceDto; + assert(result.data); + expect(result.data.items).toHaveLength(1); + items.push( + ...result.data.items.map(({ id, occurredAt }) => ({ id, occurredAt })), + ); + cursor = result.data.nextCursor; + } while (cursor); + const income = ( + await request(app.getHttpServer()) + .get( + `/api/v1/brokerage-accounts/a/income-analytics?${period}&sync=observe`, + ) + .set('Authorization', auth()) + .expect(200) + ).body as IncomeAnalyticsResourceDto; + expect(items).toEqual(expected); + expect( + income.data?.entries.map(({ id, occurredAt }) => ({ id, occurredAt })), + ).toEqual(expected); + expect(income.data?.totalsByCurrency[0].total).toEqual({ + currency: 'RUB', + value: '2.000000002', + }); + expect(upstream.getOperationsByCursor).not.toHaveBeenCalled(); + }); it.each(['default', 'today', 'today timestamp'])( 'keeps observed current coverage complete through %s period pagination without RPC', async (periodKind) => { diff --git a/test/tbank-metadata-persistence.e2e-spec.ts b/test/tbank-metadata-persistence.e2e-spec.ts index 9bb117a..fba3938 100644 --- a/test/tbank-metadata-persistence.e2e-spec.ts +++ b/test/tbank-metadata-persistence.e2e-spec.ts @@ -25,7 +25,7 @@ describe('durable shared instrument metadata (SQLite)', () => { getInstrumentByUid: jest.fn(), getInstrumentByFigi: jest.fn(), }; - const access = new TbankAccessService(prisma, tokens, upstream as never); + const access = new TbankAccessService(prisma, tokens); const repository = new InstrumentMetadataRepository(prisma); const logger = { event: jest.fn() }; const createService = () => diff --git a/test/tbank-operations-sync.e2e-spec.ts b/test/tbank-operations-sync.e2e-spec.ts index 8d6b813..ac70f75 100644 --- a/test/tbank-operations-sync.e2e-spec.ts +++ b/test/tbank-operations-sync.e2e-spec.ts @@ -42,7 +42,7 @@ describe('Request-driven operations synchronization (SQLite)', () => { TOKEN_ENCRYPTION_KEY: process.env.TOKEN_ENCRYPTION_KEY, }), ); - const access = new TbankAccessService(prisma, tokens, upstream as never); + const access = new TbankAccessService(prisma, tokens); const logger = { event: jest.fn() }; const createService = () => new BrokerageOperationsSyncService( diff --git a/test/tbank-screen-sync.e2e-spec.ts b/test/tbank-screen-sync.e2e-spec.ts index e8b2d5a..6c75d5b 100644 --- a/test/tbank-screen-sync.e2e-spec.ts +++ b/test/tbank-screen-sync.e2e-spec.ts @@ -144,7 +144,12 @@ describe('screen sync HTTP and SQLite', () => { { screen: 'stock', instrumentUid: '00000000-0000-4000-8000-000000000001', - candleRange: { from: '2026-02-01', to: '2026-01-01', jobs: [] }, + candleRange: { + from: '2026-02-01', + to: '2026-01-01', + jobs: [], + '~bad/path': 'SYNTHETIC_VALIDATION_SECRET', + }, }, ])('rejects malformed scope without reserving work: %j', async (scope) => { const response = await post(scope).expect(400); @@ -161,6 +166,20 @@ describe('screen sync HTTP and SQLite', () => { expect(typeof error.pointer).toBe('string'); expect(typeof error.detail).toBe('string'); } + if ('candleRange' in scope) { + expect( + problem.errors.filter( + (error) => error.pointer === '#/candleRange/jobs', + ), + ).toHaveLength(1); + expect(problem.errors).toContainEqual({ + pointer: '#/candleRange/~0bad~1path', + detail: expect.any(String) as unknown, + }); + expect(JSON.stringify(response.body)).not.toContain( + 'SYNTHETIC_VALIDATION_SECRET', + ); + } expect(await prisma.tbankSyncWork.count()).toBe(0); expect(upstream.getOpenAccounts).not.toHaveBeenCalled(); }); @@ -287,6 +306,99 @@ describe('screen sync HTTP and SQLite', () => { await status(last.meta.sync.id).expect(200); expect(upstream.getOperationsByCursor).toHaveBeenCalledTimes(1); }); + it.each(['succeeded', 'failed'] as const)( + 'observes the operations successor until it is %s', + async (outcome) => { + const accounts = [ + { + id: 'a', + name: 'Synthetic', + type: 'ACCOUNT_TYPE_TINKOFF', + openedDate: { seconds: '1577836800' }, + }, + ]; + let releaseMembership: (value: typeof accounts) => void = () => {}; + upstream.getOpenAccounts.mockReturnValue( + new Promise((resolve) => { + releaseMembership = resolve; + }), + ); + const connection = await prisma.tbankConnection.findUniqueOrThrow({ + where: { userId: 'owner' }, + }); + await prisma.tbankAccount.create({ + data: { + userId: 'owner', + revision: connection.revision, + accountId: 'a', + name: 'Synthetic', + type: 'ACCOUNT_TYPE_TINKOFF', + openedOn: '2020-01-01', + upstreamOrder: 0, + }, + }); + let release: (value: { + hasNext: boolean; + items: never[]; + }) => void = () => {}; + let reject: (error: Error) => void = () => {}; + upstream.getOperationsByCursor + .mockReturnValueOnce( + new Promise((resolve, rejectResult) => { + release = resolve; + reject = rejectResult; + }), + ) + .mockImplementation(() => + outcome === 'failed' + ? Promise.reject(new Error('Synthetic outage')) + : Promise.resolve({ hasNext: false, items: [] }), + ); + const accept = async (from: string, to: string) => { + const response = await post({ + screen: 'operations', + accountId: 'a', + from, + to, + }).expect(202); + return (response.body as { id: string }).id; + }; + const dispatched = async (id: string) => { + for (let i = 0; i < 300; i++) { + const work = await prisma.tbankSyncWork.findUniqueOrThrow({ + where: { id }, + }); + if ((work.progress as { dispatched: boolean }).dispatched) return id; + await delay(5); + } + throw new Error('Screen work did not dispatch'); + }; + const first = await accept('2026-01-01', '2026-01-10'); + const second = await accept('2026-01-20', '2026-01-31'); + const merged = await accept('2026-01-01', '2026-01-31'); + releaseMembership(accounts); + await Promise.all([first, second, merged].map(dispatched)); + try { + expect((await status(second).expect(200)).body).toMatchObject({ + state: 'running', + progress: { completed: 1, total: 2, discovering: false }, + }); + await status(second, 'other').expect(404); + } finally { + if (outcome === 'failed') reject(new Error('Synthetic outage')); + else release({ hasNext: false, items: [] }); + await settled(merged); + await settled(first); + } + expect(await settled(second)).toMatchObject({ + state: outcome, + errorCode: outcome === 'failed' ? 'TBANK_UNAVAILABLE' : null, + progress: { completed: 2, total: 2, discovering: false }, + }); + await prisma.tbankConnection.delete({ where: { userId: 'owner' } }); + await status(second).expect(404); + }, + ); it('rejects known foreign accounts locally and requires authentication', async () => { await post({ screen: 'operations', accountId: 'foreign' }).expect(404); await request(app.getHttpServer()) diff --git a/test/upcoming-events.e2e-spec.ts b/test/upcoming-events.e2e-spec.ts index 92d1131..33c0f02 100644 --- a/test/upcoming-events.e2e-spec.ts +++ b/test/upcoming-events.e2e-spec.ts @@ -10,6 +10,7 @@ import { PrismaService } from '../src/prisma/prisma.service'; import { TbankClient, TbankTokenService, + tbankSourceTtlMs, } from '../src/integrations/tbank/tbank.api'; import { MoexClient } from '../src/integrations/moex/moex.api'; import { CatalogEnrichmentService } from '../src/modules/investments/instruments/application/catalog-enrichment.service'; @@ -423,6 +424,95 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => { expect(moex.bondEvents).not.toHaveBeenCalled(); }); + it.each([ + ['stocks', shareUid, 'dividends', 'STOCK'], + ['bonds', bondUid, 'coupons', 'BOND'], + ])( + 'ages the saved %s payout position without RPC or a financial revision, including incompatible fresh candidates', + async (kind, uid, payout, type) => { + const date = new Date(Date.now() + 7 * 86400000) + .toISOString() + .slice(0, 10); + upstream.getDividends.mockResolvedValue([ + { + recordDate: time(date), + dividendNet: { units: '10', nano: 1, currency: 'rub' }, + }, + ]); + upstream.getBondCoupons.mockResolvedValue([ + { + couponDate: time(date), + fixDate: time(date), + payOneBond: { units: '2', currency: 'rub' }, + }, + ]); + await portfolio([position(uid, '3', type)]); + const path = `${kind}/${uid}/${payout}`; + const read = async () => + (await get(`${path}?sync=observe`).expect(200)).body as + StockDividendsResourceDto | BondCouponsResourceDto; + await get(path).expect(200); + await settled(); + const before = await read(); + if (!before.data?.positionAt) + throw new Error('Expected a saved position'); + expect(before.data.positionIsStale).toBe(false); + const saved = await prisma.tbankForecast.findFirstOrThrow(); + const expiresAt = + Date.parse(before.data.positionAt) + tbankSourceTtlMs.portfolio; + const clock = jest.spyOn(Date, 'now').mockReturnValue(expiresAt - 1); + jest.clearAllMocks(); + try { + expect((await read()).data).toEqual(before.data); + clock.mockReturnValue(expiresAt); + const aged = await read(); + expect(aged.data).toEqual({ ...before.data, positionIsStale: true }); + expect(aged.meta.freshness).toBe('stale'); + expect(aged.meta.dataRevision).toBe(before.meta.dataRevision); + expect(aged.meta.lastSuccessfulAt).toBe(before.meta.lastSuccessfulAt); + expect(await prisma.tbankForecast.findFirstOrThrow()).toEqual(saved); + + await portfolio([position(uid, '9', type)]); + await prisma.tbankPortfolio.updateMany({ + where: { userId: 'owner' }, + data: { fetchedAt: new Date(expiresAt) }, + }); + const repository = app.get(ForecastRepository); + const resolve = repository.resolve.bind(repository); + const publish = jest + .spyOn(repository, 'resolve') + .mockImplementationOnce(async (input) => { + expect(input.candidate).toMatchObject({ + positionAt: new Date(expiresAt).toISOString(), + positionIsStale: false, + }); + await prisma.tbankPortfolio.updateMany({ + where: { userId: 'owner' }, + data: { dataRevision: { increment: 1 } }, + }); + return resolve(input); + }); + try { + const fallback = await read(); + expect(fallback.data).toEqual(aged.data); + expect(fallback.meta.freshness).toBe('stale'); + expect(fallback.meta.dataRevision).toBe(before.meta.dataRevision); + expect(fallback.meta.lastSuccessfulAt).toBe( + before.meta.lastSuccessfulAt, + ); + expect(await prisma.tbankForecast.findFirstOrThrow()).toEqual(saved); + } finally { + publish.mockRestore(); + } + for (const rpc of Object.values(upstream)) + expect(rpc).not.toHaveBeenCalled(); + expect(moex.bondEvents).not.toHaveBeenCalled(); + } finally { + clock.mockRestore(); + } + }, + ); + it('keeps optional metadata failure separate from calendar completeness and uses portfolio ticker', async () => { upstream.getBond.mockRejectedValue(new Error('metadata unavailable')); await portfolio([position(bondUid, '3', 'BOND')]); @@ -436,14 +526,72 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => { expect(moex.bondEvents).toHaveBeenCalledWith('BOND'); }); - it('does not turn a missing MOEX block into a complete empty forecast and defaults to a 30-day window', async () => { + it.each([ + ['missing block', { coupons: undefined }], + [ + 'invalid cursor', + { 'coupons.cursor': { columns: ['INDEX', 'TOTAL'], data: [[0, 1]] } }, + ], + ])( + 'preserves the saved calendar and forecast when real MOEX parsing rejects %s', + async (_name, invalid) => { + await portfolio([position(bondUid, '3', 'BOND')]); + const response = { + coupons: { + columns: ['coupondate', 'value', 'faceunit'], + data: [['2099-02-01', '2', 'RUB']], + }, + offers: { columns: ['offerdate'], data: [] }, + description: { columns: ['name', 'value'], data: [] }, + }; + const fetchMock = jest + .spyOn(global, 'fetch') + .mockImplementation(() => Promise.resolve(Response.json(response))); + moex.bondEvents.mockImplementation((ticker: string) => + new MoexClient().bondEvents(ticker), + ); + try { + await events(false).expect(200); + await settled(); + const before = (await events().expect(200)) + .body as UpcomingEventsResourceDto; + expect(before.data?.incomeTotalsByCurrency).toEqual([ + { currency: 'RUB', value: '6' }, + ]); + const calendar = await prisma.tbankInstrumentSource.findFirstOrThrow({ + where: { kind: 'bond-events' }, + }); + const forecast = await prisma.tbankForecast.findFirstOrThrow(); + fetchMock.mockImplementation(() => + Promise.resolve(Response.json({ ...response, ...invalid })), + ); + await app + .get(CatalogEnrichmentService) + .requestMany('owner', 'owner-revision', 'bond', [bondUid], true, [ + 'bond-events', + ]); + await settled(); + const after = (await events().expect(200)) + .body as UpcomingEventsResourceDto; + expect(after.data).toEqual(before.data); + expect(after.meta.lastSuccessfulAt).toBe(before.meta.lastSuccessfulAt); + expect(after.meta.sync.state).toBe('failed'); + expect( + await prisma.tbankInstrumentSource.findFirstOrThrow({ + where: { kind: 'bond-events' }, + }), + ).toEqual(calendar); + expect(await prisma.tbankForecast.findFirstOrThrow()).toEqual(forecast); + } finally { + fetchMock.mockRestore(); + } + }, + ); + + it('defaults to a 30-day window', async () => { await portfolio([position(bondUid, '3', 'BOND')]); - moex.bondEvents.mockRejectedValueOnce(new Error('missing MOEX block')); await events(false).expect(200); await settled(); - expect( - ((await events().expect(200)).body as UpcomingEventsResourceDto).data, - ).toBeNull(); await get('brokerage-accounts/account/events?from=2099-02-01').expect(200); await settled(); const result = (