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; 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 }, ], }, ], }, }); }); });