moex-vibe/apps/backend/src/modules/tbank/services/broker-events.service.spec.ts
2026-06-22 06:41:37 +03:00

310 lines
10 KiB
TypeScript

import { NotFoundException } from '@nestjs/common';
import { CacheService } from '../../cache/cache.service';
import { MoexClientService } from '../../moex-client/moex-client.service';
import { BrokerAccountsService } from './broker-accounts.service';
import { BrokerEventsService } from './broker-events.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 moex = {
getDividends: vi.fn(),
getBondPositionDataBatch: vi.fn(),
} as unknown as MoexClientService;
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, moex, cache);
await expect(service.getEvents('missing', '2026-06-01', '2026-07-01')).rejects.toThrow(
NotFoundException,
);
});
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(),
});
const service = new BrokerEventsService(accounts, portfolio, moex, cache);
const result = await service.getEvents('acc-1', '2026-06-01', '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);
});
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(moex.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',
},
]);
const service = new BrokerEventsService(accounts, portfolio, moex, cache);
const result = await service.getEvents('acc-1', '2026-06-20', '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].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(moex.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,
},
]);
const service = new BrokerEventsService(accounts, portfolio, moex, cache);
const result = await service.getEvents('acc-1', '2026-06-20', '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(moex.getDividends).mockRejectedValueOnce(new Error('MOEX error'));
vi.mocked(moex.getDividends).mockResolvedValueOnce([
{ secid: 'GOOD', isin: 'RU', registryCloseDate: '2026-06-25', value: 20, currencyId: 'RUB' },
]);
const service = new BrokerEventsService(accounts, portfolio, moex, cache);
const result = await service.getEvents('acc-1', '2026-06-20', '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(moex.getDividends).mockResolvedValue([
{ secid: 'NO_AMT', isin: 'RU', registryCloseDate: '2026-06-25', value: 0, currencyId: 'RUB' },
]);
const service = new BrokerEventsService(accounts, portfolio, moex, cache);
const result = await service.getEvents('acc-1', '2026-06-20', '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(moex.getDividends).mockResolvedValue([
{ secid: 'SBER', isin: 'RU1', registryCloseDate: '2026-06-25', value: 30, currencyId: 'RUB' },
]);
vi.mocked(moex.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,
},
]);
const service = new BrokerEventsService(accounts, portfolio, moex, cache);
const result = await service.getEvents('acc-1', '2026-06-20', '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);
});
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(moex.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' },
]);
const service = new BrokerEventsService(accounts, portfolio, moex, cache);
const result = await service.getEvents('acc-1', '2026-06-20', '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');
});
});