430 lines
14 KiB
TypeScript
430 lines
14 KiB
TypeScript
import {
|
|
BadRequestException,
|
|
ConflictException,
|
|
ServiceUnavailableException,
|
|
} from '@nestjs/common';
|
|
import assert from 'node:assert/strict';
|
|
|
|
jest.mock('../../integrations/tbank/tbank-access.service', () => ({
|
|
TbankAccessService: class {},
|
|
}));
|
|
|
|
import type { TbankConnectionContext } from '../../integrations/tbank/tbank-access.service';
|
|
import { IncomeAnalyticsService } from './income/income-analytics.service';
|
|
import { BrokerageOperationsService } from './operations/application/brokerage-operations.service';
|
|
import { PortfolioService } from './portfolio/application/portfolio.service';
|
|
import { UpcomingEventsService } from './upcoming-events/upcoming-events.service';
|
|
|
|
function setup() {
|
|
const context = {
|
|
token: 'token',
|
|
generation: 'first',
|
|
accounts: [
|
|
{ id: 'a', name: 'Основной', openedOn: '2020-01-01' },
|
|
{ id: 'b', name: 'ИИС', openedOn: '2026-01-01' },
|
|
],
|
|
};
|
|
const access = {
|
|
withSupportedAccounts: jest.fn(
|
|
(_user: string, handler: (value: typeof context) => unknown) =>
|
|
handler(context),
|
|
),
|
|
withAccountForConnection: jest.fn(
|
|
(
|
|
_connection: TbankConnectionContext,
|
|
_id: string,
|
|
handler: () => unknown,
|
|
) => handler(),
|
|
),
|
|
};
|
|
const tbank = {
|
|
getOperationsByCursor: jest.fn(),
|
|
getPortfolio: jest.fn().mockResolvedValue({ positions: [] }),
|
|
getPositions: jest.fn().mockResolvedValue({ money: [] }),
|
|
getDividends: jest.fn().mockResolvedValue([]),
|
|
getInstrumentByFigi: jest.fn().mockResolvedValue({ name: 'Облигация' }),
|
|
};
|
|
const metadata = { resolve: jest.fn().mockResolvedValue(null) };
|
|
const requestContext = { set: jest.fn() };
|
|
const operations = new BrokerageOperationsService(
|
|
access as never,
|
|
tbank as never,
|
|
requestContext as never,
|
|
metadata as never,
|
|
);
|
|
const portfolio = new PortfolioService(
|
|
access as never,
|
|
tbank as never,
|
|
requestContext as never,
|
|
metadata as never,
|
|
);
|
|
const moex = { bondEvents: jest.fn().mockResolvedValue([]) };
|
|
const events = new UpcomingEventsService(
|
|
access as never,
|
|
tbank as never,
|
|
moex as never,
|
|
requestContext as never,
|
|
{ info: jest.fn(), event: jest.fn() } as never,
|
|
);
|
|
return {
|
|
context,
|
|
access,
|
|
tbank,
|
|
operations,
|
|
portfolio,
|
|
events,
|
|
moex,
|
|
income: new IncomeAnalyticsService(operations),
|
|
};
|
|
}
|
|
|
|
const operation = (
|
|
id: string,
|
|
second: number,
|
|
type = 'OPERATION_TYPE_COUPON',
|
|
value = 1,
|
|
) => ({
|
|
id,
|
|
date: { seconds: String(second) },
|
|
type,
|
|
payment: { currency: 'RUB', units: value },
|
|
});
|
|
const query = { from: '2025-01-01', to: '2026-12-31', limit: 2 };
|
|
|
|
describe('All accounts', () => {
|
|
it('does not truncate instrument history when an account opening date is missing', async () => {
|
|
const { context, operations, tbank } = setup();
|
|
Object.assign(context.accounts[0], { openedOn: null });
|
|
tbank.getOperationsByCursor.mockResolvedValue({ items: [] });
|
|
await operations.getAll('user', { instrumentUid: 'common' });
|
|
expect(tbank.getOperationsByCursor).toHaveBeenCalledWith(
|
|
'token',
|
|
expect.objectContaining({
|
|
from: { seconds: '0' },
|
|
instrumentId: 'common',
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('filters upstream by UID, keeps sold-position history and binds continuations to the instrument', async () => {
|
|
const { operations, tbank } = setup();
|
|
tbank.getOperationsByCursor.mockImplementation(
|
|
(
|
|
_token: string,
|
|
request: { instrumentId: string; accountId: string },
|
|
) => ({
|
|
items: [
|
|
{
|
|
...operation(
|
|
request.accountId + '-dividend',
|
|
9,
|
|
'OPERATION_TYPE_DIVIDEND',
|
|
),
|
|
instrumentUid: request.instrumentId,
|
|
},
|
|
{
|
|
...operation('wrong-paper', 8, 'OPERATION_TYPE_DIVIDEND'),
|
|
instrumentUid: 'preferred',
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
const first = await operations.getAll('user', {
|
|
instrumentUid: 'common',
|
|
limit: 1,
|
|
});
|
|
assert.ok(first.nextCursor);
|
|
expect(first.items).toHaveLength(1);
|
|
expect(first.items[0].instrumentUid).toBe('common');
|
|
expect(tbank.getOperationsByCursor).toHaveBeenCalledWith(
|
|
'token',
|
|
expect.objectContaining({
|
|
instrumentId: 'common',
|
|
from: { seconds: '1577836800' },
|
|
}),
|
|
);
|
|
await expect(
|
|
operations.getAll('user', {
|
|
instrumentUid: 'preferred',
|
|
limit: 1,
|
|
cursor: first.nextCursor,
|
|
}),
|
|
).rejects.toBeInstanceOf(BadRequestException);
|
|
const second = await operations.getAll('user', {
|
|
instrumentUid: 'common',
|
|
limit: 1,
|
|
cursor: first.nextCursor,
|
|
});
|
|
expect(second.items[0].instrumentUid).toBe('common');
|
|
expect(second.items[0].id).not.toBe(first.items[0].id);
|
|
expect(second.hasNext).toBe(false);
|
|
});
|
|
|
|
afterEach(() => jest.useRealTimers());
|
|
|
|
it('merges exact amounts by currency and keeps equal instruments on different accounts', async () => {
|
|
const { portfolio, tbank } = setup();
|
|
tbank.getPortfolio.mockResolvedValue({
|
|
positions: [
|
|
{
|
|
figi: 'SAME',
|
|
instrumentType: 'share',
|
|
quantity: { units: 1 },
|
|
currentPrice: { currency: 'RUB', nano: 100000000 },
|
|
},
|
|
],
|
|
});
|
|
tbank.getPositions.mockImplementation((_token: string, id: string) => ({
|
|
money: [{ currency: id === 'a' ? 'RUB' : 'USD', nano: 200000000 }],
|
|
}));
|
|
const result = await portfolio.getAll('user');
|
|
expect(result.structure.currencies).toMatchObject([
|
|
{
|
|
currency: 'RUB',
|
|
totalValue: '0.4',
|
|
categories: [
|
|
{ type: 'STOCK', value: '0.2', sharePercent: '50' },
|
|
{ type: 'BOND', value: '0' },
|
|
{ type: 'OTHER', value: '0' },
|
|
{ type: 'CASH', value: '0.2', sharePercent: '50' },
|
|
],
|
|
},
|
|
{ currency: 'USD', totalValue: '0.2' },
|
|
]);
|
|
expect(result.totalsByCurrency).toEqual([
|
|
{ currency: 'RUB', value: '0.4' },
|
|
{ currency: 'USD', value: '0.2' },
|
|
]);
|
|
expect(result.cashBalances).toEqual([
|
|
{ currency: 'RUB', value: '0.2' },
|
|
{ currency: 'USD', value: '0.2' },
|
|
]);
|
|
expect(result.positions.map((position) => position.account?.id)).toEqual([
|
|
'a',
|
|
'b',
|
|
]);
|
|
});
|
|
|
|
it('rejects missing access and a partial portfolio instead of publishing incomplete totals', async () => {
|
|
const { portfolio, access, tbank } = setup();
|
|
access.withSupportedAccounts.mockImplementationOnce(() => {
|
|
throw new ConflictException({ code: 'TBANK_NOT_CONNECTED' });
|
|
});
|
|
await expect(portfolio.getAll('user')).rejects.toBeInstanceOf(
|
|
ConflictException,
|
|
);
|
|
expect(tbank.getPortfolio).not.toHaveBeenCalled();
|
|
tbank.getPortfolio.mockRejectedValueOnce(new ServiceUnavailableException());
|
|
await expect(portfolio.getAll('user')).rejects.toBeInstanceOf(
|
|
ServiceUnavailableException,
|
|
);
|
|
});
|
|
|
|
it('marks an aggregate stale and uses its oldest snapshot timestamp', async () => {
|
|
jest.useFakeTimers().setSystemTime(new Date('2026-09-01T10:00:00Z'));
|
|
const { portfolio, tbank } = setup();
|
|
const first = await portfolio.getAll('user');
|
|
jest.setSystemTime(new Date('2026-09-01T10:01:00Z'));
|
|
tbank.getPortfolio.mockRejectedValueOnce(new ServiceUnavailableException());
|
|
const result = await portfolio.getAll('user');
|
|
expect(result.isStale).toBe(true);
|
|
expect(result.lastSuccessfulAt).toBe(first.lastSuccessfulAt);
|
|
});
|
|
|
|
it('paginates interleaved streams without dropping buffered rows, preserving duplicate IDs', async () => {
|
|
const { operations, tbank } = setup();
|
|
tbank.getOperationsByCursor.mockImplementation(
|
|
(
|
|
_token: string,
|
|
{ accountId, cursor }: { accountId: string; cursor?: string },
|
|
) => {
|
|
if (accountId === 'a')
|
|
return cursor
|
|
? { items: [operation('last', 3)] }
|
|
: {
|
|
items: [operation('same', 9), operation('a2', 5)],
|
|
hasNext: true,
|
|
nextCursor: 'a-next',
|
|
};
|
|
return { items: [operation('same', 8), operation('b2', 4)] };
|
|
},
|
|
);
|
|
const first = await operations.getAll('user', query);
|
|
assert.ok(first.nextCursor);
|
|
const second = await operations.getAll('user', {
|
|
...query,
|
|
cursor: first.nextCursor,
|
|
});
|
|
const repeated = await operations.getAll('user', {
|
|
...query,
|
|
cursor: first.nextCursor,
|
|
});
|
|
assert.ok(second.nextCursor);
|
|
const third = await operations.getAll('user', {
|
|
...query,
|
|
cursor: second.nextCursor,
|
|
});
|
|
expect(first.items.map((item) => [item.id, item.account?.id])).toEqual([
|
|
['same', 'a'],
|
|
['same', 'b'],
|
|
]);
|
|
expect(second.items.map((item) => item.id)).toEqual(['a2', 'b2']);
|
|
expect(repeated).toEqual(second);
|
|
expect(third.items.map((item) => item.id)).toEqual(['last']);
|
|
expect(third.hasNext).toBe(false);
|
|
});
|
|
|
|
it('continues through empty filtered pages and breaks ties by account', async () => {
|
|
const { operations, tbank } = setup();
|
|
tbank.getOperationsByCursor.mockImplementation(
|
|
(_token: string, { cursor }: { cursor?: string }) =>
|
|
cursor
|
|
? { items: [operation('income', 5)] }
|
|
: {
|
|
items: [operation('fee', 8, 'OPERATION_TYPE_BROKER_FEE')],
|
|
hasNext: true,
|
|
nextCursor: 'next',
|
|
},
|
|
);
|
|
const page = await operations.getAll('user', {
|
|
...query,
|
|
categories: ['income'],
|
|
});
|
|
expect(page.items.map((item) => item.account?.id)).toEqual(['a', 'b']);
|
|
expect(page.items.every((item) => item.category === 'income')).toBe(true);
|
|
});
|
|
|
|
it('rejects damaged cursor chains and partial streams', async () => {
|
|
const { operations, tbank } = setup();
|
|
tbank.getOperationsByCursor.mockResolvedValue({
|
|
items: [],
|
|
hasNext: true,
|
|
nextCursor: 'loop',
|
|
});
|
|
await expect(operations.getAll('user', query)).rejects.toBeInstanceOf(
|
|
ServiceUnavailableException,
|
|
);
|
|
tbank.getOperationsByCursor.mockRejectedValue(
|
|
new ServiceUnavailableException(),
|
|
);
|
|
await expect(
|
|
operations.getAll('another-user', query),
|
|
).rejects.toBeInstanceOf(ServiceUnavailableException);
|
|
});
|
|
|
|
it('binds continuations to user, connection, accounts and filters', async () => {
|
|
const { operations, context, tbank } = setup();
|
|
tbank.getOperationsByCursor.mockResolvedValue({
|
|
items: [operation('first', 8), operation('last', 4)],
|
|
});
|
|
const first = await operations.getAll('user', query);
|
|
assert.ok(first.nextCursor);
|
|
const next = { ...query, cursor: first.nextCursor };
|
|
await expect(operations.getAll('other', next)).rejects.toBeInstanceOf(
|
|
BadRequestException,
|
|
);
|
|
await expect(
|
|
operations.getAll('user', { ...next, categories: ['fee'] }),
|
|
).rejects.toBeInstanceOf(BadRequestException);
|
|
context.generation = 'replaced';
|
|
await expect(operations.getAll('user', next)).rejects.toBeInstanceOf(
|
|
BadRequestException,
|
|
);
|
|
context.generation = 'first';
|
|
context.accounts.pop();
|
|
await expect(operations.getAll('user', next)).rejects.toBeInstanceOf(
|
|
BadRequestException,
|
|
);
|
|
expect(tbank.getOperationsByCursor).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('collects income across the same cohort and computes comparisons from all receipts', async () => {
|
|
const { income, tbank } = setup();
|
|
tbank.getOperationsByCursor.mockImplementation(
|
|
(
|
|
_token: string,
|
|
{ accountId, from }: { accountId: string; from: { seconds: string } },
|
|
) => {
|
|
const year = new Date(Number(from.seconds) * 1000).getUTCFullYear();
|
|
const second =
|
|
Date.parse(
|
|
year === 2025 ? '2025-08-15T12:00:00Z' : '2026-08-15T12:00:00Z',
|
|
) / 1000;
|
|
return {
|
|
items:
|
|
year === 2025 && accountId === 'b'
|
|
? []
|
|
: [
|
|
operation(
|
|
'same',
|
|
second,
|
|
'OPERATION_TYPE_COUPON',
|
|
year === 2025 ? 2 : 3,
|
|
),
|
|
],
|
|
};
|
|
},
|
|
);
|
|
const result = await income.analytics(
|
|
'user',
|
|
'all',
|
|
'2026-08-01',
|
|
'2026-08-31',
|
|
);
|
|
expect(result.totalsByCurrency[0].total.value).toBe('6');
|
|
expect(
|
|
result.yearComparison?.totalsByCurrency[0].compared.total.value,
|
|
).toBe('2');
|
|
expect(
|
|
result.yearComparison?.totalsByCurrency[0].difference.total.value,
|
|
).toBe('4');
|
|
expect(result.entries.map((entry) => entry.account?.id)).toEqual([
|
|
'a',
|
|
'b',
|
|
]);
|
|
expect(
|
|
new Set(result.entries.map((entry) => entry.instrumentKey)).size,
|
|
).toBe(2);
|
|
});
|
|
|
|
it('preserves separate future events and fails the section when one source fails', async () => {
|
|
const { events, tbank, moex } = setup();
|
|
const today = new Date().toISOString().slice(0, 10);
|
|
tbank.getPortfolio.mockResolvedValue({
|
|
positions: [
|
|
{
|
|
figi: 'BOND',
|
|
ticker: 'BOND',
|
|
instrumentType: 'bond',
|
|
quantity: { units: 2 },
|
|
},
|
|
],
|
|
});
|
|
moex.bondEvents.mockResolvedValue([
|
|
{ type: 'coupon', date: today, value: '0.1', currency: 'RUB' },
|
|
]);
|
|
const result = await events.getAll('user');
|
|
expect(result.events.map((event) => event.account?.id)).toEqual(['a', 'b']);
|
|
expect(result.incomeTotalsByCurrency).toEqual([
|
|
{ currency: 'RUB', value: '0.4' },
|
|
]);
|
|
tbank.getPortfolio
|
|
.mockResolvedValueOnce({ positions: [] })
|
|
.mockRejectedValueOnce(new ServiceUnavailableException());
|
|
await expect(events.getAll('user')).rejects.toBeInstanceOf(
|
|
ServiceUnavailableException,
|
|
);
|
|
});
|
|
|
|
it('returns valid empty results for an empty account set', async () => {
|
|
const { context, portfolio, operations, income, events, tbank } = setup();
|
|
context.accounts = [];
|
|
expect((await portfolio.getAll('user')).positions).toEqual([]);
|
|
expect((await operations.getAll('user', query)).hasNext).toBe(false);
|
|
expect((await income.summary('user', 'all')).totalsByCurrency).toEqual([]);
|
|
expect((await events.getAll('user')).events).toEqual([]);
|
|
expect(tbank.getOperationsByCursor).not.toHaveBeenCalled();
|
|
expect(tbank.getPortfolio).not.toHaveBeenCalled();
|
|
});
|
|
});
|