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/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
|
.superpowers/
|
||||||
.env
|
.env
|
||||||
*.log
|
*.log
|
||||||
.DS_Store
|
.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