feat: enrich portfolio list with total value and position breakdown from MOEX #10
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,5 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.superpowers/
|
||||
.env
|
||||
*.log
|
||||
.DS_Store
|
||||
|
||||
201
apps/backend/src/modules/portfolio/portfolio.service.spec.ts
Normal file
201
apps/backend/src/modules/portfolio/portfolio.service.spec.ts
Normal file
@ -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<string, unknown> = {}) => ({
|
||||
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<string, unknown> = {}) => ({
|
||||
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>(PortfolioService);
|
||||
prisma = module.get<PrismaService>(PrismaService);
|
||||
moexClient = module.get<MoexClientService>(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<typeof vi.fn> };
|
||||
cacheMock.getOrFetch.mockImplementation(
|
||||
async (_prefix: string, _key: string[], fetchFn: () => Promise<any>) => ({
|
||||
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<typeof vi.fn> };
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user