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
This commit is contained in:
Sergey Krylov 2026-06-25 20:30:45 +03:00
parent 3b919ecdc6
commit 9f85dc5dde
22 changed files with 128 additions and 79 deletions

View File

@ -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;
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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'; import { Response } from 'express';
@Catch() @Catch()
export class HttpExceptionFilter implements ExceptionFilter { export class HttpExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(HttpExceptionFilter.name);
catch(exception: unknown, host: ArgumentsHost) { catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp(); const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>(); const response = ctx.getResponse<Response>();
@ -25,6 +27,7 @@ export class HttpExceptionFilter implements ExceptionFilter {
} }
} else if (exception instanceof Error) { } else if (exception instanceof Error) {
message = exception.message; message = exception.message;
this.logger.error(`Unhandled exception: ${exception.message}`, exception.stack);
} }
response.status(status).json({ response.status(status).json({

View File

@ -1,5 +1,5 @@
import { NotFoundException } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import { EntityNotFoundException } from '../../common/exceptions/entity-not-found.exception';
import { BondsService } from './bonds.service'; import { BondsService } from './bonds.service';
import { MoexClientService } from '../moex-client/moex-client.service'; import { MoexClientService } from '../moex-client/moex-client.service';
import { CacheService } from '../cache/cache.service'; import { CacheService } from '../cache/cache.service';
@ -135,10 +135,10 @@ describe('BondsService', () => {
expect(result.data.marketData.updatedAt).toMatch(/T18:45:00$/); 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); 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(cache.getOrFetch).toHaveBeenCalledTimes(1);
expect(moexClient.getBondMarketData).not.toHaveBeenCalled(); expect(moexClient.getBondMarketData).not.toHaveBeenCalled();
}); });

View File

@ -1,7 +1,8 @@
import { Injectable, NotFoundException } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { MoexClientService } from '../moex-client/moex-client.service'; import { MoexClientService } from '../moex-client/moex-client.service';
import { CacheService } from '../cache/cache.service'; import { CacheService } from '../cache/cache.service';
import { ApiEnvelopePayload } from '../../common/dto/api-response.dto'; import { ApiEnvelopePayload } from '../../common/dto/api-response.dto';
import { EntityNotFoundException } from '../../common/exceptions/entity-not-found.exception';
@Injectable() @Injectable()
export class BondsService { export class BondsService {
@ -23,7 +24,7 @@ export class BondsService {
); );
if (!bond) { if (!bond) {
throw new NotFoundException(`Bond ${secid} not found`); throw new EntityNotFoundException('Bond', secid);
} }
const { data: mkt } = await this.cache.getOrFetch( const { data: mkt } = await this.cache.getOrFetch(
@ -89,7 +90,7 @@ export class BondsService {
); );
if (!mkt) { if (!mkt) {
throw new NotFoundException(`Market data for bond ${secid} not found`); throw new EntityNotFoundException('MarketData', `bond ${secid}`);
} }
return new ApiEnvelopePayload( return new ApiEnvelopePayload(

View File

@ -5,7 +5,8 @@ import { PrismaService } from '../prisma/prisma.service';
import { MoexClientService } from '../moex-client/moex-client.service'; import { MoexClientService } from '../moex-client/moex-client.service';
import { CacheService } from '../cache/cache.service'; import { CacheService } from '../cache/cache.service';
import configuration from '../../config/configuration'; 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', () => { describe('PortfolioService', () => {
let service: PortfolioService; let service: PortfolioService;
@ -204,14 +205,14 @@ describe('PortfolioService', () => {
}); });
describe('findOne', () => { 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); 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); 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 () => { it('should return portfolio with enriched positions and analytics summary', async () => {
@ -525,16 +526,16 @@ describe('PortfolioService', () => {
expect(result.summary.weightedYield).toBeCloseTo(0, 1); 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); 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); 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);
}); });
}); });
}); });

View File

