From 25a6d22a0cd55077ac2df44921ca35042fb39b7a Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Sun, 14 Jun 2026 14:08:11 +0300 Subject: [PATCH] test(backend): add failing tests for portfolio list enrichment --- .gitignore | 1 + .../portfolio/portfolio.service.spec.ts | 201 ++++++++++++++++++ 2 files changed, 202 insertions(+) create mode 100644 apps/backend/src/modules/portfolio/portfolio.service.spec.ts diff --git a/.gitignore b/.gitignore index 84b2050..f54e210 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ node_modules/ dist/ +.superpowers/ .env *.log .DS_Store diff --git a/apps/backend/src/modules/portfolio/portfolio.service.spec.ts b/apps/backend/src/modules/portfolio/portfolio.service.spec.ts new file mode 100644 index 0000000..9e3742d --- /dev/null +++ b/apps/backend/src/modules/portfolio/portfolio.service.spec.ts @@ -0,0 +1,201 @@ +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, + 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: { + 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', async () => { + const sharePosition = mockPosition({ + id: 1, + secid: 'SBER', + type: 'share', + quantity: 10, + }); + const bondPosition = mockPosition({ + id: 2, + portfolioId: 1, + secid: 'SU26238RMFS5', + type: 'bond', + quantity: 5, + }); + + 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); + }); + + 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); + }); + }); +});