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,375 @@
import {
ConflictException,
NotFoundException,
ServiceUnavailableException,
UnauthorizedException,
} from '@nestjs/common';
import { TbankAccessService } from '../../../../../integrations/tbank/tbank-access.service';
import { TbankClient } from '../../../../../integrations/tbank/tbank.client';
import { PortfolioService } from '../../../portfolio/application/portfolio.service';
import { GetStockCompanyUseCase } from './get-stock-company.use-case';
import { StockIncomeService } from './stock-income.service';
import { StockMarketService } from './stock-market.service';
import { StockCatalogService } from './stock-catalog.service';
import { previousClose } from '../../application/instrument-market';
jest.mock('../../../../../integrations/tbank/tbank-access.service', () => ({
TbankAccessService: class TbankAccessService {},
}));
const time = (date: string) => ({ seconds: String(Date.parse(date) / 1000) });
const share = {
uid: 'uid',
assetUid: 'asset',
currency: 'rub',
sector: 'financial',
first_1dayCandleDate: time('2025-11-20'),
};
function setup() {
let generation = 'a';
const tbank = {
findShares: jest.fn().mockResolvedValue([]),
getLastPrices: jest.fn().mockResolvedValue([]),
getShare: jest.fn().mockResolvedValue(share),
getCandles: jest.fn().mockResolvedValue([]),
getAssetDescription: jest.fn().mockResolvedValue('Company'),
getFundamentals: jest.fn().mockResolvedValue({
assetUid: 'asset',
currency: 'rub',
peRatioTtm: 6,
netIncomeTtm: 10,
}),
getDividends: jest.fn().mockResolvedValue([]),
};
const access = {
withConnection: jest.fn(
(user: string, handler: (context: unknown) => unknown) =>
handler({ token: user, generation }),
),
};
const portfolios = {
getAll: jest.fn().mockResolvedValue({
positions: [],
lastSuccessfulAt: '2026-09-10T00:00:00Z',
isStale: false,
}),
};
const service = new StockCatalogService(
access as unknown as TbankAccessService,
tbank as unknown as TbankClient,
);
const market = new StockMarketService(
service,
tbank as unknown as TbankClient,
);
const income = new StockIncomeService(
service,
tbank as unknown as TbankClient,
portfolios as unknown as PortfolioService,
);
const companyInfo = new GetStockCompanyUseCase(
service,
tbank as unknown as TbankClient,
);
return {
market,
income,
companyInfo,
service,
tbank,
portfolios,
changeGeneration: () => {
generation = 'b';
},
};
}
describe('stock market use case', () => {
beforeEach(() =>
jest.useFakeTimers().setSystemTime(new Date('2026-09-10T10:00:00Z')),
);
afterEach(() => jest.useRealTimers());
it('returns unique ISINs, keeps preferred shares separate and matches prices by selected UID', async () => {
const { service, tbank } = setup();
const listings = [
{
uid: 'special',
ticker: 'SBER',
classCode: 'SPEQ',
apiTradeAvailableFlag: false,
},
{
uid: 'preferred',
ticker: 'SBERP',
classCode: 'TQBR',
apiTradeAvailableFlag: true,
},
{
uid: 'common',
ticker: 'SBER',
classCode: 'TQBR',
apiTradeAvailableFlag: true,
},
].map((item) => ({
...item,
name: 'Сбербанк',
isin: item.uid === 'preferred' ? 'ISINP' : 'ISIN',
instrumentKind: 'INSTRUMENT_TYPE_SHARE',
}));
tbank.findShares.mockResolvedValue([
...listings,
{ uid: 'bond', instrumentKind: 'INSTRUMENT_TYPE_BOND' },
{ instrumentKind: 'INSTRUMENT_TYPE_SHARE' },
{ uid: '', instrumentKind: 'INSTRUMENT_TYPE_SHARE' },
]);
tbank.getShare.mockImplementation((_token: string, uid: string) =>
Promise.resolve({
...listings.find((item) => item.uid === uid),
currency: 'rub',
brand: { logoName: 'sber.png' },
shareType:
uid === 'preferred' ? 'SHARE_TYPE_PREFERRED' : 'SHARE_TYPE_COMMON',
}),
);
tbank.getLastPrices.mockResolvedValue([
{
instrumentUid: 'preferred',
price: { units: '305' },
time: time('2026-09-09'),
},
{
instrumentUid: 'common',
price: { units: '306', nano: 120000000 },
time: time('2026-09-10'),
},
]);
const result = await service.search('user', 'SBER');
expect(result.map((item) => item.instrumentUid)).toEqual([
'common',
'preferred',
]);
expect(result[0]).toMatchObject({
classCode: 'TQBR',
isin: 'ISIN',
logoName: 'sber.png',
price: { currency: 'RUB', value: '306.12' },
priceAt: '2026-09-10T00:00:00.000Z',
});
expect(result[1]).toMatchObject({
shareType: 'SHARE_TYPE_PREFERRED',
price: { value: '305' },
});
expect(tbank.getLastPrices).toHaveBeenCalledTimes(1);
});
it('prefers TQBR among equally available listings, deduplicates before the limit and keeps missing ISINs separate', async () => {
const { service, tbank } = setup();
const hit = {
instrumentKind: 'INSTRUMENT_TYPE_SHARE',
ticker: 'SBER',
apiTradeAvailableFlag: true,
};
tbank.findShares.mockResolvedValue([
...Array.from({ length: 35 }, (_, i) => ({
...hit,
uid: `alternate-${i}`,
isin: ' isin ',
classCode: 'SPEQ',
})),
{ ...hit, uid: 'common', isin: 'ISIN', classCode: 'TQBR' },
{
...hit,
uid: 'preferred',
ticker: 'SBERP',
isin: 'ISINP',
classCode: 'TQBR',
},
{ ...hit, uid: 'unknown-a', isin: '' },
{ ...hit, uid: 'unknown-b' },
]);
tbank.getShare.mockResolvedValue(null);
const result = await service.search('user', 'SBER');
expect(result.map((item) => item.instrumentUid)).toEqual([
'common',
'unknown-a',
'unknown-b',
'preferred',
]);
expect(tbank.getShare).toHaveBeenCalledTimes(4);
});
it('retains catalog identity when enrichment is unavailable, but propagates auth errors', async () => {
const { service, tbank } = setup();
tbank.findShares.mockResolvedValue([
{
uid: 'uid',
ticker: 'SBER',
classCode: 'TQBR',
instrumentKind: 'INSTRUMENT_TYPE_SHARE',
},
]);
tbank.getShare.mockRejectedValue(new ServiceUnavailableException());
tbank.getLastPrices.mockRejectedValue(new ServiceUnavailableException());
expect(await service.search('user', 'SBER')).toEqual([
expect.objectContaining({
instrumentUid: 'uid',
classCode: 'TQBR',
price: null,
logoName: null,
}),
]);
tbank.getShare.mockResolvedValue(null);
expect(await service.search('user', 'sber')).toEqual([
expect.objectContaining({ instrumentUid: 'uid' }),
]);
tbank.getShare.mockRejectedValue(new UnauthorizedException());
await expect(service.search('another-user', 'SBER')).rejects.toBeInstanceOf(
UnauthorizedException,
);
});
it('skips enrichment for an empty catalog result', async () => {
const { service, tbank } = setup();
expect(await service.search('user', 'none')).toEqual([]);
expect(tbank.getShare).not.toHaveBeenCalled();
expect(tbank.getLastPrices).not.toHaveBeenCalled();
});
it('resolves an exact ticker, preserves other listing UIDs and does not confuse SBER with SBERP', async () => {
const { service, tbank } = setup();
const listings = [
{
uid: 'common',
ticker: 'SBER',
isin: 'COMMON',
classCode: 'TQBR',
apiTradeAvailableFlag: true,
},
{
uid: 'special',
ticker: 'SBER',
isin: 'COMMON',
classCode: 'SPEQ',
apiTradeAvailableFlag: false,
},
{
uid: 'preferred',
ticker: 'SBERP',
isin: 'PREFERRED',
classCode: 'TQBR',
apiTradeAvailableFlag: true,
},
].map((item) => ({ ...item, instrumentKind: 'INSTRUMENT_TYPE_SHARE' }));
tbank.findShares.mockResolvedValue(listings);
tbank.getShare.mockImplementation((_token: string, uid: string) =>
Promise.resolve(listings.find((item) => item.uid === uid)),
);
expect(await service.getByTicker('user', 'sberp')).toMatchObject({
instrumentUid: 'preferred',
slug: 'SBERP',
});
expect(await service.get('user', 'common')).toMatchObject({
instrumentUid: 'common',
slug: 'SBER',
});
expect(await service.get('user', 'special')).toMatchObject({
instrumentUid: 'special',
slug: 'special',
});
await expect(service.getByTicker('user', 'SB')).rejects.toBeInstanceOf(
NotFoundException,
);
});
it('refuses ambiguous tickers while keeping exact UID pages accessible', async () => {
const { service, tbank } = setup();
const listings = ['one', 'two'].map((uid) => ({
uid,
ticker: 'SAME',
isin: uid,
instrumentKind: 'INSTRUMENT_TYPE_SHARE',
}));
tbank.findShares.mockResolvedValue(listings);
tbank.getShare.mockResolvedValue(listings[0]);
await expect(service.getByTicker('user', 'SAME')).rejects.toBeInstanceOf(
ConflictException,
);
expect(await service.get('user', 'one')).toMatchObject({
instrumentUid: 'one',
slug: 'one',
});
});
it('keeps exact pages if alias lookup fails and partitions ticker resolution by connection', async () => {
const { service, tbank, changeGeneration } = setup();
tbank.getShare.mockResolvedValue({ ...share, ticker: 'F' });
tbank.findShares.mockRejectedValueOnce(new ServiceUnavailableException());
expect(await service.get('user', 'uid')).toMatchObject({ slug: 'uid' });
tbank.findShares.mockResolvedValue([
{ ...share, ticker: 'F', instrumentKind: 'INSTRUMENT_TYPE_SHARE' },
]);
expect(await service.getByTicker('user', 'F')).toMatchObject({
instrumentUid: 'uid',
slug: 'F',
});
changeGeneration();
expect(await service.getByTicker('user', 'F')).toMatchObject({ slug: 'F' });
expect(tbank.findShares).toHaveBeenCalledTimes(3);
});
it('uses a complete previous trading day, never today’s main close', () => {
const candles = [
{ time: time('2026-09-09'), close: { units: '10' }, isComplete: true },
{ time: time('2026-09-10'), close: { units: '11' }, isComplete: true },
{ time: time('2026-09-08'), close: { units: '9' }, isComplete: false },
];
expect(previousClose(candles, '2026-09-10T18:00:00Z')).toEqual(candles[0]);
expect(previousClose(candles, '2026-09-09T22:00:00Z')).toEqual(candles[0]);
});
it('starts all-history at listing, keeps incomplete candles and deduplicates boundaries', async () => {
const { tbank, market } = setup();
tbank.getCandles.mockResolvedValue([
{ time: time('2026-09-09'), close: { units: '10' }, isComplete: false },
]);
const result = await market.candles('user', 'uid', 'all');
expect(tbank.getCandles).toHaveBeenCalledWith(
'user',
'uid',
new Date('2025-11-20'),
expect.any(Date),
);
expect(result.candles).toHaveLength(1);
expect(result.candles[0].isComplete).toBe(false);
});
it('does not manufacture zero fundamentals and isolates optional failures', async () => {
const { tbank, companyInfo } = setup();
tbank.getAssetDescription.mockRejectedValue(
new ServiceUnavailableException(),
);
const result = await companyInfo.execute('user', 'uid');
expect(result.description).toBeNull();
expect(result.unavailable).toEqual(['description']);
expect(result.metrics.map((item) => item.key)).toEqual([
'peRatioTtm',
'netIncomeTtm',
]);
});
it('isolates cache by user and connection generation', async () => {
const { service, tbank, changeGeneration } = setup();
await service.get('a', 'uid');
await service.get('a', 'uid');
expect(tbank.getShare).toHaveBeenCalledTimes(1);
await service.get('b', 'uid');
changeGeneration();
await service.get('a', 'uid');
expect(tbank.getShare).toHaveBeenCalledTimes(3);
});
it('keeps public dividends when there is no holding or position retrieval fails', async () => {
const { tbank, portfolios, income } = setup();
portfolios.getAll.mockRejectedValue(new ServiceUnavailableException());
tbank.getDividends.mockResolvedValue([
{
dividendNet: { currency: 'rub', units: '32', nano: 880000000 },
recordDate: time('2026-10-13'),
paymentDate: time('2026-10-27'),
},
]);
const result = await income.dividends('user', 'uid');
expect(result.estimateUnavailable).toBe(true);
expect(result.dividends[0]).toMatchObject({
perShare: { currency: 'RUB', value: '32.88' },
estimatedAmount: null,
timing: 'upcoming',
});
});
});