import { INestApplication } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { Test } from '@nestjs/testing'; import type { Server } from 'node:http'; import request from 'supertest'; import { AppModule } from '../src/app.module'; import { configureApp } from '../src/main'; import { TbankAccessService } from '../src/tbank/tbank-access.service'; import { TbankClient } from '../src/tbank/tbank.client'; import { PortfolioService } from '../src/tbank/portfolio.service'; describe('Portfolio structure HTTP contract', () => { let app: INestApplication; const context = { token: 'fixture', generation: 'fixture', accounts: [ { id: 'a', name: 'A' }, { id: 'b', name: 'B' }, ], }; const tbank = { getPortfolio: jest.fn(), getPositions: jest.fn(), getInstrumentByFigi: jest.fn().mockResolvedValue(null), getInstrumentByUid: jest.fn().mockResolvedValue(null), }; const access = { withConnection: (_user: string, action: (value: unknown) => unknown) => action(context), withSupportedAccounts: ( _user: string, action: (value: unknown) => unknown, ) => action(context), withAccountForConnection: ( _context: unknown, _id: string, action: () => unknown, ) => action(), }; 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(TbankAccessService) .useValue(access) .overrideProvider(TbankClient) .useValue(tbank) .compile(); app = module.createNestApplication(); configureApp(app); await app.init(); }); beforeEach(() => { app.get(PortfolioService).invalidate('structure-user'); 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()); it.each(['a', 'all'])( 'serializes exact structure including cash for %s', async (id) => { const response = await request(app.getHttpServer()) .get(`/api/v1/brokerage-accounts/${id}/portfolio`) .set('Authorization', `Bearer ${token}`) .expect(200); expect(response.body).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 } }], }); const response = await request(app.getHttpServer()) .get('/api/v1/brokerage-accounts/a/portfolio') .set('Authorization', `Bearer ${token}`) .expect(200); expect(response.body).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 }], })); const response = await request(app.getHttpServer()) .get('/api/v1/brokerage-accounts/all/portfolio') .set('Authorization', `Bearer ${token}`) .expect(200); expect(response.body).toMatchObject({ structure: { currencies: [ { hasNegativeValues: true, totalValue: '150', categories: [ { value: '120', sharePercent: null }, { sharePercent: null }, { sharePercent: null }, { value: '30', sharePercent: null }, ], }, ], }, }); }); });