refactor: organize backend by business responsibility

This commit is contained in:
2026-09-13 13:52:57 +03:00
parent 7fde3ece6e
commit 8ba005346d
145 changed files with 3027 additions and 2346 deletions
@@ -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),
);
});
});