310 lines
10 KiB
TypeScript
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');
|
|
});
|
|
});
|