Files
backend/src/modules/investments/portfolio/application/portfolio.service.spec.ts
T
ksv741 bc631e72de
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 13s
Backend verify / verify (push) Failing after 6m21s
test: align resource availability metadata
2026-10-09 23:10:50 +03:00

225 lines
7.2 KiB
TypeScript

import { setImmediate as immediate } from 'node:timers/promises';
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() };
const tbank = { getPortfolio: jest.fn(), getPositions: jest.fn() };
const metadata = { savedMany: jest.fn(), request: 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,
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');
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);
});
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: [],
});
await service.request('user', 'private', 'a');
for (
let index = 0;
index < 10 && !repository.publish.mock.calls.length;
index += 1
) {
await immediate();
}
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' },
] });
await service.request('user', 'private', 'a');
for (let index = 0; index < 10 && !repository.publish.mock.calls.length; index++) {
await immediate();
}
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('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();
});
});