refactor: organize backend by business responsibility
This commit is contained in:
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user