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:
parent
3b919ecdc6
commit
9f85dc5dde
8
apps/backend/src/common/exceptions/domain.exception.ts
Normal file
8
apps/backend/src/common/exceptions/domain.exception.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
8
apps/backend/src/common/exceptions/moex-api.exception.ts
Normal file
8
apps/backend/src/common/exceptions/moex-api.exception.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
14
apps/backend/src/common/exceptions/tbank-api.exception.ts
Normal file
14
apps/backend/src/common/exceptions/tbank-api.exception.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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({
|
||||||
|
|||||||
@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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);
|
||||||
|
|
||||||
|
|||||||
@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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 () => {
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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 () => {
|
||||||
|
|||||||
@ -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(',');
|
||||||
|
|||||||
@ -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 () => {
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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 () => {
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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'),
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user