From 9f85dc5ddeec09df9cf279d3fd269ba4b1a0dbe2 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Thu, 25 Jun 2026 20:30:45 +0300 Subject: [PATCH] refactor(backend): introduce domain exception hierarchy - Add DomainException base class extending HttpException - Add EntityNotFoundException, PortfolioAccessDeniedException, MoexApiException, TBankApiException, TBankNotConfiguredException - Update HttpExceptionFilter with unhandled error logging - Replace generic NestJS exceptions in services with domain exceptions - Update all affected tests - 117 tests pass, build succeeds --- .../src/common/exceptions/domain.exception.ts | 8 +++++ .../exceptions/entity-not-found.exception.ts | 8 +++++ .../common/exceptions/moex-api.exception.ts | 8 +++++ .../exceptions/portfolio-access.exception.ts | 8 +++++ .../common/exceptions/tbank-api.exception.ts | 14 ++++++++ .../common/filters/http-exception.filter.ts | 5 ++- .../src/modules/bonds/bonds.service.spec.ts | 6 ++-- .../src/modules/bonds/bonds.service.ts | 7 ++-- .../portfolio/portfolio.service.spec.ts | 19 +++++----- .../modules/portfolio/portfolio.service.ts | 36 +++++++++---------- .../src/modules/shares/shares.service.spec.ts | 6 ++-- .../src/modules/shares/shares.service.ts | 7 ++-- .../services/broker-analytics.service.spec.ts | 4 +-- .../services/broker-analytics.service.ts | 11 +++--- .../services/broker-events.service.spec.ts | 4 +-- .../tbank/services/broker-events.service.ts | 5 +-- .../broker-operations.service.spec.ts | 4 +-- .../services/broker-operations.service.ts | 5 +-- .../services/broker-portfolio.service.spec.ts | 6 ++-- .../services/broker-portfolio.service.ts | 7 ++-- .../services/tbank-client.service.spec.ts | 8 ++--- .../tbank/services/tbank-client.service.ts | 21 +++++------ 22 files changed, 128 insertions(+), 79 deletions(-) create mode 100644 apps/backend/src/common/exceptions/domain.exception.ts create mode 100644 apps/backend/src/common/exceptions/entity-not-found.exception.ts create mode 100644 apps/backend/src/common/exceptions/moex-api.exception.ts create mode 100644 apps/backend/src/common/exceptions/portfolio-access.exception.ts create mode 100644 apps/backend/src/common/exceptions/tbank-api.exception.ts diff --git a/apps/backend/src/common/exceptions/domain.exception.ts b/apps/backend/src/common/exceptions/domain.exception.ts new file mode 100644 index 0000000..a87dfe8 --- /dev/null +++ b/apps/backend/src/common/exceptions/domain.exception.ts @@ -0,0 +1,8 @@ +import { HttpException, HttpStatus } from '@nestjs/common'; + +export abstract class DomainException extends HttpException { + constructor(message: string, status: HttpStatus) { + super(message, status); + this.name = this.constructor.name; + } +} diff --git a/apps/backend/src/common/exceptions/entity-not-found.exception.ts b/apps/backend/src/common/exceptions/entity-not-found.exception.ts new file mode 100644 index 0000000..4e2bfbd --- /dev/null +++ b/apps/backend/src/common/exceptions/entity-not-found.exception.ts @@ -0,0 +1,8 @@ +import { HttpStatus } from '@nestjs/common'; +import { DomainException } from './domain.exception'; + +export class EntityNotFoundException extends DomainException { + constructor(entity: string, id: string | number) { + super(`${entity} ${id} not found`, HttpStatus.NOT_FOUND); + } +} diff --git a/apps/backend/src/common/exceptions/moex-api.exception.ts b/apps/backend/src/common/exceptions/moex-api.exception.ts new file mode 100644 index 0000000..933ebc2 --- /dev/null +++ b/apps/backend/src/common/exceptions/moex-api.exception.ts @@ -0,0 +1,8 @@ +import { HttpStatus } from '@nestjs/common'; +import { DomainException } from './domain.exception'; + +export class MoexApiException extends DomainException { + constructor(message: string) { + super(`MOEX API error: ${message}`, HttpStatus.BAD_GATEWAY); + } +} diff --git a/apps/backend/src/common/exceptions/portfolio-access.exception.ts b/apps/backend/src/common/exceptions/portfolio-access.exception.ts new file mode 100644 index 0000000..2d33779 --- /dev/null +++ b/apps/backend/src/common/exceptions/portfolio-access.exception.ts @@ -0,0 +1,8 @@ +import { HttpStatus } from '@nestjs/common'; +import { DomainException } from './domain.exception'; + +export class PortfolioAccessDeniedException extends DomainException { + constructor(portfolioId: number) { + super(`Access denied to portfolio ${portfolioId}`, HttpStatus.FORBIDDEN); + } +} diff --git a/apps/backend/src/common/exceptions/tbank-api.exception.ts b/apps/backend/src/common/exceptions/tbank-api.exception.ts new file mode 100644 index 0000000..dcd8c7c --- /dev/null +++ b/apps/backend/src/common/exceptions/tbank-api.exception.ts @@ -0,0 +1,14 @@ +import { HttpStatus } from '@nestjs/common'; +import { DomainException } from './domain.exception'; + +export class TBankApiException extends DomainException { + constructor(message: string) { + super(`T-Bank API error: ${message}`, HttpStatus.BAD_GATEWAY); + } +} + +export class TBankNotConfiguredException extends DomainException { + constructor() { + super('T-Bank integration is not configured', HttpStatus.SERVICE_UNAVAILABLE); + } +} diff --git a/apps/backend/src/common/filters/http-exception.filter.ts b/apps/backend/src/common/filters/http-exception.filter.ts index 04eec41..e44966f 100644 --- a/apps/backend/src/common/filters/http-exception.filter.ts +++ b/apps/backend/src/common/filters/http-exception.filter.ts @@ -1,8 +1,10 @@ -import { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus } from '@nestjs/common'; +import { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus, Logger } from '@nestjs/common'; import { Response } from 'express'; @Catch() export class HttpExceptionFilter implements ExceptionFilter { + private readonly logger = new Logger(HttpExceptionFilter.name); + catch(exception: unknown, host: ArgumentsHost) { const ctx = host.switchToHttp(); const response = ctx.getResponse(); @@ -25,6 +27,7 @@ export class HttpExceptionFilter implements ExceptionFilter { } } else if (exception instanceof Error) { message = exception.message; + this.logger.error(`Unhandled exception: ${exception.message}`, exception.stack); } response.status(status).json({ diff --git a/apps/backend/src/modules/bonds/bonds.service.spec.ts b/apps/backend/src/modules/bonds/bonds.service.spec.ts index 6cd3f62..23d3aba 100644 --- a/apps/backend/src/modules/bonds/bonds.service.spec.ts +++ b/apps/backend/src/modules/bonds/bonds.service.spec.ts @@ -1,5 +1,5 @@ -import { NotFoundException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; +import { EntityNotFoundException } from '../../common/exceptions/entity-not-found.exception'; import { BondsService } from './bonds.service'; import { MoexClientService } from '../moex-client/moex-client.service'; import { CacheService } from '../cache/cache.service'; @@ -135,10 +135,10 @@ describe('BondsService', () => { expect(result.data.marketData.updatedAt).toMatch(/T18:45:00$/); }); - it('throws NotFoundException when bond data is missing', async () => { + it('throws EntityNotFoundException when bond data is missing', async () => { vi.mocked(moexClient.getBondData).mockResolvedValue(null); - await expect(service.getBond('UNKNOWN')).rejects.toBeInstanceOf(NotFoundException); + await expect(service.getBond('UNKNOWN')).rejects.toBeInstanceOf(EntityNotFoundException); expect(cache.getOrFetch).toHaveBeenCalledTimes(1); expect(moexClient.getBondMarketData).not.toHaveBeenCalled(); }); diff --git a/apps/backend/src/modules/bonds/bonds.service.ts b/apps/backend/src/modules/bonds/bonds.service.ts index 407451b..3d6eb94 100644 --- a/apps/backend/src/modules/bonds/bonds.service.ts +++ b/apps/backend/src/modules/bonds/bonds.service.ts @@ -1,7 +1,8 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { MoexClientService } from '../moex-client/moex-client.service'; import { CacheService } from '../cache/cache.service'; import { ApiEnvelopePayload } from '../../common/dto/api-response.dto'; +import { EntityNotFoundException } from '../../common/exceptions/entity-not-found.exception'; @Injectable() export class BondsService { @@ -23,7 +24,7 @@ export class BondsService { ); if (!bond) { - throw new NotFoundException(`Bond ${secid} not found`); + throw new EntityNotFoundException('Bond', secid); } const { data: mkt } = await this.cache.getOrFetch( @@ -89,7 +90,7 @@ export class BondsService { ); if (!mkt) { - throw new NotFoundException(`Market data for bond ${secid} not found`); + throw new EntityNotFoundException('MarketData', `bond ${secid}`); } return new ApiEnvelopePayload( diff --git a/apps/backend/src/modules/portfolio/portfolio.service.spec.ts b/apps/backend/src/modules/portfolio/portfolio.service.spec.ts index e98ba63..67c8184 100644 --- a/apps/backend/src/modules/portfolio/portfolio.service.spec.ts +++ b/apps/backend/src/modules/portfolio/portfolio.service.spec.ts @@ -5,7 +5,8 @@ import { PrismaService } from '../prisma/prisma.service'; import { MoexClientService } from '../moex-client/moex-client.service'; import { CacheService } from '../cache/cache.service'; import configuration from '../../config/configuration'; -import { ForbiddenException, NotFoundException } from '@nestjs/common'; +import { EntityNotFoundException } from '../../common/exceptions/entity-not-found.exception'; +import { PortfolioAccessDeniedException } from '../../common/exceptions/portfolio-access.exception'; describe('PortfolioService', () => { let service: PortfolioService; @@ -204,14 +205,14 @@ describe('PortfolioService', () => { }); describe('findOne', () => { - it('should throw NotFoundException for non-existent portfolio', async () => { + it('should throw EntityNotFoundException for non-existent portfolio', async () => { vi.mocked(prisma.portfolio.findUnique).mockResolvedValue(null); - await expect(service.findOne(1, 999)).rejects.toThrow(NotFoundException); + await expect(service.findOne(1, 999)).rejects.toThrow(EntityNotFoundException); }); - it('should throw ForbiddenException for wrong user', async () => { + it('should throw PortfolioAccessDeniedException for wrong user', async () => { vi.mocked(prisma.portfolio.findUnique).mockResolvedValue(mockPortfolio({ userId: 2 }) as any); - await expect(service.findOne(1, 1)).rejects.toThrow(ForbiddenException); + await expect(service.findOne(1, 1)).rejects.toThrow(PortfolioAccessDeniedException); }); it('should return portfolio with enriched positions and analytics summary', async () => { @@ -525,16 +526,16 @@ describe('PortfolioService', () => { expect(result.summary.weightedYield).toBeCloseTo(0, 1); }); - it('should throw ForbiddenException if portfolio belongs to another user', async () => { + it('should throw PortfolioAccessDeniedException if portfolio belongs to another user', async () => { vi.mocked(prisma.portfolio.findUnique).mockResolvedValue(mockPortfolio({ userId: 2 }) as any); - await expect(service.getAnalytics(1, 1)).rejects.toThrow(ForbiddenException); + await expect(service.getAnalytics(1, 1)).rejects.toThrow(PortfolioAccessDeniedException); }); - it('should throw NotFoundException if portfolio does not exist', async () => { + it('should throw EntityNotFoundException if portfolio does not exist', async () => { vi.mocked(prisma.portfolio.findUnique).mockResolvedValue(null); - await expect(service.getAnalytics(1, 999)).rejects.toThrow(NotFoundException); + await expect(service.getAnalytics(1, 999)).rejects.toThrow(EntityNotFoundException); }); }); }); diff --git a/apps/backend/src/modules/portfolio/portfolio.service.ts b/apps/backend/src/modules/portfolio/portfolio.service.ts index 4add5ae..39246f6 100644 --- a/apps/backend/src/modules/portfolio/portfolio.service.ts +++ b/apps/backend/src/modules/portfolio/portfolio.service.ts @@ -1,12 +1,12 @@ import { Injectable, - NotFoundException, BadRequestException, - ForbiddenException, } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { MoexClientService } from '../moex-client/moex-client.service'; import { CacheService } from '../cache/cache.service'; +import { EntityNotFoundException } from '../../common/exceptions/entity-not-found.exception'; +import { PortfolioAccessDeniedException } from '../../common/exceptions/portfolio-access.exception'; import type { MoexShareMarketData, MoexBondPositionData, @@ -135,8 +135,8 @@ export class PortfolioService { include: { positions: true }, }); - if (!portfolio) throw new NotFoundException(`Portfolio ${id} not found`); - if (portfolio.userId !== userId) throw new ForbiddenException('Access denied'); + if (!portfolio) throw new EntityNotFoundException('Portfolio', id); + if (portfolio.userId !== userId) throw new PortfolioAccessDeniedException(id); const positionsWithPrices = await this.enrichPositions(portfolio.positions, id); @@ -168,8 +168,8 @@ export class PortfolioService { async update(userId: number, id: number, dto: UpdatePortfolioDto) { const portfolio = await this.prisma.portfolio.findUnique({ where: { id } }); - if (!portfolio) throw new NotFoundException(`Portfolio ${id} not found`); - if (portfolio.userId !== userId) throw new ForbiddenException('Access denied'); + if (!portfolio) throw new EntityNotFoundException('Portfolio', id); + if (portfolio.userId !== userId) throw new PortfolioAccessDeniedException(id); const updated = await this.prisma.portfolio.update({ where: { id }, @@ -189,8 +189,8 @@ export class PortfolioService { async remove(userId: number, id: number) { const portfolio = await this.prisma.portfolio.findUnique({ where: { id } }); - if (!portfolio) throw new NotFoundException(`Portfolio ${id} not found`); - if (portfolio.userId !== userId) throw new ForbiddenException('Access denied'); + if (!portfolio) throw new EntityNotFoundException('Portfolio', id); + if (portfolio.userId !== userId) throw new PortfolioAccessDeniedException(id); await this.prisma.portfolio.delete({ where: { id } }); } @@ -200,8 +200,8 @@ export class PortfolioService { where: { id: portfolioId }, include: { positions: true }, }); - if (!portfolio) throw new NotFoundException(`Portfolio ${portfolioId} not found`); - if (portfolio.userId !== userId) throw new ForbiddenException('Access denied'); + if (!portfolio) throw new EntityNotFoundException('Portfolio', portfolioId); + if (portfolio.userId !== userId) throw new PortfolioAccessDeniedException(portfolioId); const exists = portfolio.positions.find((p) => p.secid === dto.secid); if (exists) @@ -235,12 +235,12 @@ export class PortfolioService { dto: UpdatePositionDto, ) { const portfolio = await this.prisma.portfolio.findUnique({ where: { id: portfolioId } }); - if (!portfolio) throw new NotFoundException(`Portfolio ${portfolioId} not found`); - if (portfolio.userId !== userId) throw new ForbiddenException('Access denied'); + if (!portfolio) throw new EntityNotFoundException('Portfolio', portfolioId); + if (portfolio.userId !== userId) throw new PortfolioAccessDeniedException(portfolioId); const position = await this.prisma.position.findUnique({ where: { id: positionId } }); if (!position || position.portfolioId !== portfolioId) { - throw new NotFoundException(`Position ${positionId} not found`); + throw new EntityNotFoundException('Position', positionId); } return this.prisma.position.update({ @@ -257,12 +257,12 @@ export class PortfolioService { async removePosition(userId: number, portfolioId: number, positionId: number) { const portfolio = await this.prisma.portfolio.findUnique({ where: { id: portfolioId } }); - if (!portfolio) throw new NotFoundException(`Portfolio ${portfolioId} not found`); - if (portfolio.userId !== userId) throw new ForbiddenException('Access denied'); + if (!portfolio) throw new EntityNotFoundException('Portfolio', portfolioId); + if (portfolio.userId !== userId) throw new PortfolioAccessDeniedException(portfolioId); const position = await this.prisma.position.findUnique({ where: { id: positionId } }); if (!position || position.portfolioId !== portfolioId) { - throw new NotFoundException(`Position ${positionId} not found`); + throw new EntityNotFoundException('Position', positionId); } await this.prisma.position.delete({ where: { id: positionId } }); @@ -517,8 +517,8 @@ export class PortfolioService { async getAnalytics(userId: number, portfolioId: number): Promise { const portfolio = await this.prisma.portfolio.findUnique({ where: { id: portfolioId } }); - if (!portfolio) throw new NotFoundException(`Portfolio ${portfolioId} not found`); - if (portfolio.userId !== userId) throw new ForbiddenException(); + if (!portfolio) throw new EntityNotFoundException('Portfolio', portfolioId); + if (portfolio.userId !== userId) throw new PortfolioAccessDeniedException(portfolioId); const enrichedPositions = await this.getPositionsWithPrices(portfolioId); diff --git a/apps/backend/src/modules/shares/shares.service.spec.ts b/apps/backend/src/modules/shares/shares.service.spec.ts index d261af7..a3a6f5f 100644 --- a/apps/backend/src/modules/shares/shares.service.spec.ts +++ b/apps/backend/src/modules/shares/shares.service.spec.ts @@ -1,5 +1,5 @@ -import { NotFoundException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; +import { EntityNotFoundException } from '../../common/exceptions/entity-not-found.exception'; import { SharesService } from './shares.service'; import { MoexClientService } from '../moex-client/moex-client.service'; import { CacheService } from '../cache/cache.service'; @@ -109,7 +109,7 @@ describe('SharesService', () => { expect(result.data.marketData.updatedAt).toMatch(/T18:45:00$/); }); - it('throws NotFoundException for non-share security', async () => { + it('throws EntityNotFoundException for non-share security', async () => { vi.mocked(moexClient.getSecurityDescription).mockResolvedValue({ secid: 'SU26238RMFS5', isin: 'RU000A1038V6', @@ -129,7 +129,7 @@ describe('SharesService', () => { eveningSession: false, }); - await expect(service.getShare('SU26238RMFS5')).rejects.toBeInstanceOf(NotFoundException); + await expect(service.getShare('SU26238RMFS5')).rejects.toBeInstanceOf(EntityNotFoundException); expect(cache.getOrFetch).not.toHaveBeenCalled(); }); }); diff --git a/apps/backend/src/modules/shares/shares.service.ts b/apps/backend/src/modules/shares/shares.service.ts index b9032d2..b12fe27 100644 --- a/apps/backend/src/modules/shares/shares.service.ts +++ b/apps/backend/src/modules/shares/shares.service.ts @@ -1,7 +1,8 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { MoexClientService } from '../moex-client/moex-client.service'; import { CacheService } from '../cache/cache.service'; import { ApiEnvelopePayload } from '../../common/dto/api-response.dto'; +import { EntityNotFoundException } from '../../common/exceptions/entity-not-found.exception'; @Injectable() export class SharesService { @@ -20,7 +21,7 @@ export class SharesService { desc.type === 'preferred_share' ) ) { - throw new NotFoundException(`Share ${secid} not found`); + throw new EntityNotFoundException('Share', secid); } const { @@ -83,7 +84,7 @@ export class SharesService { ); if (!marketData) { - throw new NotFoundException(`Market data for ${secid} not found`); + throw new EntityNotFoundException('MarketData', secid); } return new ApiEnvelopePayload( diff --git a/apps/backend/src/modules/tbank/services/broker-analytics.service.spec.ts b/apps/backend/src/modules/tbank/services/broker-analytics.service.spec.ts index 3997603..9188bc3 100644 --- a/apps/backend/src/modules/tbank/services/broker-analytics.service.spec.ts +++ b/apps/backend/src/modules/tbank/services/broker-analytics.service.spec.ts @@ -1,5 +1,5 @@ -import { NotFoundException } from '@nestjs/common'; import { CacheService } from '../../cache/cache.service'; +import { EntityNotFoundException } from '../../../common/exceptions/entity-not-found.exception'; import { BrokerAccountsService } from './broker-accounts.service'; import { BrokerAnalyticsService } from './broker-analytics.service'; import { PrismaService } from '../../prisma/prisma.service'; @@ -40,7 +40,7 @@ describe('BrokerAnalyticsService', () => { vi.mocked(accounts.findById).mockResolvedValue(null); const service = new BrokerAnalyticsService(prisma, accounts, cache); - await expect(service.getAnalytics('missing')).rejects.toThrow(NotFoundException); + await expect(service.getAnalytics('missing')).rejects.toThrow(EntityNotFoundException); }); it('returns zeros for account with no operations', async () => { diff --git a/apps/backend/src/modules/tbank/services/broker-analytics.service.ts b/apps/backend/src/modules/tbank/services/broker-analytics.service.ts index d21bb03..952a7a4 100644 --- a/apps/backend/src/modules/tbank/services/broker-analytics.service.ts +++ b/apps/backend/src/modules/tbank/services/broker-analytics.service.ts @@ -1,10 +1,11 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { PrismaService } from '../../prisma/prisma.service'; import { CacheService } from '../../cache/cache.service'; -import { ApiEnvelopePayload } from '../../../common/dto/api-response.dto'; -import { BrokerAnalyticsDto } from '../dto/broker-analytics-response.dto'; -import { BrokerAccountsService } from './broker-accounts.service'; import { TBANK_CACHE_KEYS } from '../tbank.config'; +import { BrokerAnalyticsDto } from '../dto/broker-analytics-response.dto'; +import { EntityNotFoundException } from '../../../common/exceptions/entity-not-found.exception'; +import { ApiEnvelopePayload } from '../../../common/dto/api-response.dto'; +import { BrokerAccountsService } from './broker-accounts.service'; const DEPOSIT_TYPES = new Set([ 'OPERATION_TYPE_INPUT', @@ -44,7 +45,7 @@ export class BrokerAnalyticsService { async getAnalytics(accountId: string): Promise> { const account = await this.accountsService.findById(accountId); - if (!account) throw new NotFoundException('Broker account not found'); + if (!account) throw new EntityNotFoundException('BrokerAccount', accountId); const result = await this.cacheService.getOrFetch( TBANK_CACHE_KEYS.analytics, diff --git a/apps/backend/src/modules/tbank/services/broker-events.service.spec.ts b/apps/backend/src/modules/tbank/services/broker-events.service.spec.ts index d5edd12..acee4a9 100644 --- a/apps/backend/src/modules/tbank/services/broker-events.service.spec.ts +++ b/apps/backend/src/modules/tbank/services/broker-events.service.spec.ts @@ -1,5 +1,5 @@ -import { NotFoundException } from '@nestjs/common'; import { CacheService } from '../../cache/cache.service'; +import { EntityNotFoundException } from '../../../common/exceptions/entity-not-found.exception'; import { MoexClientService } from '../../moex-client/moex-client.service'; import { BrokerAccountsService } from './broker-accounts.service'; import { BrokerEventsService } from './broker-events.service'; @@ -45,7 +45,7 @@ describe('BrokerEventsService', () => { const service = new BrokerEventsService(accounts, portfolio, moex, operations, cache); await expect( service.getEvents('missing', { from: '2026-06-01', to: '2026-07-01' }), - ).rejects.toThrow(NotFoundException); + ).rejects.toThrow(EntityNotFoundException); }); it('returns empty events for account with no positions', async () => { diff --git a/apps/backend/src/modules/tbank/services/broker-events.service.ts b/apps/backend/src/modules/tbank/services/broker-events.service.ts index 8c3b7d4..81fe004 100644 --- a/apps/backend/src/modules/tbank/services/broker-events.service.ts +++ b/apps/backend/src/modules/tbank/services/broker-events.service.ts @@ -1,7 +1,8 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { CacheService } from '../../cache/cache.service'; import { MoexClientService } from '../../moex-client/moex-client.service'; import { ApiEnvelopePayload } from '../../../common/dto/api-response.dto'; +import { EntityNotFoundException } from '../../../common/exceptions/entity-not-found.exception'; import { TBANK_CACHE_KEYS } from '../tbank.config'; import { mapQuotationToNumber } from '../mappers/money.mapper'; import type { @@ -55,7 +56,7 @@ export class BrokerEventsService { query: BrokerEventsQuery, ): Promise> { const account = await this.accountsService.findById(accountId); - if (!account) throw new NotFoundException('Broker account not found'); + if (!account) throw new EntityNotFoundException('BrokerAccount', accountId); const eventTypes = this.parseEventTypes(query.types); const eventTypeKey = Array.from(eventTypes).join(','); diff --git a/apps/backend/src/modules/tbank/services/broker-operations.service.spec.ts b/apps/backend/src/modules/tbank/services/broker-operations.service.spec.ts index 384e810..f36b405 100644 --- a/apps/backend/src/modules/tbank/services/broker-operations.service.spec.ts +++ b/apps/backend/src/modules/tbank/services/broker-operations.service.spec.ts @@ -1,5 +1,5 @@ -import { NotFoundException } from '@nestjs/common'; import { CacheService } from '../../cache/cache.service'; +import { EntityNotFoundException } from '../../../common/exceptions/entity-not-found.exception'; import { BrokerAccountsService } from './broker-accounts.service'; import { BrokerOperationsService } from './broker-operations.service'; import { TBankClientService } from './tbank-client.service'; @@ -17,7 +17,7 @@ describe('BrokerOperationsService', () => { vi.mocked(accounts.findById).mockResolvedValue(null); const service = new BrokerOperationsService(accounts, client, cache); - await expect(service.getOperations('missing', {})).rejects.toThrow(NotFoundException); + await expect(service.getOperations('missing', {})).rejects.toThrow(EntityNotFoundException); }); it('builds cursor request and maps operation page', async () => { diff --git a/apps/backend/src/modules/tbank/services/broker-operations.service.ts b/apps/backend/src/modules/tbank/services/broker-operations.service.ts index 5ab575f..f5ea5f1 100644 --- a/apps/backend/src/modules/tbank/services/broker-operations.service.ts +++ b/apps/backend/src/modules/tbank/services/broker-operations.service.ts @@ -1,9 +1,10 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { CacheService } from '../../cache/cache.service'; import { ApiEnvelopePayload } from '../../../common/dto/api-response.dto'; import type { BrokerOperationQueryDto } from '../dto/broker-operation-query.dto'; import { mapOperationsPage } from '../mappers/operation.mapper'; import { TBANK_CACHE_KEYS } from '../tbank.config'; +import { EntityNotFoundException } from '../../../common/exceptions/entity-not-found.exception'; import type { BrokerOperationsPage } from '../types/broker.types'; import type { TBankOperationsByCursorResponse } from '../types/tbank-proto.types'; import { BrokerAccountsService } from './broker-accounts.service'; @@ -22,7 +23,7 @@ export class BrokerOperationsService { query: BrokerOperationQueryDto, ): Promise> { const account = await this.accountsService.findById(accountId); - if (!account) throw new NotFoundException('Broker account not found'); + if (!account) throw new EntityNotFoundException('BrokerAccount', accountId); const request = this.buildRequest(accountId, query); const result = await this.cacheService.getOrFetch( diff --git a/apps/backend/src/modules/tbank/services/broker-portfolio.service.spec.ts b/apps/backend/src/modules/tbank/services/broker-portfolio.service.spec.ts index 338eae5..e9b52f8 100644 --- a/apps/backend/src/modules/tbank/services/broker-portfolio.service.spec.ts +++ b/apps/backend/src/modules/tbank/services/broker-portfolio.service.spec.ts @@ -1,5 +1,5 @@ -import { NotFoundException } from '@nestjs/common'; import { CacheService } from '../../cache/cache.service'; +import { EntityNotFoundException } from '../../../common/exceptions/entity-not-found.exception'; import { BrokerAccountsService } from './broker-accounts.service'; import { BrokerInstrumentsService } from './broker-instruments.service'; import { BrokerPortfolioService } from './broker-portfolio.service'; @@ -19,7 +19,7 @@ describe('BrokerPortfolioService', () => { vi.mocked(accounts.findById).mockResolvedValue(null); const service = new BrokerPortfolioService(accounts, instruments, client, cache); - await expect(service.getPortfolio('missing')).rejects.toThrow(NotFoundException); + await expect(service.getPortfolio('missing')).rejects.toThrow(EntityNotFoundException); }); it('fetches portfolio through cache without positions', async () => { @@ -98,7 +98,7 @@ describe('BrokerPortfolioService', () => { vi.mocked(accounts.findById).mockResolvedValue(null); const service = new BrokerPortfolioService(accounts, instruments, client, cache); - await expect(service.getPositions('missing')).rejects.toThrow(NotFoundException); + await expect(service.getPositions('missing')).rejects.toThrow(EntityNotFoundException); }); it('returns first page of positions', async () => { diff --git a/apps/backend/src/modules/tbank/services/broker-portfolio.service.ts b/apps/backend/src/modules/tbank/services/broker-portfolio.service.ts index 7559b44..45244f9 100644 --- a/apps/backend/src/modules/tbank/services/broker-portfolio.service.ts +++ b/apps/backend/src/modules/tbank/services/broker-portfolio.service.ts @@ -1,7 +1,8 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { CacheService } from '../../cache/cache.service'; import { mapBrokerPortfolio, mapBrokerPositionsPage } from '../mappers/portfolio.mapper'; import { TBANK_CACHE_KEYS } from '../tbank.config'; +import { EntityNotFoundException } from '../../../common/exceptions/entity-not-found.exception'; import type { BrokerPortfolio, BrokerPositionsPage } from '../types/broker.types'; import type { TBankInstrument, @@ -25,7 +26,7 @@ export class BrokerPortfolioService { async getPortfolio(accountId: string): Promise> { const account = await this.accountsService.findById(accountId); - if (!account) throw new NotFoundException('Broker account not found'); + if (!account) throw new EntityNotFoundException('BrokerAccount', accountId); const result = await this.cacheService.getOrFetch( TBANK_CACHE_KEYS.portfolio, @@ -51,7 +52,7 @@ export class BrokerPortfolioService { type?: string, ): Promise> { const account = await this.accountsService.findById(accountId); - if (!account) throw new NotFoundException('Broker account not found'); + if (!account) throw new EntityNotFoundException('BrokerAccount', accountId); const result = await this.cacheService.getOrFetch( TBANK_CACHE_KEYS.positions, diff --git a/apps/backend/src/modules/tbank/services/tbank-client.service.spec.ts b/apps/backend/src/modules/tbank/services/tbank-client.service.spec.ts index 09e30ef..953b0cb 100644 --- a/apps/backend/src/modules/tbank/services/tbank-client.service.spec.ts +++ b/apps/backend/src/modules/tbank/services/tbank-client.service.spec.ts @@ -1,5 +1,5 @@ -import { ServiceUnavailableException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; +import { TBankNotConfiguredException } from '../../../common/exceptions/tbank-api.exception'; import { ChannelCredentials, ClientUnaryCall, Metadata, ServiceError, status } from '@grpc/grpc-js'; import { mkdtempSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; @@ -85,7 +85,7 @@ describe('TBankClientService', () => { }, {}, ), - ).rejects.toThrow(ServiceUnavailableException); + ).rejects.toThrow(TBankNotConfiguredException); }); it('wraps grpc errors with status code and tracking id', async () => { @@ -107,9 +107,7 @@ describe('TBankClientService', () => { {}, ), ).rejects.toMatchObject({ - response: expect.objectContaining({ - message: expect.stringContaining('T-Bank upstream error'), - }), + response: expect.stringContaining('T-Bank upstream error'), }); }); diff --git a/apps/backend/src/modules/tbank/services/tbank-client.service.ts b/apps/backend/src/modules/tbank/services/tbank-client.service.ts index 3f68fb2..dea60f3 100644 --- a/apps/backend/src/modules/tbank/services/tbank-client.service.ts +++ b/apps/backend/src/modules/tbank/services/tbank-client.service.ts @@ -1,10 +1,6 @@ -import { - BadGatewayException, - Injectable, - Logger, - ServiceUnavailableException, -} from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; +import { TBankNotConfiguredException, TBankApiException } from '../../../common/exceptions/tbank-api.exception'; import { CallOptions, ChannelCredentials, @@ -79,7 +75,7 @@ export class TBankClientService { createMetadata(): Metadata { const token = this.configService.get('app.tbank.token', ''); if (!token) { - throw new ServiceUnavailableException('T-Bank integration is not configured'); + throw new TBankNotConfiguredException(); } const metadata = new Metadata(); @@ -169,7 +165,7 @@ export class TBankClientService { error instanceof Error ? error.message : String(error) }`, ); - throw new ServiceUnavailableException('T-Bank CA certificate is not readable'); + throw new TBankApiException('T-Bank CA certificate is not readable'); } } @@ -191,10 +187,9 @@ export class TBankClientService { }), ); - return new BadGatewayException({ - message: publicMessage, - trackingId: trackingId ? String(trackingId) : null, - retryAfter: retryAfter ? String(retryAfter) : null, - }); + const detail = [publicMessage, trackingId && `trackingId:${trackingId}`, retryAfter && `retryAfter:${retryAfter}`] + .filter(Boolean) + .join('; '); + return new TBankApiException(detail); } }