Files
backend/test/portfolio-structure.e2e-spec.ts
T
ksv741 83df5f0be6
Backend verify / commits (pull_request) Successful in 1m38s
Backend verify / security (pull_request) Successful in 1m28s
Backend verify / verify (pull_request) Successful in 8m40s
refactor!: support RUB only (WealthLedger/main#55)
Filter non-RUB data at ingest and collapse per-currency collections
into a single RUB Money. Drop currency from TbankPortfolioValuePoint,
remove NON_RUB_ACTIVITY and the screener currency filter.

BREAKING CHANGE: per-currency API fields replaced with single RUB Money.
2026-11-11 09:40:25 +03:00

180 lines
5.9 KiB
TypeScript

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