refactor: organize backend by business responsibility
This commit is contained in:
@@ -0,0 +1,434 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
NotFoundException,
|
||||
ServiceUnavailableException,
|
||||
} from '@nestjs/common';
|
||||
|
||||
jest.mock('../../../integrations/tbank/tbank-access.service', () => ({
|
||||
TbankAccessService: class TbankAccessService {},
|
||||
}));
|
||||
|
||||
import type { TbankAccountContext } from '../../../integrations/tbank/tbank-access.service';
|
||||
import { UpcomingEventsService } from './upcoming-events.service';
|
||||
|
||||
describe('UpcomingEventsService', () => {
|
||||
const access = { withAccount: jest.fn() };
|
||||
const tbank = {
|
||||
getPortfolio: jest.fn(),
|
||||
getInstrumentByUid: jest.fn(),
|
||||
getInstrumentByFigi: jest.fn(),
|
||||
getDividends: jest.fn(),
|
||||
};
|
||||
const moex = { bondEvents: jest.fn() };
|
||||
const requestContext = { set: jest.fn() };
|
||||
const logger = { info: jest.fn(), event: jest.fn() };
|
||||
let service: UpcomingEventsService;
|
||||
|
||||
beforeEach(() => {
|
||||
service = new UpcomingEventsService(
|
||||
access as never,
|
||||
tbank as never,
|
||||
moex as never,
|
||||
requestContext as never,
|
||||
logger as never,
|
||||
);
|
||||
jest.clearAllMocks();
|
||||
access.withAccount.mockImplementation(
|
||||
(
|
||||
_userId: string,
|
||||
_accountId: string,
|
||||
handler: (context: TbankAccountContext) => Promise<unknown>,
|
||||
) =>
|
||||
handler({
|
||||
token: 'token',
|
||||
generation: 'generation-1',
|
||||
account: { id: 'account', type: 'ACCOUNT_TYPE_TINKOFF', name: '' },
|
||||
}),
|
||||
);
|
||||
tbank.getInstrumentByFigi.mockResolvedValue({ name: 'Бумага' });
|
||||
moex.bondEvents.mockResolvedValue([]);
|
||||
tbank.getDividends.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it('includes both selected boundaries beyond the default window and isolates dividend caches by period', async () => {
|
||||
tbank.getPortfolio.mockResolvedValue({
|
||||
positions: [
|
||||
{
|
||||
figi: 'S',
|
||||
ticker: 'SHARE',
|
||||
instrumentType: 'share',
|
||||
quantity: { units: 2 },
|
||||
},
|
||||
{
|
||||
figi: 'B',
|
||||
ticker: 'BOND',
|
||||
instrumentType: 'bond',
|
||||
quantity: { units: 3 },
|
||||
},
|
||||
],
|
||||
});
|
||||
moex.bondEvents.mockResolvedValue([
|
||||
{ type: 'coupon', date: '2099-02-01', value: '2', currency: 'RUB' },
|
||||
{ type: 'offer', date: '2099-02-28' },
|
||||
{ type: 'maturity', date: '2099-03-01', value: '1000', currency: 'RUB' },
|
||||
{ type: 'offer', date: '2099-01-31' },
|
||||
]);
|
||||
tbank.getDividends.mockImplementation(
|
||||
(_token: string, _id: string, from: string) =>
|
||||
Promise.resolve([
|
||||
{
|
||||
dividendNet: { units: 5, currency: 'RUB' },
|
||||
recordDate: {
|
||||
seconds: String(Date.parse(`${from}T00:00:00Z`) / 1000),
|
||||
},
|
||||
},
|
||||
]),
|
||||
);
|
||||
const february = { from: '2099-02-01', to: '2099-02-28' };
|
||||
const first = await service.get('user', 'account', february);
|
||||
expect(first.events.map(({ type, date }) => ({ type, date }))).toEqual([
|
||||
{ type: 'coupon', date: february.from },
|
||||
{ type: 'dividend', date: february.from },
|
||||
{ type: 'offer', date: february.to },
|
||||
]);
|
||||
expect(first.incomeTotalsByCurrency).toEqual([
|
||||
{ currency: 'RUB', value: '16' },
|
||||
]);
|
||||
const march = await service.get('user', 'account', {
|
||||
from: '2099-03-01',
|
||||
to: '2099-03-01',
|
||||
});
|
||||
expect(march.events.map((event) => event.type)).toEqual([
|
||||
'dividend',
|
||||
'maturity',
|
||||
]);
|
||||
expect(march.incomeTotalsByCurrency).toEqual([
|
||||
{ currency: 'RUB', value: '10' },
|
||||
]);
|
||||
await service.get('user', 'account', february);
|
||||
expect(tbank.getDividends).toHaveBeenCalledTimes(2);
|
||||
expect(tbank.getDividends).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'token',
|
||||
'S',
|
||||
february.from,
|
||||
february.to,
|
||||
);
|
||||
expect(tbank.getDividends).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'token',
|
||||
'S',
|
||||
'2099-03-01',
|
||||
'2099-03-01',
|
||||
);
|
||||
});
|
||||
|
||||
it('defaults the end to 30 days after the selected start', async () => {
|
||||
tbank.getPortfolio.mockResolvedValue({
|
||||
positions: [
|
||||
{
|
||||
figi: 'B',
|
||||
ticker: 'BOND',
|
||||
instrumentType: 'bond',
|
||||
quantity: { units: 1 },
|
||||
},
|
||||
],
|
||||
});
|
||||
moex.bondEvents.mockResolvedValue([
|
||||
{ type: 'offer', date: '2099-01-01' },
|
||||
{ type: 'offer', date: '2099-01-31' },
|
||||
{ type: 'offer', date: '2099-02-01' },
|
||||
]);
|
||||
const result = await service.get('user', 'account', { from: '2099-01-01' });
|
||||
expect(result.events.map((event) => event.date)).toEqual([
|
||||
'2099-01-01',
|
||||
'2099-01-31',
|
||||
]);
|
||||
});
|
||||
|
||||
it('projects exact amounts and preserves registry dates and offers', async () => {
|
||||
const now = new Date();
|
||||
const day = (offset: number) =>
|
||||
new Date(
|
||||
Date.UTC(
|
||||
now.getUTCFullYear(),
|
||||
now.getUTCMonth(),
|
||||
now.getUTCDate() + offset,
|
||||
),
|
||||
)
|
||||
.toISOString()
|
||||
.slice(0, 10);
|
||||
tbank.getInstrumentByFigi.mockResolvedValue({
|
||||
name: 'Бумага',
|
||||
isin: ' RU000A0JX0J2 ',
|
||||
brand: { logoName: ' instrument-logo.png ' },
|
||||
});
|
||||
tbank.getPortfolio.mockResolvedValue({
|
||||
positions: [
|
||||
{
|
||||
figi: 'S',
|
||||
ticker: 'SBER',
|
||||
instrumentType: 'share',
|
||||
quantity: { units: 2 },
|
||||
},
|
||||
{
|
||||
figi: 'B',
|
||||
ticker: 'BOND',
|
||||
instrumentType: 'bond',
|
||||
quantity: { units: 3 },
|
||||
},
|
||||
],
|
||||
});
|
||||
tbank.getDividends.mockResolvedValue([
|
||||
{
|
||||
dividendNet: { units: 1, nano: 250000000, currency: 'RUB' },
|
||||
recordDate: {
|
||||
seconds: String(Math.floor(Date.parse(`${day(1)}T00:00:00Z`) / 1000)),
|
||||
},
|
||||
dividendType: 'Regular Cash',
|
||||
},
|
||||
]);
|
||||
moex.bondEvents.mockResolvedValue([
|
||||
{ type: 'offer', date: day(2) },
|
||||
{ type: 'coupon', date: day(1), value: '2.5', currency: 'RUB' },
|
||||
{ type: 'maturity', date: day(31), value: '1000', currency: 'RUB' },
|
||||
]);
|
||||
|
||||
await expect(service.get('user', 'account')).resolves.toMatchObject({
|
||||
events: [
|
||||
{
|
||||
type: 'coupon',
|
||||
projectedAmount: { currency: 'RUB', value: '7.5' },
|
||||
dateKind: 'event',
|
||||
isin: 'RU000A0JX0J2',
|
||||
logoName: 'instrument-logo.png',
|
||||
},
|
||||
{
|
||||
type: 'dividend',
|
||||
projectedAmount: { currency: 'RUB', value: '2.5' },
|
||||
dateKind: 'registry',
|
||||
isin: 'RU000A0JX0J2',
|
||||
logoName: 'instrument-logo.png',
|
||||
},
|
||||
{
|
||||
type: 'offer',
|
||||
projectedAmount: null,
|
||||
isin: 'RU000A0JX0J2',
|
||||
logoName: 'instrument-logo.png',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it.each([undefined, ' '])(
|
||||
'keeps events without optional metadata when it is %s',
|
||||
async (metadata) => {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
tbank.getPortfolio.mockResolvedValue({
|
||||
positions: [
|
||||
{
|
||||
instrumentUid: 'bond-uid',
|
||||
ticker: 'BOND',
|
||||
instrumentType: 'bond',
|
||||
quantity: { units: 1 },
|
||||
},
|
||||
],
|
||||
});
|
||||
tbank.getInstrumentByUid.mockResolvedValue({
|
||||
name: 'Bond',
|
||||
isin: metadata,
|
||||
brand: { logoName: metadata },
|
||||
});
|
||||
moex.bondEvents.mockResolvedValue([{ type: 'offer', date: today }]);
|
||||
await expect(service.get('user', 'account')).resolves.toMatchObject({
|
||||
events: [{ type: 'offer', isin: null, logoName: null }],
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it('does not read upstream data for inaccessible accounts', async () => {
|
||||
access.withAccount.mockRejectedValueOnce(new NotFoundException());
|
||||
await expect(service.get('user', 'account')).rejects.toEqual(
|
||||
expect.any(NotFoundException),
|
||||
);
|
||||
expect(tbank.getPortfolio).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses the portfolio ticker without an unnecessary instrument lookup', async () => {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
tbank.getPortfolio.mockResolvedValue({
|
||||
positions: [
|
||||
{
|
||||
figi: 'S',
|
||||
ticker: 'SBER',
|
||||
instrumentType: 'share',
|
||||
quantity: { units: 1 },
|
||||
},
|
||||
],
|
||||
});
|
||||
tbank.getInstrumentByFigi.mockRejectedValue(
|
||||
new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }),
|
||||
);
|
||||
tbank.getDividends.mockResolvedValue([
|
||||
{
|
||||
dividendNet: { units: 1, nano: 0, currency: 'RUB' },
|
||||
recordDate: {
|
||||
seconds: String(Math.floor(Date.parse(`${today}T00:00:00Z`) / 1000)),
|
||||
},
|
||||
dividendType: 'Regular Cash',
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(service.get('user', 'account')).resolves.toMatchObject({
|
||||
events: [
|
||||
{
|
||||
ticker: 'SBER',
|
||||
projectedAmount: { currency: 'RUB', value: '1' },
|
||||
isin: null,
|
||||
logoName: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(tbank.getInstrumentByFigi).toHaveBeenCalledWith('token', 'S');
|
||||
});
|
||||
|
||||
it('skips cancelled dividends', async () => {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
tbank.getPortfolio.mockResolvedValue({
|
||||
positions: [
|
||||
{
|
||||
figi: 'S',
|
||||
ticker: 'SBER',
|
||||
instrumentType: 'share',
|
||||
quantity: { units: 1 },
|
||||
},
|
||||
],
|
||||
});
|
||||
tbank.getDividends.mockResolvedValue([
|
||||
{
|
||||
dividendNet: { units: 1, nano: 0, currency: 'RUB' },
|
||||
recordDate: {
|
||||
seconds: String(Math.floor(Date.parse(`${today}T00:00:00Z`) / 1000)),
|
||||
},
|
||||
dividendType: 'Cancelled',
|
||||
},
|
||||
{
|
||||
dividendNet: { units: 2, nano: 0, currency: 'RUB' },
|
||||
recordDate: {
|
||||
seconds: String(Math.floor(Date.parse(`${today}T00:00:00Z`) / 1000)),
|
||||
},
|
||||
dividendType: 'Regular Cash',
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(service.get('user', 'account')).resolves.toMatchObject({
|
||||
events: [
|
||||
{
|
||||
type: 'dividend',
|
||||
projectedAmount: { currency: 'RUB', value: '2' },
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('does not invent projected money when the source omits currency', async () => {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
tbank.getPortfolio.mockResolvedValue({
|
||||
positions: [
|
||||
{
|
||||
figi: 'S',
|
||||
ticker: 'SBER',
|
||||
instrumentType: 'share',
|
||||
quantity: { units: 1 },
|
||||
},
|
||||
],
|
||||
});
|
||||
tbank.getDividends.mockResolvedValue([
|
||||
{
|
||||
dividendNet: { units: 1, nano: 0 },
|
||||
recordDate: {
|
||||
seconds: String(Math.floor(Date.parse(`${today}T00:00:00Z`) / 1000)),
|
||||
},
|
||||
dividendType: 'Regular Cash',
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(service.get('user', 'account')).resolves.toMatchObject({
|
||||
events: [{ type: 'dividend', projectedAmount: null }],
|
||||
});
|
||||
});
|
||||
|
||||
it('adds exact source-currency income totals without changing the events list', async () => {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
tbank.getPortfolio.mockResolvedValue({
|
||||
positions: [
|
||||
{
|
||||
figi: 'B',
|
||||
ticker: 'BOND',
|
||||
instrumentType: 'bond',
|
||||
quantity: { units: 1 },
|
||||
},
|
||||
],
|
||||
});
|
||||
moex.bondEvents.mockResolvedValue([
|
||||
{ type: 'coupon', date: today, value: '1.25', currency: 'RUB' },
|
||||
{ type: 'maturity', date: today, value: '100', currency: 'RUB' },
|
||||
{ type: 'offer', date: today },
|
||||
]);
|
||||
|
||||
const result = await service.get('user', 'account');
|
||||
expect(result.incomeTotalsByCurrency).toEqual([
|
||||
{ currency: 'RUB', value: '1.25' },
|
||||
]);
|
||||
expect(result.events).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ type: 'maturity' }),
|
||||
expect.objectContaining({ type: 'offer' }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('reuses upstream results within the cache TTL across requests', async () => {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
tbank.getPortfolio.mockResolvedValue({
|
||||
positions: [
|
||||
{
|
||||
figi: 'S',
|
||||
ticker: 'SBER',
|
||||
instrumentType: 'share',
|
||||
quantity: { units: 1 },
|
||||
},
|
||||
],
|
||||
});
|
||||
tbank.getDividends.mockResolvedValue([
|
||||
{
|
||||
dividendNet: { units: 1, nano: 0, currency: 'RUB' },
|
||||
recordDate: {
|
||||
seconds: String(Math.floor(Date.parse(`${today}T00:00:00Z`) / 1000)),
|
||||
},
|
||||
dividendType: 'Regular Cash',
|
||||
},
|
||||
]);
|
||||
|
||||
await service.get('user', 'account');
|
||||
await service.get('user', 'account');
|
||||
|
||||
expect(tbank.getInstrumentByFigi).toHaveBeenCalledTimes(1);
|
||||
expect(tbank.getDividends).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('preserves invalid token and source failures without a partial result', async () => {
|
||||
access.withAccount.mockRejectedValueOnce(
|
||||
new BadRequestException({ code: 'INVALID_TBANK_TOKEN' }),
|
||||
);
|
||||
await expect(service.get('user', 'account')).rejects.toEqual(
|
||||
expect.any(BadRequestException),
|
||||
);
|
||||
tbank.getPortfolio.mockRejectedValueOnce(
|
||||
new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }),
|
||||
);
|
||||
await expect(service.get('user', 'account')).rejects.toEqual(
|
||||
expect.any(ServiceUnavailableException),
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user