import { Test, TestingModule } from '@nestjs/testing'; import { ConfigModule } from '@nestjs/config'; import { PortfolioService } from './portfolio.service'; 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'; describe('PortfolioService', () => { let service: PortfolioService; let prisma: PrismaService; let moexClient: MoexClientService; let module: TestingModule; const mockPortfolio = (overrides: Record = {}) => ({ id: 1, userId: 1, name: 'Test Portfolio', description: 'A test portfolio', currency: 'RUB', targets: null, createdAt: new Date('2026-01-01'), updatedAt: new Date('2026-06-14'), ...overrides, }); const mockPosition = (overrides: Record = {}) => ({ id: 1, portfolioId: 1, secid: 'SBER', type: 'share', quantity: 10, buyPrice: null, buyDate: null, notes: null, tags: null, createdAt: new Date('2026-01-01'), updatedAt: new Date('2026-06-14'), ...overrides, }); beforeAll(async () => { module = await Test.createTestingModule({ imports: [ConfigModule.forRoot({ load: [configuration] })], providers: [ PortfolioService, { provide: PrismaService, useValue: { portfolio: { findMany: vi.fn(), findUnique: vi.fn(), create: vi.fn(), update: vi.fn(), delete: vi.fn(), }, position: { findMany: vi.fn(), findUnique: vi.fn(), create: vi.fn(), update: vi.fn(), delete: vi.fn(), }, }, }, { provide: MoexClientService, useValue: { getShareMarketDataBatch: vi.fn(), getBondPositionDataBatch: vi.fn(), getSecurityDescription: vi.fn(), }, }, { provide: CacheService, useValue: { getOrFetch: vi.fn(), }, }, ], }).compile(); service = module.get(PortfolioService); prisma = module.get(PrismaService); moexClient = module.get(MoexClientService); }); beforeEach(() => { vi.clearAllMocks(); }); describe('findAll', () => { it('should return empty array when user has no portfolios', async () => { vi.mocked(prisma.portfolio.findMany).mockResolvedValue([]); const result = await service.findAll(1); expect(result).toEqual([]); }); it('should return portfolios with zero aggregates when no positions exist', async () => { vi.mocked(prisma.portfolio.findMany).mockResolvedValue([ mockPortfolio({ positions: [] }) as any, ]); const result = await service.findAll(1); expect(result).toHaveLength(1); expect(result[0]).toMatchObject({ name: 'Test Portfolio', totalValue: 0, positionCount: 0, shareCount: 0, bondCount: 0, }); }); it('should enrich portfolios with market data from batch MOEX call and compute PnL', async () => { const sharePosition = mockPosition({ id: 1, secid: 'SBER', type: 'share', quantity: 10, buyPrice: 230, buyDate: new Date('2026-03-01'), }); const bondPosition = mockPosition({ id: 2, portfolioId: 1, secid: 'SU26238RMFS5', type: 'bond', quantity: 5, buyPrice: 950, buyDate: new Date('2026-03-01'), }); vi.mocked(prisma.portfolio.findMany).mockResolvedValue([ mockPortfolio({ positions: [sharePosition, bondPosition] }) as any, ]); vi.mocked(moexClient.getShareMarketDataBatch).mockResolvedValue([ { secid: 'SBER', shortName: 'Sberbank', last: 250, lastChange: 5, lastChangePrcnt: 2 }, ] as any); vi.mocked(moexClient.getBondPositionDataBatch).mockResolvedValue([ { secid: 'SU26238RMFS5', shortName: 'OFZ 26238', price: 98.5, faceValue: 1000, }, ] as any); const cacheMock = module.get(CacheService) as { getOrFetch: ReturnType }; cacheMock.getOrFetch.mockImplementation( async (_prefix: string, _key: string[], fetchFn: () => Promise) => ({ data: await fetchFn(), fromCache: false, cachedAt: null, }), ); const result = await service.findAll(1); expect(result).toHaveLength(1); expect(result[0].name).toBe('Test Portfolio'); expect(result[0].positionCount).toBe(2); expect(result[0].shareCount).toBe(1); expect(result[0].bondCount).toBe(1); // SBER: 250 * 10 = 2500, OFZ: (98.5 / 100) * 1000 * 5 = 4925 expect(result[0].totalValue).toBe(7425); // Verify PnL for SBER share // totalCost = 230 * 10 = 2300 // currentValue = 250 * 10 = 2500 // pnl = 2500 - 2300 = 200 // pnlPercent = 200 / 2300 * 100 ≈ 8.70 // Verify via findOne which gives enriched positions }); it('should propagate MOEX errors to the caller', async () => { vi.mocked(prisma.portfolio.findMany).mockResolvedValue([ mockPortfolio({ positions: [mockPosition()] }) as any, ]); const cacheMock = module.get(CacheService) as { getOrFetch: ReturnType }; cacheMock.getOrFetch.mockRejectedValue(new Error('MOEX down')); await expect(service.findAll(1)).rejects.toThrow('MOEX down'); }); it('should only return portfolios belonging to the requesting user', async () => { vi.mocked(prisma.portfolio.findMany).mockResolvedValue([]); await service.findAll(2); expect(prisma.portfolio.findMany).toHaveBeenCalledWith({ where: { userId: 2 }, include: { positions: true }, orderBy: { updatedAt: 'desc' }, }); }); }); describe('findOne', () => { it('should throw NotFoundException for non-existent portfolio', async () => { vi.mocked(prisma.portfolio.findUnique).mockResolvedValue(null); await expect(service.findOne(1, 999)).rejects.toThrow(NotFoundException); }); it('should throw ForbiddenException for wrong user', async () => { vi.mocked(prisma.portfolio.findUnique).mockResolvedValue(mockPortfolio({ userId: 2 }) as any); await expect(service.findOne(1, 1)).rejects.toThrow(ForbiddenException); }); it('should return portfolio with enriched positions and analytics summary', async () => { const sharePosition = mockPosition({ id: 1, secid: 'SBER', type: 'share', quantity: 10, buyPrice: 200, }); vi.mocked(prisma.portfolio.findUnique).mockResolvedValue( mockPortfolio({ positions: [sharePosition] }) as any, ); vi.mocked(prisma.position.findMany).mockResolvedValue([sharePosition] as any); const cacheMock = module.get(CacheService) as { getOrFetch: ReturnType }; cacheMock.getOrFetch.mockImplementation( async (_prefix: string, _key: string[], fetchFn: () => Promise) => ({ data: await fetchFn(), fromCache: false, cachedAt: null, }), ); vi.mocked(moexClient.getShareMarketDataBatch).mockResolvedValue([ { secid: 'SBER', shortName: 'Sberbank', last: 250 }, ] as any); const result = await service.findOne(1, 1); expect(result.id).toBe(1); expect(result.positions).toHaveLength(1); expect(result.positions[0].secid).toBe('SBER'); expect(result.positions[0].weightPercent).toBe(100); expect(result.totalValue).toBe(2500); expect(result.analytics).toBeDefined(); expect(result.analytics.totalInvested).toBe(2000); expect(result.analytics.totalValue).toBe(2500); expect(result.analytics.totalPnl).toBe(500); }); }); describe('getPositionsWithPrices', () => { it('should return empty array when no positions exist', async () => { vi.mocked(prisma.position.findMany).mockResolvedValue([]); const result = await service.getPositionsWithPrices(1); expect(result).toEqual([]); }); it('should return enriched positions with PnL for shares', async () => { vi.mocked(prisma.position.findMany).mockResolvedValue([ mockPosition({ id: 1, secid: 'SBER', type: 'share', quantity: 10, buyPrice: 230, buyDate: new Date('2026-03-01'), }), ] as any); const cacheMock = module.get(CacheService) as { getOrFetch: ReturnType }; cacheMock.getOrFetch.mockImplementation( async (_prefix: string, _key: string[], fetchFn: () => Promise) => ({ data: await fetchFn(), fromCache: false, cachedAt: null, }), ); vi.mocked(moexClient.getShareMarketDataBatch).mockResolvedValue([ { secid: 'SBER', shortName: 'Sberbank', last: 250, lastChange: 5, lastChangePrcnt: 2 }, ] as any); const result = await service.getPositionsWithPrices(1); expect(result).toHaveLength(1); expect(result[0].secid).toBe('SBER'); expect(result[0].buyPrice).toBe(230); expect(result[0].buyDate).toBe('2026-03-01T00:00:00.000Z'); expect(result[0].totalCost).toBe(2300); expect(result[0].currentPrice).toBe(250); expect(result[0].currentValue).toBe(2500); expect(result[0].pnl).toBe(200); expect(result[0].pnlPercent).toBeCloseTo(8.6957, 1); expect(result[0].dividendIncome).toBe(0); expect(result[0].totalReturn).toBe(200); expect(result[0].totalReturnPercent).toBeCloseTo(8.6957, 1); }); it('should return enriched positions with PnL for bonds', async () => { vi.mocked(prisma.position.findMany).mockResolvedValue([ mockPosition({ id: 2, portfolioId: 1, secid: 'SU26238RMFS5', type: 'bond', quantity: 5, buyPrice: 95, // 95% of face value buyDate: new Date('2026-03-01'), }), ] as any); const cacheMock = module.get(CacheService) as { getOrFetch: ReturnType }; cacheMock.getOrFetch.mockImplementation( async (_prefix: string, _key: string[], fetchFn: () => Promise) => ({ data: await fetchFn(), fromCache: false, cachedAt: null, }), ); vi.mocked(moexClient.getBondPositionDataBatch).mockResolvedValue([ { secid: 'SU26238RMFS5', shortName: 'OFZ 26238', price: 98.5, faceValue: 1000, }, ] as any); const result = await service.getPositionsWithPrices(1); expect(result).toHaveLength(1); expect(result[0].secid).toBe('SU26238RMFS5'); // totalCost = 950 * 5 = 4750 expect(result[0].totalCost).toBe(4750); // currentValue = (98.5 / 100) * 1000 * 5 = 4925 expect(result[0].currentValue).toBe(4925); // pnl = 4925 - 4750 = 175 expect(result[0].pnl).toBe(175); expect(result[0].pnlPercent).toBeCloseTo(3.6842, 1); expect(result[0].totalReturn).toBe(175); }); it('should set PnL to null when buyPrice is missing', async () => { vi.mocked(prisma.position.findMany).mockResolvedValue([ mockPosition({ id: 1, secid: 'SBER', type: 'share', quantity: 10, buyPrice: null, buyDate: null, }), ] as any); const cacheMock = module.get(CacheService) as { getOrFetch: ReturnType }; cacheMock.getOrFetch.mockImplementation( async (_prefix: string, _key: string[], fetchFn: () => Promise) => ({ data: await fetchFn(), fromCache: false, cachedAt: null, }), ); vi.mocked(moexClient.getShareMarketDataBatch).mockResolvedValue([ { secid: 'SBER', shortName: 'Sberbank', last: 250 }, ] as any); const result = await service.getPositionsWithPrices(1); expect(result[0].buyPrice).toBeNull(); expect(result[0].totalCost).toBeNull(); expect(result[0].currentValue).toBe(2500); expect(result[0].pnl).toBeNull(); expect(result[0].pnlPercent).toBeNull(); expect(result[0].totalReturn).toBeNull(); expect(result[0].totalReturnPercent).toBeNull(); }); it('should set PnL to null when market data is missing', async () => { vi.mocked(prisma.position.findMany).mockResolvedValue([ mockPosition({ id: 1, secid: 'UNKNOWN', type: 'share', quantity: 10, buyPrice: 100, buyDate: new Date('2026-03-01'), }), ] as any); const cacheMock = module.get(CacheService) as { getOrFetch: ReturnType }; cacheMock.getOrFetch.mockImplementation( async (_prefix: string, _key: string[], fetchFn: () => Promise) => ({ data: await fetchFn(), fromCache: false, cachedAt: null, }), ); vi.mocked(moexClient.getShareMarketDataBatch).mockResolvedValue([] as any); const result = await service.getPositionsWithPrices(1); expect(result[0].currentPrice).toBeNull(); expect(result[0].currentValue).toBeNull(); expect(result[0].pnl).toBeNull(); expect(result[0].totalReturn).toBeNull(); }); }); describe('getAnalytics', () => { it('should return empty analytics when no positions', async () => { vi.mocked(prisma.portfolio.findUnique).mockResolvedValue(mockPortfolio() as any); vi.mocked(prisma.position.findMany).mockResolvedValue([]); const result = await service.getAnalytics(1, 1); expect(result.summary.totalInvested).toBe(0); expect(result.summary.totalValue).toBe(0); expect(result.summary.totalPnl).toBe(0); expect(result.summary.positionCount).toBe(0); expect(result.summary.weightedYield).toBeNull(); }); it('should compute correct summary with share positions', async () => { vi.mocked(prisma.portfolio.findUnique).mockResolvedValue(mockPortfolio() as any); vi.mocked(prisma.position.findMany).mockResolvedValue([ mockPosition({ id: 1, secid: 'SBER', type: 'share', quantity: 10, buyPrice: 230, buyDate: new Date('2026-03-01'), }), mockPosition({ id: 2, portfolioId: 1, secid: 'GAZP', type: 'share', quantity: 5, buyPrice: 150, buyDate: new Date('2026-03-01'), }), ] as any); const cacheMock = module.get(CacheService) as { getOrFetch: ReturnType }; cacheMock.getOrFetch.mockImplementation( async (_prefix: string, _key: string[], fetchFn: () => Promise) => ({ data: await fetchFn(), fromCache: false, cachedAt: null, }), ); vi.mocked(moexClient.getShareMarketDataBatch).mockResolvedValue([ { secid: 'SBER', shortName: 'Sberbank', last: 250, lastChange: 5, lastChangePrcnt: 2 }, { secid: 'GAZP', shortName: 'Gazprom', last: 160, lastChange: 3, lastChangePrcnt: 1.5 }, ] as any); const result = await service.getAnalytics(1, 1); // totalInvested: 230*10 + 150*5 = 2300 + 750 = 3050 expect(result.summary.totalInvested).toBe(3050); // totalValue: 250*10 + 160*5 = 2500 + 800 = 3300 expect(result.summary.totalValue).toBe(3300); // totalPnl: 200 + 50 = 250 expect(result.summary.totalPnl).toBe(250); // totalPnlPercent: 250 / 3050 * 100 ≈ 8.20 expect(result.summary.totalPnlPercent).toBeCloseTo(8.1967, 1); expect(result.summary.positionCount).toBe(2); expect(result.summary.totalDividends).toBe(0); expect(result.summary.totalReturn).toBe(250); expect(result.summary.totalReturnPercent).toBeCloseTo(8.1967, 1); }); it('should compute weightedYield correctly', async () => { vi.mocked(prisma.portfolio.findUnique).mockResolvedValue(mockPortfolio() as any); vi.mocked(prisma.position.findMany).mockResolvedValue([ mockPosition({ id: 1, secid: 'SBER', type: 'share', quantity: 10, buyPrice: 100, buyDate: new Date('2026-03-01'), }), mockPosition({ id: 2, portfolioId: 1, secid: 'GAZP', type: 'share', quantity: 10, buyPrice: 200, buyDate: new Date('2026-03-01'), }), ] as any); const cacheMock = module.get(CacheService) as { getOrFetch: ReturnType }; cacheMock.getOrFetch.mockImplementation( async (_prefix: string, _key: string[], fetchFn: () => Promise) => ({ data: await fetchFn(), fromCache: false, cachedAt: null, }), ); vi.mocked(moexClient.getShareMarketDataBatch).mockResolvedValue([ { secid: 'SBER', shortName: 'Sberbank', last: 120 }, { secid: 'GAZP', shortName: 'Gazprom', last: 180 }, ] as any); const result = await service.getAnalytics(1, 1); // SBER: pnlPercent=20%, cost=1000, weight=1000/3000=1/3 // GAZP: pnlPercent=-10%, cost=2000, weight=2000/3000=2/3 // weightedYield = 20*(1/3) + (-10)*(2/3) = 20/3 - 20/3 = 0 expect(result.summary.weightedYield).toBeCloseTo(0, 1); }); it('should throw ForbiddenException 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); }); it('should throw NotFoundException if portfolio does not exist', async () => { vi.mocked(prisma.portfolio.findUnique).mockResolvedValue(null); await expect(service.getAnalytics(1, 999)).rejects.toThrow(NotFoundException); }); }); });