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', }); }); });