Files
backend/src/modules/investments/portfolio/application/portfolio.service.spec.ts
T
ksv741 5680f2655c
Backend verify / commits (pull_request) Successful in 1m33s
Backend verify / security (pull_request) Successful in 1m3s
Backend verify / verify (pull_request) Successful in 6m6s
feat(portfolio): store and publish instrument sector and issuer (WealthLedger/main#48)
2026-11-01 12:24:28 +03:00

278 lines
10 KiB
TypeScript

import assert from 'node:assert/strict';
jest.mock('../../../../prisma/prisma.service', () => ({
PrismaService: class {},
}));
import { PortfolioDto } from '../presentation/portfolio.dto';
import { PortfolioService } from './portfolio.service';
describe('persistent portfolio projection', () => {
const access = { local: jest.fn(), tokenForAttempt: jest.fn(), rejectMembershipToken: jest.fn() };
const tbank = { getPortfolio: jest.fn(), getPositions: jest.fn() };
const metadata = { savedMany: jest.fn(), request: jest.fn() };
const classification = { refresh: jest.fn() };
const repository = {
read: jest.fn(),
reserve: jest.fn(),
publish: jest.fn(),
fail: jest.fn(),
};
let service: PortfolioService;
const membershipAt = new Date();
beforeEach(() => {
jest.resetAllMocks();
service = new PortfolioService(
access as never,
tbank as never,
metadata as never,
classification as never,
repository as never,
{ request: jest.fn().mockResolvedValue(null) } as never,
{ event: jest.fn() } as never,
{ hourly: jest.fn().mockResolvedValue(null) } as never,
);
access.local.mockResolvedValue({
revision: 'private',
membershipAt,
accounts: [{ accountId: 'a' }, { accountId: 'b' }],
});
access.tokenForAttempt.mockResolvedValue('synthetic');
access.rejectMembershipToken.mockResolvedValue(undefined);
repository.reserve.mockResolvedValue(null);
repository.read.mockResolvedValue({
membershipAt,
membershipRevision: 1,
accounts: [],
work: [],
});
repository.publish.mockResolvedValue(true);
metadata.savedMany.mockResolvedValue(new Map());
metadata.request.mockResolvedValue(null);
classification.refresh.mockResolvedValue(new Map());
});
it('refreshes classification before publishing and passes the hourly walk time', async () => {
const walkStartedAt = new Date('2026-10-31T12:00:00Z');
const position = { instrumentType: 'share', instrumentUid: 'uid', figi: 'figi', quantity: { units: '1' } };
repository.reserve.mockResolvedValue({ launch: true, work: { id: 'work', attemptId: 'attempt' } });
tbank.getPortfolio.mockResolvedValue({ positions: [position] });
tbank.getPositions.mockResolvedValue({ money: [] });
classification.refresh.mockResolvedValue(new Map([[JSON.stringify(['uid', 'figi']), {
sector: 'Energy', issuer: { id: 'issuer', name: 'Issuer' },
}]]));
const handle = await service.request('user', 'private', 'a', true, walkStartedAt);
assert(handle);
await handle.done;
expect(classification.refresh).toHaveBeenCalledWith('synthetic', [position], walkStartedAt);
expect(classification.refresh.mock.invocationCallOrder[0]).toBeLessThan(repository.publish.mock.invocationCallOrder[0]);
const snapshot = (repository.publish.mock.calls as [unknown, string, PortfolioDto][])[0][2];
expect(snapshot.positions[0]).toEqual(expect.objectContaining({
sector: 'Energy', issuer: { id: 'issuer', name: 'Issuer' },
}));
});
it('rejects an invalid classification token without publishing the snapshot', async () => {
repository.reserve.mockResolvedValue({ launch: true, work: { id: 'work', attemptId: 'attempt' } });
tbank.getPortfolio.mockResolvedValue({ positions: [] });
tbank.getPositions.mockResolvedValue({ money: [] });
classification.refresh.mockRejectedValue(new Error('INVALID_TBANK_TOKEN'));
const handle = await service.request('user', 'private', 'a');
assert(handle);
await handle.done;
expect(repository.publish).not.toHaveBeenCalled();
expect(access.rejectMembershipToken).toHaveBeenCalled();
});
it('normalizes exact signed money, order, UID metadata and unknown types without metadata RPC', async () => {
repository.reserve.mockResolvedValueOnce({
launch: true,
work: { id: 'work', attemptId: 'attempt' },
});
tbank.getPortfolio.mockResolvedValue({
positions: [
{
figi: 'A',
instrumentUid: 'uid',
instrumentType: 'share',
quantity: { units: '2' },
currentPrice: { currency: 'rub', units: '3', nano: 1 },
averagePositionPrice: { currency: 'RUB', units: '2' },
},
{
figi: 'B',
instrumentType: 'new-kind',
quantity: { nano: -500000000 },
currentPrice: { currency: 'USD', units: '4' },
},
{ figi: 'zero', instrumentType: 'bond', quantity: { units: '0' } },
],
});
tbank.getPositions.mockResolvedValue({
money: [
{ currency: 'rub', units: '1' },
{ currency: 'EUR', units: '0' },
],
});
metadata.savedMany.mockResolvedValue(
new Map([
[
JSON.stringify(['uid', 'A']),
{ data: { name: 'Known', ticker: 'A' } },
],
]),
);
repository.read.mockResolvedValue({
membershipAt,
membershipRevision: 1,
accounts: [
{
accountId: 'a',
name: 'A',
type: 'BROKERAGE',
openedOn: null,
portfolio: null,
},
],
work: [],
});
const handle = await service.request('user', 'private', 'a');
assert(handle);
await handle.done;
const snapshot = (
repository.publish.mock.calls as [unknown, string, PortfolioDto][]
)[0][2];
expect(
snapshot.positions.map((position) => [
position.displayName,
position.type,
position.quantity,
]),
).toEqual([
['Known', 'STOCK', '2'],
['B', 'OTHER', '-0.5'],
]);
expect(snapshot.positions[0].absoluteReturn).toEqual({
currency: 'RUB',
value: '2.000000002',
});
expect(snapshot.totalsByCurrency).toEqual([
{ currency: 'RUB', value: '7.000000002' },
{ currency: 'USD', value: '-2' },
]);
expect(snapshot.cashBalances).toEqual([{ currency: 'RUB', value: '1' }]);
});
it('keeps net-zero cash groups, missing valuations and incompatible currencies distinct', async () => {
repository.reserve.mockResolvedValueOnce({
launch: true,
work: { id: 'work', attemptId: 'attempt' },
});
tbank.getPortfolio.mockResolvedValue({ positions: [
{ quantity: { units: '1' }, currentPrice: { units: '3' } },
{
quantity: { units: '1' },
currentPrice: { currency: 'USD', units: '3' },
averagePositionPrice: { currency: 'RUB', units: '2' },
},
] });
tbank.getPositions.mockResolvedValue({ money: [
{ currency: 'rub', units: '2' },
{ currency: 'RUB', units: '-2' },
{ units: '99' },
{ currency: 'EUR', units: '1' },
] });
const handle = await service.request('user', 'private', 'a');
assert(handle);
await handle.done;
const snapshot = (repository.publish.mock.calls as [unknown, string, PortfolioDto][])[0][2];
expect(snapshot.cashBalances).toEqual([
{ currency: 'RUB', value: '0' },
{ currency: 'EUR', value: '1' },
]);
expect(snapshot.totalsByCurrency).toEqual([
{ currency: 'EUR', value: '1' },
{ currency: 'USD', value: '3' },
]);
expect(snapshot.positions[0].currentValue).toBeNull();
expect(snapshot.positions[1].absoluteReturn).toBeNull();
expect(snapshot.positions[1].averagePurchasePrice).toEqual({ currency: 'RUB', value: '2' });
});
it('counts cash once when T-Bank also returns it as a CURRENCY position', async () => {
repository.reserve.mockResolvedValueOnce({
launch: true,
work: { id: 'work', attemptId: 'attempt' },
});
tbank.getPortfolio.mockResolvedValue({ positions: [
{ figi: 'FUND', instrumentType: 'etf', quantity: { units: '1' }, currentPrice: { currency: 'RUB', units: '120', nano: 240000000 } },
{ figi: 'RUB000UTSTOM', instrumentType: 'currency', quantity: { units: '1', nano: 250000000 }, currentPrice: { currency: 'RUB', units: '1' } },
] });
tbank.getPositions.mockResolvedValue({ money: [{ currency: 'RUB', units: '1', nano: 250000000 }] });
repository.read.mockResolvedValue({
membershipAt,
membershipRevision: 1,
accounts: [{ accountId: 'a', name: 'A', type: 'BROKERAGE', openedOn: null, portfolio: null }],
work: [],
});
const handle = await service.request('user', 'private', 'a');
assert(handle);
await handle.done;
const snapshot = (repository.publish.mock.calls as [unknown, string, PortfolioDto][])[0][2];
expect(snapshot.totalsByCurrency).toEqual([{ currency: 'RUB', value: '121.49' }]);
expect(snapshot.positions.map((position) => position.type)).toEqual(['FUND', 'CURRENCY']);
expect(snapshot.structure.currencies[0].totalValue).toBe('121.49');
});
it('does not turn a missing account snapshot into aggregate zero; summaries stay independent', async () => {
const snapshot: PortfolioDto = {
lastSuccessfulAt: membershipAt.toISOString(),
isStale: false,
positions: [],
cashBalances: [],
structure: { currencies: [], unvaluedCategories: [] },
totalsByCurrency: [{ currency: 'RUB', value: '1.000000001' }],
};
const account = {
accountId: 'a',
name: 'A',
type: 'BROKERAGE',
openedOn: null,
portfolio: {
payload: snapshot,
fetchedAt: membershipAt,
dataRevision: 1,
},
};
repository.read.mockResolvedValue({
membershipAt,
membershipRevision: 1,
accounts: [account, { ...account, accountId: 'b', portfolio: null }],
work: [],
});
const partial = await service.read('user', 'all');
expect(partial.data).toBeNull();
expect(partial.meta.availability).toBe('temporarily-unavailable');
expect(partial.meta.completeness).toBe('incomplete');
const saved = await service.saved('user', 'all');
expect(saved.accounts[0].snapshot?.totalsByCurrency).toEqual(
snapshot.totalsByCurrency,
);
expect(saved.accounts[1].snapshot).toBeNull();
repository.read.mockResolvedValue({
membershipAt,
membershipRevision: 1,
accounts: [
account,
{
...account,
accountId: 'b',
portfolio: { ...account.portfolio, fetchedAt: new Date(0) },
},
],
work: [],
});
const complete = await service.read('user', 'all');
expect(complete.data?.totalsByCurrency).toEqual([
{ currency: 'RUB', value: '2.000000002' },
]);
expect(complete.data?.lastSuccessfulAt).toBe(new Date(0).toISOString());
expect(complete.data?.isStale).toBe(true);
expect(tbank.getPortfolio).not.toHaveBeenCalled();
});
});