import type { Server } from 'node:http'; import { INestApplication } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { Test } from '@nestjs/testing'; import request from 'supertest'; import { AppModule } from '../src/app.module'; import { TbankMembershipService } from '../src/integrations/tbank/tbank-membership.service'; import { TbankClient } from '../src/integrations/tbank/tbank.client'; import { configureApp } from '../src/main'; import { PortfolioService } from '../src/modules/investments/portfolio/application/portfolio.service'; import { TbankHourlyScheduler } from '../src/modules/investments/tbank-sync/tbank-hourly.scheduler'; import { PrismaService } from '../src/prisma/prisma.service'; describe('Portfolio structure HTTP contract', () => { let app: INestApplication; let prisma: PrismaService; let membership: TbankMembershipService; let portfolios: PortfolioService; 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), getOpenAccounts: jest.fn().mockResolvedValue([ { id: 'a', name: 'A', type: 'ACCOUNT_TYPE_TINKOFF' }, { id: 'b', name: 'B', type: 'ACCOUNT_TYPE_TINKOFF' }, ]), }; 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) .overrideProvider(TbankHourlyScheduler) .useValue({ connected: jest.fn().mockResolvedValue(undefined) }) .compile(); app = module.createNestApplication(); configureApp(app); await app.init(); prisma = app.get(PrismaService); membership = app.get(TbankMembershipService); portfolios = app.get(PortfolioService); await prisma.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) { const connection = await prisma.tbankConnection.findUniqueOrThrow({ where: { userId: 'structure-user' }, }); const member = await membership.request( 'structure-user', connection.revision, true, ); if (member) await member.done; const accountIds = id === 'all' ? ['a', 'b'] : [id]; for (const accountId of accountIds) { const work = await portfolios.request( 'structure-user', connection.revision, accountId, true, ); if (work) await work.done; } } 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: [], 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 }], 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: { hasNegativeValues: true, totalValue: '150', categories: [ { value: '120', sharePercent: null }, { sharePercent: null }, { sharePercent: null }, { value: '30', sharePercent: null }, ], }, }); }); });