feat: add portfolio

This commit is contained in:
2026-08-13 22:44:53 +03:00
parent c0a4a93880
commit dfca11c81f
11 changed files with 771 additions and 78 deletions
+167
View File
@@ -0,0 +1,167 @@
import {
BadRequestException,
ConflictException,
NotFoundException,
ServiceUnavailableException,
} from '@nestjs/common';
jest.mock('../prisma/prisma.service', () => ({
PrismaService: class PrismaService {},
}));
import { PortfolioService } from './portfolio.service';
import { TbankCrypto } from './tbank.crypto';
describe('PortfolioService', () => {
const key = 'MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=';
const prisma = { tbankConnection: { findUnique: jest.fn() } };
const config = { getOrThrow: jest.fn(() => key) };
const tbank = {
getOpenAccounts: jest.fn(),
getPortfolio: jest.fn(),
getPositions: jest.fn(),
getInstrumentByFigi: jest.fn(),
getInstrumentByUid: jest.fn(),
};
const service = new PortfolioService(
prisma as never,
config as never,
tbank as never,
);
const crypto = TbankCrypto.fromBase64(key);
beforeEach(() => {
jest.clearAllMocks();
service.invalidate('user');
prisma.tbankConnection.findUnique.mockResolvedValue({
encryptedToken: crypto.encrypt('token'),
});
tbank.getOpenAccounts.mockResolvedValue([
{ id: 'account', type: 'ACCOUNT_TYPE_TINKOFF' },
]);
tbank.getPortfolio.mockResolvedValue({ positions: [] });
tbank.getPositions.mockResolvedValue({ money: [] });
});
it('requires a connection and an accessible brokerage account', async () => {
prisma.tbankConnection.findUnique.mockResolvedValue(null);
await expect(service.get('user', 'account')).rejects.toEqual(
expect.any(ConflictException),
);
prisma.tbankConnection.findUnique.mockResolvedValue({
encryptedToken: crypto.encrypt('token'),
});
tbank.getOpenAccounts.mockResolvedValue([]);
await expect(service.get('user', 'account')).rejects.toEqual(
expect.any(NotFoundException),
);
});
it('normalizes upstream order, exact totals, metadata fallback, and cash', async () => {
tbank.getPortfolio.mockResolvedValue({
positions: [
{
figi: 'ONE',
instrumentType: 'share',
quantity: { units: 2 },
currentPrice: { currency: 'rub', units: 3 },
averagePositionPrice: { currency: 'RUB', units: 2 },
},
{
figi: 'TWO',
instrumentType: 'new-kind',
quantity: { nano: -500000000 },
currentPrice: { currency: 'USD', units: 4 },
},
{ figi: 'ZERO', quantity: {} },
],
});
tbank.getPositions.mockResolvedValue({
money: [
{ currency: 'RUB', units: 1 },
{ currency: 'rub', nano: 500000000 },
{ currency: 'USD' },
],
});
tbank.getInstrumentByFigi
.mockResolvedValueOnce({ ticker: 'ONE', name: 'Первая позиция' })
.mockRejectedValueOnce(new Error('unavailable'));
await expect(service.get('user', 'account')).resolves.toMatchObject({
positions: [
{
figi: 'ONE',
displayName: 'Первая позиция',
type: 'STOCK',
quantity: '2',
currentValue: { currency: 'RUB', value: '6' },
absoluteReturn: { currency: 'RUB', value: '2' },
},
{
figi: 'TWO',
displayName: 'TWO',
type: 'OTHER',
quantity: '-0.5',
currentValue: { currency: 'USD', value: '-2' },
},
],
cashBalances: [{ currency: 'RUB', value: '1.5' }],
totalsByCurrency: [
{ currency: 'RUB', value: '7.5' },
{ currency: 'USD', value: '-2' },
],
});
});
it('uses instrument UID before FIGI when resolving a name', async () => {
tbank.getPortfolio.mockResolvedValue({
positions: [
{
figi: 'FIGI',
instrumentUid: 'UID',
instrumentType: 'share',
quantity: { units: 1 },
},
],
});
tbank.getInstrumentByUid.mockResolvedValue({ name: 'Имя по UID' });
await expect(service.get('user', 'account')).resolves.toMatchObject({
positions: [{ displayName: 'Имя по UID' }],
});
expect(tbank.getInstrumentByFigi).not.toHaveBeenCalled();
});
it('uses fresh snapshots, falls back only for temporary errors, and clears them', async () => {
await service.get('user', 'account');
await service.get('user', 'account');
expect(tbank.getOpenAccounts).toHaveBeenCalledTimes(1);
service.invalidate('user');
await service.get('user', 'account');
expect(tbank.getOpenAccounts).toHaveBeenCalledTimes(2);
const snapshots = (
service as never as { snapshots: Map<string, { storedAt: number }> }
).snapshots;
snapshots.get('user:account')!.storedAt = Date.now() - 30_001;
tbank.getOpenAccounts.mockRejectedValueOnce(
new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }),
);
await expect(service.get('user', 'account')).resolves.toMatchObject({
isStale: true,
});
});
it('does not mask an invalid token with a snapshot', async () => {
await service.get('user', 'account');
const snapshots = (
service as never as { snapshots: Map<string, { storedAt: number }> }
).snapshots;
snapshots.get('user:account')!.storedAt = Date.now() - 30_001;
tbank.getOpenAccounts.mockRejectedValueOnce(
new Error('INVALID_TBANK_TOKEN'),
);
await expect(service.get('user', 'account')).rejects.toEqual(
expect.any(BadRequestException),
);
});
});