175 lines
5.4 KiB
TypeScript
175 lines
5.4 KiB
TypeScript
import type { Server } from 'node:http';
|
|
|
|
import { INestApplication } from '@nestjs/common';
|
|
import { JwtService } from '@nestjs/jwt';
|
|
import { Test } from '@nestjs/testing';
|
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
import request from 'supertest';
|
|
|
|
import { AppModule } from '../src/app.module';
|
|
import { TbankClient } from '../src/integrations/tbank/tbank.client';
|
|
import { configureApp } from '../src/main';
|
|
import { PrismaService } from '../src/prisma/prisma.service';
|
|
|
|
describe('Portfolio structure HTTP contract', () => {
|
|
let app: INestApplication<Server>;
|
|
const tbank = {
|
|
validateToken: jest.fn().mockResolvedValue([
|
|
{ id: 'a', name: 'A', type: 'ACCOUNT_TYPE_TINKOFF' },
|
|
{ id: 'b', name: 'B', type: 'ACCOUNT_TYPE_TINKOFF' },
|
|
]),
|
|
getPortfolio: jest.fn(),
|
|
getPositions: jest.fn(),
|
|
getInstrumentByFigi: jest.fn().mockResolvedValue(null),
|
|
getInstrumentByUid: jest.fn().mockResolvedValue(null),
|
|
};
|
|
const token = new JwtService().sign(
|
|
{ sub: 'structure-user', type: 'access' },
|
|
{ secret: process.env.JWT_SECRET },
|
|
);
|
|
|
|
beforeAll(async () => {
|
|
const module = await Test.createTestingModule({ imports: [AppModule] })
|
|
.overrideProvider(TbankClient)
|
|
.useValue(tbank)
|
|
.compile();
|
|
app = module.createNestApplication();
|
|
configureApp(app);
|
|
await app.init();
|
|
await app.get(PrismaService).user.create({
|
|
data: {
|
|
id: 'structure-user',
|
|
email: 'structure@example.invalid',
|
|
passwordHash: 'fixture',
|
|
},
|
|
});
|
|
});
|
|
beforeEach(async () => {
|
|
await request(app.getHttpServer())
|
|
.put('/api/v1/tbank-connection')
|
|
.set('Authorization', `Bearer ${token}`)
|
|
.send({ token: 'fixture' })
|
|
.expect(200);
|
|
tbank.getPortfolio.mockResolvedValue({
|
|
positions: [
|
|
{
|
|
instrumentType: 'share',
|
|
quantity: { units: 2 },
|
|
currentPrice: { currency: 'RUB', units: 30 },
|
|
},
|
|
],
|
|
});
|
|
tbank.getPositions.mockResolvedValue({
|
|
money: [{ currency: 'RUB', units: 40 }],
|
|
});
|
|
});
|
|
afterAll(() => app.close());
|
|
async function load(id: string) {
|
|
await request(app.getHttpServer())
|
|
.get(`/api/v1/brokerage-accounts/${id}/portfolio`)
|
|
.set('Authorization', `Bearer ${token}`)
|
|
.expect(200);
|
|
for (let index = 0; index < 200; index += 1) {
|
|
if (
|
|
!(await app
|
|
.get(PrismaService)
|
|
.tbankSyncWork.count({ where: { state: 'running' } }))
|
|
) {
|
|
return;
|
|
}
|
|
await delay(5);
|
|
}
|
|
throw new Error('Portfolio did not settle');
|
|
}
|
|
|
|
it.each(['a', 'all'])(
|
|
'serializes exact structure including cash for %s',
|
|
async (id) => {
|
|
await load(id);
|
|
const response = await request(app.getHttpServer())
|
|
.get(`/api/v1/brokerage-accounts/${id}/portfolio`)
|
|
.set('Authorization', `Bearer ${token}`)
|
|
.expect(200);
|
|
expect((response.body as { data: unknown }).data).toMatchObject({
|
|
structure: {
|
|
unvaluedCategories: [],
|
|
currencies: [
|
|
{
|
|
currency: 'RUB',
|
|
totalValue: id === 'all' ? '200' : '100',
|
|
hasNegativeValues: false,
|
|
categories: [
|
|
{
|
|
type: 'STOCK',
|
|
value: id === 'all' ? '120' : '60',
|
|
sharePercent: '60',
|
|
},
|
|
{ type: 'BOND', value: '0', sharePercent: '0' },
|
|
{ type: 'OTHER', value: '0', sharePercent: '0' },
|
|
{
|
|
type: 'CASH',
|
|
value: id === 'all' ? '80' : '40',
|
|
sharePercent: '40',
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
});
|
|
},
|
|
);
|
|
|
|
it('retains unknown valuations on the wire', async () => {
|
|
tbank.getPortfolio.mockResolvedValue({
|
|
positions: [{ instrumentType: 'bond', quantity: { units: 1 } }],
|
|
});
|
|
await load('a');
|
|
const response = await request(app.getHttpServer())
|
|
.get('/api/v1/brokerage-accounts/a/portfolio')
|
|
.set('Authorization', `Bearer ${token}`)
|
|
.expect(200);
|
|
expect((response.body as { data: unknown }).data).toMatchObject({
|
|
structure: {
|
|
unvaluedCategories: [{ type: 'BOND', count: 1 }],
|
|
currencies: [
|
|
{
|
|
categories: [
|
|
{ sharePercent: null },
|
|
{ sharePercent: null },
|
|
{ sharePercent: null },
|
|
{ value: '40', sharePercent: null },
|
|
],
|
|
},
|
|
],
|
|
},
|
|
});
|
|
});
|
|
|
|
it('keeps a negative cash constituent visible to aggregate eligibility after netting', async () => {
|
|
tbank.getPositions.mockImplementation((_token: string, id: string) => ({
|
|
money: [{ currency: 'RUB', units: id === 'a' ? -10 : 40 }],
|
|
}));
|
|
await load('all');
|
|
const response = await request(app.getHttpServer())
|
|
.get('/api/v1/brokerage-accounts/all/portfolio')
|
|
.set('Authorization', `Bearer ${token}`)
|
|
.expect(200);
|
|
expect((response.body as { data: unknown }).data).toMatchObject({
|
|
structure: {
|
|
currencies: [
|
|
{
|
|
hasNegativeValues: true,
|
|
totalValue: '150',
|
|
categories: [
|
|
{ value: '120', sharePercent: null },
|
|
{ sharePercent: null },
|
|
{ sharePercent: null },
|
|
{ value: '30', sharePercent: null },
|
|
],
|
|
},
|
|
],
|
|
},
|
|
});
|
|
});
|
|
});
|