@ -1,12 +1,12 @@
import { import {
Injectable, Injectable,
NotFoundException,
BadRequestException, BadRequestException,
ForbiddenException,
} from '@nestjs/common'; } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { MoexClientService } from '../moex-client/moex-client.service'; import { MoexClientService } from '../moex-client/moex-client.service';
import { CacheService } from '../cache/cache.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 { import type {
MoexShareMarketData, MoexShareMarketData,
MoexBondPositionData, MoexBondPositionData,
@ -135,8 +135,8 @@ export class PortfolioService {
include: { positions: true }, include: { positions: true },
}); });
if (!portfolio) throw new NotFoundException(`Portfolio ${id} not found`); if (!portfolio) throw new EntityNotFoundException('Portfolio', id);
if (portfolio.userId !== userId) throw new ForbiddenException('Access denied'); if (portfolio.userId !== userId) throw new PortfolioAccessDeniedException(id);
const positionsWithPrices = await this.enrichPositions(portfolio.positions, id); const positionsWithPrices = await this.enrichPositions(portfolio.positions, id);
@ -168,8 +168,8 @@ export class PortfolioService {
async update(userId: number, id: number, dto: UpdatePortfolioDto) { async update(userId: number, id: number, dto: UpdatePortfolioDto) {
const portfolio = await this.prisma.portfolio.findUnique({ where: { id } }); const portfolio = await this.prisma.portfolio.findUnique({ where: { id } });
if (!portfolio) throw new NotFoundException(`Portfolio ${id} not found`); if (!portfolio) throw new EntityNotFoundException('Portfolio', id);
if (portfolio.userId !== userId) throw new ForbiddenException('Access denied'); if (portfolio.userId !== userId) throw new PortfolioAccessDeniedException(id);
const updated = await this.prisma.portfolio.update({ const updated = await this.prisma.portfolio.update({
where: { id }, where: { id },
@ -189,8 +189,8 @@ export class PortfolioService {
async remove(userId: number, id: number) { async remove(userId: number, id: number) {
const portfolio = await this.prisma.portfolio.findUnique({ where: { id } }); const portfolio = await this.prisma.portfolio.findUnique({ where: { id } });
if (!portfolio) throw new NotFoundException(`Portfolio ${id} not found`); if (!portfolio) throw new EntityNotFoundException('Portfolio', id);
if (portfolio.userId !== userId) throw new ForbiddenException('Access denied'); if (portfolio.userId !== userId) throw new PortfolioAccessDeniedException(id);
await this.prisma.portfolio.delete({ where: { id } }); await this.prisma.portfolio.delete({ where: { id } });
} }
@ -200,8 +200,8 @@ export class PortfolioService {
where: { id: portfolioId }, where: { id: portfolioId },
include: { positions: true }, include: { positions: true },
}); });
if (!portfolio) throw new NotFoundException(`Portfolio ${portfolioId} not found`); if (!portfolio) throw new EntityNotFoundException('Portfolio', portfolioId);
if (portfolio.userId !== userId) throw new ForbiddenException('Access denied'); if (portfolio.userId !== userId) throw new PortfolioAccessDeniedException(portfolioId);
const exists = portfolio.positions.find((p) => p.secid === dto.secid); const exists = portfolio.positions.find((p) => p.secid === dto.secid);
if (exists) if (exists)
@ -235,12 +235,12 @@ export class PortfolioService {
dto: UpdatePositionDto, dto: UpdatePositionDto,
) { ) {
const portfolio = await this.prisma.portfolio.findUnique({ where: { id: portfolioId } }); const portfolio = await this.prisma.portfolio.findUnique({ where: { id: portfolioId } });
if (!portfolio) throw new NotFoundException(`Portfolio ${portfolioId} not found`); if (!portfolio) throw new EntityNotFoundException('Portfolio', portfolioId);
if (portfolio.userId !== userId) throw new ForbiddenException('Access denied'); if (portfolio.userId !== userId) throw new PortfolioAccessDeniedException(portfolioId);
const position = await this.prisma.position.findUnique({ where: { id: positionId } }); const position = await this.prisma.position.findUnique({ where: { id: positionId } });
if (!position || position.portfolioId !== portfolioId) { if (!position || position.portfolioId !== portfolioId) {
throw new NotFoundException(`Position ${positionId} not found`); throw new EntityNotFoundException('Position', positionId);
} }
return this.prisma.position.update({ return this.prisma.position.update({
@ -257,12 +257,12 @@ export class PortfolioService {
async removePosition(userId: number, portfolioId: number, positionId: number) { async removePosition(userId: number, portfolioId: number, positionId: number) {
const portfolio = await this.prisma.portfolio.findUnique({ where: { id: portfolioId } }); const portfolio = await this.prisma.portfolio.findUnique({ where: { id: portfolioId } });
if (!portfolio) throw new NotFoundException(`Portfolio ${portfolioId} not found`); if (!portfolio) throw new EntityNotFoundException('Portfolio', portfolioId);
if (portfolio.userId !== userId) throw new ForbiddenException('Access denied'); if (portfolio.userId !== userId) throw new PortfolioAccessDeniedException(portfolioId);
const position = await this.prisma.position.findUnique({ where: { id: positionId } }); const position = await this.prisma.position.findUnique({ where: { id: positionId } });
if (!position || position.portfolioId !== portfolioId) { 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 } }); await this.prisma.position.delete({ where: { id: positionId } });
@ -517,8 +517,8 @@ export class PortfolioService {
async getAnalytics(userId: number, portfolioId: number): Promise<AnalyticsResponseDto> { async getAnalytics(userId: number, portfolioId: number): Promise<AnalyticsResponseDto> {
const portfolio = await this.prisma.portfolio.findUnique({ where: { id: portfolioId } }); const portfolio = await this.prisma.portfolio.findUnique({ where: { id: portfolioId } });
if (!portfolio) throw new NotFoundException(`Portfolio ${portfolioId} not found`); if (!portfolio) throw new EntityNotFoundException('Portfolio', portfolioId);
if (portfolio.userId !== userId) throw new ForbiddenException(); if (portfolio.userId !== userId) throw new PortfolioAccessDeniedException(portfolioId);
const enrichedPositions = await this.getPositionsWithPrices(portfolioId); const enrichedPositions = await this.getPositionsWithPrices(portfolioId);

View File

@ -1,5 +1,5 @@
import { NotFoundException } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import { EntityNotFoundException } from '../../common/exceptions/entity-not-found.exception';
import { SharesService } from './shares.service'; import { SharesService } from './shares.service';
import { MoexClientService } from '../moex-client/moex-client.service'; import { MoexClientService } from '../moex-client/moex-client.service';
import { CacheService } from '../cache/cache.service'; import { CacheService } from '../cache/cache.service';
@ -109,7 +109,7 @@ describe('SharesService', () => {
expect(result.data.marketData.updatedAt).toMatch(/T18:45:00$/); 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({ vi.mocked(moexClient.getSecurityDescription).mockResolvedValue({
secid: 'SU26238RMFS5', secid: 'SU26238RMFS5',
isin: 'RU000A1038V6', isin: 'RU000A1038V6',
@ -129,7 +129,7 @@ describe('SharesService', () => {
eveningSession: false, eveningSession: false,
}); });
await expect(service.getShare('SU26238RMFS5')).rejects.toBeInstanceOf(NotFoundException); await expect(service.getShare('SU26238RMFS5')).rejects.toBeInstanceOf(EntityNotFoundException);
expect(cache.getOrFetch).not.toHaveBeenCalled(); expect(cache.getOrFetch).not.toHaveBeenCalled();
}); });
}); });

View File

@ -1,7 +1,8 @@
import { Injectable, NotFoundException } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { MoexClientService } from '../moex-client/moex-client.service'; import { MoexClientService } from '../moex-client/moex-client.service';
import { CacheService } from '../cache/cache.service'; import { CacheService } from '../cache/cache.service';
import { ApiEnvelopePayload } from '../../common/dto/api-response.dto'; import { ApiEnvelopePayload } from '../../common/dto/api-response.dto';
import { EntityNotFoundException } from '../../common/exceptions/entity-not-found.exception';
@Injectable() @Injectable()
export class SharesService { export class SharesService {
@ -20,7 +21,7 @@ export class SharesService {
desc.type === 'preferred_share' desc.type === 'preferred_share'
) )
) { ) {
throw new NotFoundException(`Share ${secid} not found`); throw new EntityNotFoundException('Share', secid);
} }
const { const {
@ -83,7 +84,7 @@ export class SharesService {
); );
if (!marketData) { if (!marketData) {
throw new NotFoundException(`Market data for ${secid} not found`); throw new EntityNotFoundException('MarketData', secid);
} }
return new ApiEnvelopePayload( return new ApiEnvelopePayload(

View File

@ -1,5 +1,5 @@
import { NotFoundException } from '@nestjs/common';
import { CacheService } from '../../cache/cache.service'; import { CacheService } from '../../cache/cache.service';
import { EntityNotFoundException } from '../../../common/exceptions/entity-not-found.exception';
import { BrokerAccountsService } from './broker-accounts.service'; import { BrokerAccountsService } from './broker-accounts.service';
import { BrokerAnalyticsService } from './broker-analytics.service'; import { BrokerAnalyticsService } from './broker-analytics.service';
import { PrismaService } from '../../prisma/prisma.service'; import { PrismaService } from '../../prisma/prisma.service';
@ -40,7 +40,7 @@ describe('BrokerAnalyticsService', () => {
vi.mocked(accounts.findById).mockResolvedValue(null); vi.mocked(accounts.findById).mockResolvedValue(null);
const service = new BrokerAnalyticsService(prisma, accounts, cache); 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 () => { it('returns zeros for account with no operations', async () => {

View File

@ -1,10 +1,11 @@
import { Injectable, NotFoundException } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service'; import { PrismaService } from '../../prisma/prisma.service';
import { CacheService } from '../../cache/cache.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 { 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([ const DEPOSIT_TYPES = new Set([
'OPERATION_TYPE_INPUT', 'OPERATION_TYPE_INPUT',
@ -44,7 +45,7 @@ export class BrokerAnalyticsService {
async getAnalytics(accountId: string): Promise<ApiEnvelopePayload<BrokerAnalyticsDto>> { async getAnalytics(accountId: string): Promise<ApiEnvelopePayload<BrokerAnalyticsDto>> {
const account = await this.accountsService.findById(accountId); 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( const result = await this.cacheService.getOrFetch(
TBANK_CACHE_KEYS.analytics, TBANK_CACHE_KEYS.analytics,

View File

@ -1,5 +1,5 @@
import { NotFoundException } from '@nestjs/common';
import { CacheService } from '../../cache/cache.service'; import { CacheService } from '../../cache/cache.service';
import { EntityNotFoundException } from '../../../common/exceptions/entity-not-found.exception';
import { MoexClientService } from '../../moex-client/moex-client.service'; import { MoexClientService } from '../../moex-client/moex-client.service';
import { BrokerAccountsService } from './broker-accounts.service'; import { BrokerAccountsService } from './broker-accounts.service';
import { BrokerEventsService } from './broker-events.service'; import { BrokerEventsService } from './broker-events.service';
@ -45,7 +45,7 @@ describe('BrokerEventsService', () => {
const service = new BrokerEventsService(accounts, portfolio, moex, operations, cache); const service = new BrokerEventsService(accounts, portfolio, moex, operations, cache);
await expect( await expect(
service.getEvents('missing', { from: '2026-06-01', to: '2026-07-01' }), 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 () => { it('returns empty events for account with no positions', async () => {

View File

@ -1,7 +1,8 @@
import { Injectable, NotFoundException } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { CacheService } from '../../cache/cache.service'; import { CacheService } from '../../cache/cache.service';
import { MoexClientService } from '../../moex-client/moex-client.service'; import { MoexClientService } from '../../moex-client/moex-client.service';
import { ApiEnvelopePayload } from '../../../common/dto/api-response.dto'; 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 { TBANK_CACHE_KEYS } from '../tbank.config';
import { mapQuotationToNumber } from '../mappers/money.mapper'; import { mapQuotationToNumber } from '../mappers/money.mapper';
import type { import type {
@ -55,7 +56,7 @@ export class BrokerEventsService {
query: BrokerEventsQuery, query: BrokerEventsQuery,
): Promise<ApiEnvelopePayload<BrokerEventsData>> { ): Promise<ApiEnvelopePayload<BrokerEventsData>> {
const account = await this.accountsService.findById(accountId); 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 eventTypes = this.parseEventTypes(query.types);
const eventTypeKey = Array.from(eventTypes).join(','); const eventTypeKey = Array.from(eventTypes).join(',');

View File

@ -1,5 +1,5 @@
import { NotFoundException } from '@nestjs/common';
import { CacheService } from '../../cache/cache.service'; import { CacheService } from '../../cache/cache.service';
import { EntityNotFoundException } from '../../../common/exceptions/entity-not-found.exception';
import { BrokerAccountsService } from './broker-accounts.service'; import { BrokerAccountsService } from './broker-accounts.service';
import { BrokerOperationsService } from './broker-operations.service'; import { BrokerOperationsService } from './broker-operations.service';
import { TBankClientService } from './tbank-client.service'; import { TBankClientService } from './tbank-client.service';
@ -17,7 +17,7 @@ describe('BrokerOperationsService', () => {
vi.mocked(accounts.findById).mockResolvedValue(null); vi.mocked(accounts.findById).mockResolvedValue(null);
const service = new BrokerOperationsService(accounts, client, cache); 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 () => { it('builds cursor request and maps operation page', async () => {

View File

@ -1,9 +1,10 @@
import { Injectable, NotFoundException } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { CacheService } from '../../cache/cache.service'; import { CacheService } from '../../cache/cache.service';
import { ApiEnvelopePayload } from '../../../common/dto/api-response.dto'; import { ApiEnvelopePayload } from '../../../common/dto/api-response.dto';
import type { BrokerOperationQueryDto } from '../dto/broker-operation-query.dto'; import type { BrokerOperationQueryDto } from '../dto/broker-operation-query.dto';
import { mapOperationsPage } from '../mappers/operation.mapper'; import { mapOperationsPage } from '../mappers/operation.mapper';
import { TBANK_CACHE_KEYS } from '../tbank.config'; 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 { BrokerOperationsPage } from '../types/broker.types';
import type { TBankOperationsByCursorResponse } from '../types/tbank-proto.types'; import type { TBankOperationsByCursorResponse } from '../types/tbank-proto.types';
import { BrokerAccountsService } from './broker-accounts.service'; import { BrokerAccountsService } from './broker-accounts.service';
@ -22,7 +23,7 @@ export class BrokerOperationsService {
query: BrokerOperationQueryDto, query: BrokerOperationQueryDto,
): Promise<ApiEnvelopePayload<BrokerOperationsPage>> { ): Promise<ApiEnvelopePayload<BrokerOperationsPage>> {
const account = await this.accountsService.findById(accountId); 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 request = this.buildRequest(accountId, query);
const result = await this.cacheService.getOrFetch( const result = await this.cacheService.getOrFetch(

View File

@ -1,5 +1,5 @@
import { NotFoundException } from '@nestjs/common';
import { CacheService } from '../../cache/cache.service'; import { CacheService } from '../../cache/cache.service';
import { EntityNotFoundException } from '../../../common/exceptions/entity-not-found.exception';
import { BrokerAccountsService } from './broker-accounts.service'; import { BrokerAccountsService } from './broker-accounts.service';
import { BrokerInstrumentsService } from './broker-instruments.service'; import { BrokerInstrumentsService } from './broker-instruments.service';
import { BrokerPortfolioService } from './broker-portfolio.service'; import { BrokerPortfolioService } from './broker-portfolio.service';
@ -19,7 +19,7 @@ describe('BrokerPortfolioService', () => {
vi.mocked(accounts.findById).mockResolvedValue(null); vi.mocked(accounts.findById).mockResolvedValue(null);
const service = new BrokerPortfolioService(accounts, instruments, client, cache); 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 () => { it('fetches portfolio through cache without positions', async () => {
@ -98,7 +98,7 @@ describe('BrokerPortfolioService', () => {
vi.mocked(accounts.findById).mockResolvedValue(null); vi.mocked(accounts.findById).mockResolvedValue(null);
const service = new BrokerPortfolioService(accounts, instruments, client, cache); 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 () => { it('returns first page of positions', async () => {

View File

@ -1,7 +1,8 @@
import { Injectable, NotFoundException } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { CacheService } from '../../cache/cache.service'; import { CacheService } from '../../cache/cache.service';
import { mapBrokerPortfolio, mapBrokerPositionsPage } from '../mappers/portfolio.mapper'; import { mapBrokerPortfolio, mapBrokerPositionsPage } from '../mappers/portfolio.mapper';
import { TBANK_CACHE_KEYS } from '../tbank.config'; 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 { BrokerPortfolio, BrokerPositionsPage } from '../types/broker.types';
import type { import type {
TBankInstrument, TBankInstrument,
@ -25,7 +26,7 @@ export class BrokerPortfolioService {
async getPortfolio(accountId: string): Promise<ApiEnvelopePayload<BrokerPortfolio>> { async getPortfolio(accountId: string): Promise<ApiEnvelopePayload<BrokerPortfolio>> {
const account = await this.accountsService.findById(accountId); 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( const result = await this.cacheService.getOrFetch(
TBANK_CACHE_KEYS.portfolio, TBANK_CACHE_KEYS.portfolio,
@ -51,7 +52,7 @@ export class BrokerPortfolioService {
type?: string, type?: string,
): Promise<ApiEnvelopePayload<BrokerPositionsPage>> { ): Promise<ApiEnvelopePayload<BrokerPositionsPage>> {
const account = await this.accountsService.findById(accountId); 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( const result = await this.cacheService.getOrFetch(
TBANK_CACHE_KEYS.positions, TBANK_CACHE_KEYS.positions,

View File

@ -1,5 +1,5 @@
import { ServiceUnavailableException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { TBankNotConfiguredException } from '../../../common/exceptions/tbank-api.exception';
import { ChannelCredentials, ClientUnaryCall, Metadata, ServiceError, status } from '@grpc/grpc-js'; import { ChannelCredentials, ClientUnaryCall, Metadata, ServiceError, status } from '@grpc/grpc-js';
import { mkdtempSync, writeFileSync } from 'node:fs'; import { mkdtempSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os'; 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 () => { it('wraps grpc errors with status code and tracking id', async () => {
@ -107,9 +107,7 @@ describe('TBankClientService', () => {
{}, {},
), ),
).rejects.toMatchObject({ ).rejects.toMatchObject({
response: expect.objectContaining({ response: expect.stringContaining('T-Bank upstream error'),
message: expect.stringContaining('T-Bank upstream error'),
}),
}); });
}); });

View File

@ -1,10 +1,6 @@
import { import { Injectable, Logger } from '@nestjs/common';
BadGatewayException,
Injectable,
Logger,
ServiceUnavailableException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { TBankNotConfiguredException, TBankApiException } from '../../../common/exceptions/tbank-api.exception';
import { import {
CallOptions, CallOptions,
ChannelCredentials, ChannelCredentials,
@ -79,7 +75,7 @@ export class TBankClientService {
createMetadata(): Metadata { createMetadata(): Metadata {
const token = this.configService.get<string>('app.tbank.token', ''); const token = this.configService.get<string>('app.tbank.token', '');
if (!token) { if (!token) {
throw new ServiceUnavailableException('T-Bank integration is not configured'); throw new TBankNotConfiguredException();
} }
const metadata = new Metadata(); const metadata = new Metadata();
@ -169,7 +165,7 @@ export class TBankClientService {
error instanceof Error ? error.message : String(error) 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({ const detail = [publicMessage, trackingId && `trackingId:${trackingId}`, retryAfter && `retryAfter:${retryAfter}`]
message: publicMessage, .filter(Boolean)
trackingId: trackingId ? String(trackingId) : null, .join('; ');
retryAfter: retryAfter ? String(retryAfter) : null, return new TBankApiException(detail);
});
} }
} }