moex-vibe/apps/backend/src/modules/tbank/services/broker-events.service.spec.ts
Sergey Krylov 75fead68b8 refactor: split MoexClientService into domain-specific clients
- MoexHttpClient: infrastructure (axios, rate limiter, circuit breaker)
- MoexSecuritiesClient: search and security descriptions
- MoexMarketDataClient: share/bond market data and batch queries
- MoexCandlesClient: candle data
- MoexHistoryClient: share/bond history
- MoexDividendsClient: dividend data
- Removed @Global() from MoexClientModule
- Updated all 7 consumers with explicit DI
- All 141 tests passing
2026-06-25 20:49:17 +03:00

500 lines
18 KiB
TypeScript

import { CacheService } from '../../cache/cache.service';
import { EntityNotFoundException } from '../../../common/exceptions/entity-not-found.exception';
import { MoexMarketDataClient } from '../../moex-client/moex-market-data.client';
import { MoexDividendsClient } from '../../moex-client/moex-dividends.client';
import { BrokerAccountsService } from './broker-accounts.service';
import { BrokerEventsService } from './broker-events.service';
import { BrokerOperationsService } from './broker-operations.service';
import { BrokerPortfolioService } from './broker-portfolio.service';
describe('BrokerEventsService', () => {
const accounts = { findById: vi.fn() } as unknown as BrokerAccountsService;
const portfolio = { getPositionsWithInstruments: vi.fn() } as unknown as BrokerPortfolioService;
const moexMarketData = { getBondPositionDataBatch: vi.fn() } as unknown as MoexMarketDataClient;
const moexDividends = { getDividends: vi.fn() } as unknown as MoexDividendsClient;
const operations = { getOperations: vi.fn() } as unknown as BrokerOperationsService;
const cache = { getOrFetch: vi.fn() } as unknown as CacheService;
const acc1 = {
id: 'acc-1',
type: 'brokerage' as const,
name: 'Test Broker',
status: 'ACCOUNT_STATUS_OPEN',
openedAt: null,
accessLevel: null,
};
beforeEach(() => {
vi.clearAllMocks();
});
function mockCachePassthrough() {
vi.mocked(cache.getOrFetch).mockImplementation(
async (_prefix: string, _parts: string[], fetchFn: () => Promise<unknown>) => ({
data: await fetchFn(),
fromCache: false,
cachedAt: null,
}),
);
}
it('throws 404 for missing account', async () => {
vi.mocked(accounts.findById).mockResolvedValue(null);
const service = new BrokerEventsService(accounts, portfolio, moexMarketData, moexDividends, operations, cache);
await expect(
service.getEvents('missing', { from: '2026-06-01', to: '2026-07-01' }),
).rejects.toThrow(EntityNotFoundException);
});
it('returns empty events for account with no positions', async () => {
vi.mocked(accounts.findById).mockResolvedValue(acc1);
mockCachePassthrough();
vi.mocked(portfolio.getPositionsWithInstruments).mockResolvedValue({
positions: [],
instruments: new Map(),
});
vi.mocked(operations.getOperations).mockResolvedValue({
data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-01' },
fromCache: false,
cachedAt: null,
});
const service = new BrokerEventsService(accounts, portfolio, moexMarketData, moexDividends, operations, cache);
const result = await service.getEvents('acc-1', { from: '2026-06-01', to: '2026-07-01' });
expect(result.data.items).toEqual([]);
expect(result.data.summary.eventCount).toBe(0);
expect(result.data.summary.nearestEventDate).toBeNull();
expect(result.data.summary.totalEstimatedCashflow).toBe(0);
expect(result.data.summary.actualCashflow).toBe(0);
expect(result.data.summary.forecastEstimatedCashflow).toBe(0);
});
it('builds dividend events from share positions in date range', async () => {
vi.mocked(accounts.findById).mockResolvedValue(acc1);
mockCachePassthrough();
vi.mocked(portfolio.getPositionsWithInstruments).mockResolvedValue({
positions: [
{
ticker: 'SBER',
instrumentUid: 'uid-sber',
instrumentType: 'share',
quantity: { units: '10', nano: 0 },
},
],
instruments: new Map([['uid-sber', { name: 'Sberbank', currency: 'RUB' }]]),
});
vi.mocked(moexDividends.getDividends).mockResolvedValue([
{
secid: 'SBER',
isin: 'RU000A0JS',
registryCloseDate: '2026-06-15',
value: 33.5,
currencyId: 'RUB',
},
{
secid: 'SBER',
isin: 'RU000A0JS',
registryCloseDate: '2026-06-25',
value: 33.5,
currencyId: 'RUB',
},
{
secid: 'SBER',
isin: 'RU000A0JS',
registryCloseDate: '2026-08-01',
value: 33.5,
currencyId: 'RUB',
},
]);
vi.mocked(operations.getOperations).mockResolvedValue({
data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-20' },
fromCache: false,
cachedAt: null,
});
const service = new BrokerEventsService(accounts, portfolio, moexMarketData, moexDividends, operations, cache);
const result = await service.getEvents('acc-1', { from: '2026-06-20', to: '2026-07-10' });
expect(result.data.items).toHaveLength(1);
expect(result.data.items[0].type).toBe('dividend');
expect(result.data.items[0].eventDate).toBe('2026-06-25');
expect(result.data.items[0].estimatedAmount).toBe(335);
expect(result.data.items[0].actualAmount).toBeNull();
expect(result.data.items[0].source).toBe('forecast');
expect(result.data.items[0].currency).toBe('RUB');
expect(result.data.items[0].name).toBe('Sberbank');
});
it('builds coupon, maturity, and offer events for bonds', async () => {
vi.mocked(accounts.findById).mockResolvedValue(acc1);
mockCachePassthrough();
vi.mocked(portfolio.getPositionsWithInstruments).mockResolvedValue({
positions: [
{
ticker: 'SU26248RMFS4',
instrumentUid: 'uid-bond-1',
instrumentType: 'bond',
quantity: { units: '5', nano: 0 },
},
],
instruments: new Map([['uid-bond-1', { name: 'OFZ 26248', currency: 'RUB' }]]),
});
vi.mocked(moexMarketData.getBondPositionDataBatch).mockResolvedValue([
{
secid: 'SU26248RMFS4',
couponValue: 35.4,
nextCouponDate: '2026-06-25',
matDate: '2026-06-27',
offerDate: '2026-06-28',
faceValue: 1000,
boardid: 'TQCB',
shortName: '',
price: null,
yieldToMaturity: null,
duration: null,
couponPercent: null,
accruedInt: null,
bid: null,
offer: null,
couponPeriod: null,
bondType: null,
},
]);
vi.mocked(operations.getOperations).mockResolvedValue({
data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-20' },
fromCache: false,
cachedAt: null,
});
const service = new BrokerEventsService(accounts, portfolio, moexMarketData, moexDividends, operations, cache);
const result = await service.getEvents('acc-1', { from: '2026-06-20', to: '2026-07-10' });
expect(result.data.items).toHaveLength(3);
const coupon = result.data.items.find((e) => e.type === 'coupon')!;
expect(coupon.estimatedAmount).toBe(177);
const offer = result.data.items.find((e) => e.type === 'offer')!;
expect(offer.category).toBe('corporate');
const maturity = result.data.items.find((e) => e.type === 'maturity')!;
expect(maturity.estimatedAmount).toBe(5000);
});
it('handles partial failures gracefully', async () => {
vi.mocked(accounts.findById).mockResolvedValue(acc1);
mockCachePassthrough();
vi.mocked(portfolio.getPositionsWithInstruments).mockResolvedValue({
positions: [
{
ticker: 'OK_SPLIT',
instrumentUid: 'uid-1',
instrumentType: 'share',
quantity: { units: '10', nano: 0 },
},
{
ticker: 'GOOD',
instrumentUid: 'uid-2',
instrumentType: 'share',
quantity: { units: '5', nano: 0 },
},
],
instruments: new Map([
['uid-1', { name: 'Failing' }],
['uid-2', { name: 'Working' }],
]),
});
vi.mocked(moexDividends.getDividends).mockRejectedValueOnce(new Error('MOEX error'));
vi.mocked(moexDividends.getDividends).mockResolvedValueOnce([
{ secid: 'GOOD', isin: 'RU', registryCloseDate: '2026-06-25', value: 20, currencyId: 'RUB' },
]);
vi.mocked(operations.getOperations).mockResolvedValue({
data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-20' },
fromCache: false,
cachedAt: null,
});
const service = new BrokerEventsService(accounts, portfolio, moexMarketData, moexDividends, operations, cache);
const result = await service.getEvents('acc-1', { from: '2026-06-20', to: '2026-07-10' });
expect(result.data.items).toHaveLength(1);
expect(result.data.items[0].ticker).toBe('GOOD');
});
it('includes events without amount when payout is unknown', async () => {
vi.mocked(accounts.findById).mockResolvedValue(acc1);
mockCachePassthrough();
vi.mocked(portfolio.getPositionsWithInstruments).mockResolvedValue({
positions: [
{
ticker: 'NO_AMT',
instrumentUid: 'uid-1',
instrumentType: 'share',
quantity: { units: '10', nano: 0 },
},
],
instruments: new Map([['uid-1', { name: 'No Amount' }]]),
});
vi.mocked(moexDividends.getDividends).mockResolvedValue([
{ secid: 'NO_AMT', isin: 'RU', registryCloseDate: '2026-06-25', value: 0, currencyId: 'RUB' },
]);
vi.mocked(operations.getOperations).mockResolvedValue({
data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-20' },
fromCache: false,
cachedAt: null,
});
const service = new BrokerEventsService(accounts, portfolio, moexMarketData, moexDividends, operations, cache);
const result = await service.getEvents('acc-1', { from: '2026-06-20', to: '2026-07-10' });
expect(result.data.items).toHaveLength(1);
expect(result.data.items[0].estimatedAmount).toBe(0);
});
it('calculates summary totals correctly', async () => {
vi.mocked(accounts.findById).mockResolvedValue(acc1);
mockCachePassthrough();
vi.mocked(portfolio.getPositionsWithInstruments).mockResolvedValue({
positions: [
{
ticker: 'SBER',
instrumentUid: 'uid-1',
instrumentType: 'share',
quantity: { units: '10', nano: 0 },
},
{
ticker: 'BOND1',
instrumentUid: 'uid-2',
instrumentType: 'bond',
quantity: { units: '2', nano: 0 },
},
],
instruments: new Map([
['uid-1', { name: 'Sber' }],
['uid-2', { name: 'OFZ' }],
]),
});
vi.mocked(moexDividends.getDividends).mockResolvedValue([
{ secid: 'SBER', isin: 'RU1', registryCloseDate: '2026-06-25', value: 30, currencyId: 'RUB' },
]);
vi.mocked(moexMarketData.getBondPositionDataBatch).mockResolvedValue([
{
secid: 'BOND1',
couponValue: 50,
nextCouponDate: '2026-06-26',
matDate: '2026-06-27',
offerDate: null,
faceValue: 1000,
boardid: 'TQCB',
shortName: '',
price: null,
yieldToMaturity: null,
duration: null,
couponPercent: null,
accruedInt: null,
bid: null,
offer: null,
couponPeriod: null,
bondType: null,
},
]);
vi.mocked(operations.getOperations).mockResolvedValue({
data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-20' },
fromCache: false,
cachedAt: null,
});
const service = new BrokerEventsService(accounts, portfolio, moexMarketData, moexDividends, operations, cache);
const result = await service.getEvents('acc-1', { from: '2026-06-20', to: '2026-07-10' });
expect(result.data.summary.eventCount).toBe(3);
expect(result.data.summary.nearestEventDate).toBe('2026-06-25');
expect(result.data.summary.dividendsTotal).toBe(300);
expect(result.data.summary.couponsTotal).toBe(100);
expect(result.data.summary.principalRepaymentTotal).toBe(2000);
expect(result.data.summary.totalEstimatedCashflow).toBe(2400);
expect(result.data.summary.forecastEstimatedCashflow).toBe(2400);
expect(result.data.summary.actualCashflow).toBe(0);
});
it('filters events by inclusive date range', async () => {
vi.mocked(accounts.findById).mockResolvedValue(acc1);
mockCachePassthrough();
vi.mocked(portfolio.getPositionsWithInstruments).mockResolvedValue({
positions: [
{
ticker: 'SBER',
instrumentUid: 'uid-1',
instrumentType: 'share',
quantity: { units: '1', nano: 0 },
},
],
instruments: new Map([['uid-1', { name: 'Sber' }]]),
});
vi.mocked(moexDividends.getDividends).mockResolvedValue([
{ secid: 'SBER', isin: 'RU', registryCloseDate: '2026-06-20', value: 10, currencyId: 'RUB' },
{ secid: 'SBER', isin: 'RU', registryCloseDate: '2026-07-29', value: 10, currencyId: 'RUB' },
{ secid: 'SBER', isin: 'RU', registryCloseDate: '2026-07-30', value: 10, currencyId: 'RUB' },
]);
vi.mocked(operations.getOperations).mockResolvedValue({
data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-20' },
fromCache: false,
cachedAt: null,
});
const service = new BrokerEventsService(accounts, portfolio, moexMarketData, moexDividends, operations, cache);
const result = await service.getEvents('acc-1', { from: '2026-06-20', to: '2026-07-29' });
expect(result.data.items).toHaveLength(2);
expect(result.data.items[0].eventDate).toBe('2026-06-20');
expect(result.data.items[1].eventDate).toBe('2026-07-29');
});
it('filters forecast events by selected event types', async () => {
vi.mocked(accounts.findById).mockResolvedValue(acc1);
mockCachePassthrough();
vi.mocked(portfolio.getPositionsWithInstruments).mockResolvedValue({
positions: [
{
ticker: 'SBER',
instrumentUid: 'uid-share',
instrumentType: 'share',
quantity: { units: '10', nano: 0 },
},
{
ticker: 'BOND1',
instrumentUid: 'uid-bond',
instrumentType: 'bond',
quantity: { units: '2', nano: 0 },
},
],
instruments: new Map(),
});
vi.mocked(moexDividends.getDividends).mockResolvedValue([
{ secid: 'SBER', isin: 'RU', registryCloseDate: '2026-06-25', value: 30, currencyId: 'RUB' },
]);
vi.mocked(moexMarketData.getBondPositionDataBatch).mockResolvedValue([
{
secid: 'BOND1',
couponValue: 50,
nextCouponDate: '2026-06-26',
matDate: '2026-06-27',
offerDate: '2026-06-28',
faceValue: 1000,
boardid: 'TQCB',
shortName: '',
price: null,
yieldToMaturity: null,
duration: null,
couponPercent: null,
accruedInt: null,
bid: null,
offer: null,
couponPeriod: null,
bondType: null,
},
]);
vi.mocked(operations.getOperations).mockResolvedValue({
data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-20' },
fromCache: false,
cachedAt: null,
});
const service = new BrokerEventsService(accounts, portfolio, moexMarketData, moexDividends, operations, cache);
const result = await service.getEvents('acc-1', {
from: '2026-06-20',
to: '2026-07-10',
types: 'coupon,maturity',
});
expect(result.data.items.map((event) => event.type)).toEqual(['coupon', 'maturity']);
expect(cache.getOrFetch).toHaveBeenCalledWith(
expect.any(String),
['acc-1', '2026-06-20', '2026-07-10', 'coupon,maturity'],
expect.any(Function),
'tbankPortfolioTtl',
);
});
it('adds actual past income events from broker operations', async () => {
vi.mocked(accounts.findById).mockResolvedValue(acc1);
mockCachePassthrough();
vi.mocked(portfolio.getPositionsWithInstruments).mockResolvedValue({
positions: [],
instruments: new Map(),
});
vi.mocked(operations.getOperations).mockResolvedValue({
data: {
accountId: 'acc-1',
items: [
{
cursor: 'cur-1',
accountId: 'acc-1',
id: 'op-1',
parentOperationId: null,
date: '2026-06-18T10:00:00.000Z',
type: 'OPERATION_TYPE_DIVIDEND',
category: 'income',
description: 'Dividend payment',
name: 'Sberbank',
state: 'OPERATION_STATE_EXECUTED',
instrumentUid: 'uid-sber',
figi: null,
ticker: 'SBER',
classCode: 'TQBR',
instrumentType: 'share',
payment: { currency: 'RUB', units: '123', nano: 450000000, value: 123.45 },
price: null,
commission: null,
yield: null,
accruedInt: null,
quantity: null,
quantityDone: null,
},
],
nextCursor: null,
hasNext: false,
asOf: '2026-06-19T00:00:00.000Z',
},
fromCache: false,
cachedAt: null,
});
const service = new BrokerEventsService(accounts, portfolio, moexMarketData, moexDividends, operations, cache);
const result = await service.getEvents('acc-1', {
from: '2026-06-15',
to: '2026-06-20',
types: 'dividend',
});
expect(operations.getOperations).toHaveBeenCalledWith('acc-1', {
from: '2026-06-15T00:00:00.000Z',
to: '2026-06-20T23:59:59.999Z',
operationTypes: 'OPERATION_TYPE_DIVIDEND,OPERATION_TYPE_DIV_EXT',
limit: 100,
state: 'OPERATION_STATE_EXECUTED',
});
expect(result.data.items).toEqual([
expect.objectContaining({
id: 'actual-op-1',
type: 'dividend',
source: 'actual',
eventDate: '2026-06-18',
actualAmount: 123.45,
estimatedAmount: null,
estimateMode: null,
currency: 'RUB',
}),
]);
expect(result.data.summary.actualCashflow).toBe(123.45);
expect(result.data.summary.actualDividendsTotal).toBe(123.45);
expect(result.data.summary.forecastEstimatedCashflow).toBe(0);
});
});