refactor: organize backend by business responsibility
This commit is contained in:
@@ -0,0 +1,429 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user