import { NotFoundException, ServiceUnavailableException } from '@nestjs/common'; import type { TbankShare } from '../../../../../integrations/tbank/tbank.api'; import { StockScreenerService } from './stock-screener.service'; const token = 'secret-token'; const revision = 'revision-1'; const pageSize = 30; function share(index: number, overrides: Partial = {}): TbankShare { return { apiTradeAvailableFlag: true, buyAvailableFlag: true, currency: 'RUB', lot: 1, name: `Share ${index}`, ticker: `T${String(index).padStart(2, '0')}`, uid: `e6123145-9665-43e0-8413-cd61b8aa${String(index).padStart(4, '0')}`, ...overrides, }; } describe('StockScreenerService', () => { const access = { current: jest.fn(), local: jest.fn() }; const client = { getAllShares: jest.fn() }; const logger = { event: jest.fn() }; let service: StockScreenerService; beforeEach(() => { jest.clearAllMocks(); access.current.mockResolvedValue({ revision, token }); access.local.mockResolvedValue({ revision }); client.getAllShares.mockResolvedValue( Array.from({ length: pageSize + 1 }, (_, index) => share(index)), ); service = new StockScreenerService(access, client as never, logger as never); }); it('stores the full selected catalog and returns stable non-overlapping pages', async () => { const first = await service.search('user-1', {}); const second = await service.page('user-1', first.searchId, 2); expect(first.items).toHaveLength(pageSize); expect(first.hasNext).toBe(true); expect(second).toMatchObject({ asOf: first.asOf, hasNext: false, page: 2, searchId: first.searchId }); expect(second.items).toEqual([expect.objectContaining({ ticker: 'T30' })]); expect(client.getAllShares).toHaveBeenCalledTimes(1); }); it('creates a fresh result for every search and preserves a successful empty result', async () => { client.getAllShares.mockResolvedValueOnce([share(1, { sector: 'energy' })]).mockResolvedValueOnce([share(2, { sector: 'energy' })]); const first = await service.search('user-1', { sector: 'finance' }); const second = await service.search('user-1', { sector: 'finance' }); expect(first).toMatchObject({ hasNext: false, items: [], page: 1 }); expect(second).toMatchObject({ hasNext: false, items: [], page: 1 }); expect(second.searchId).not.toBe(first.searchId); expect(client.getAllShares).toHaveBeenCalledTimes(2); }); it('does not store a result when the catalog source fails', async () => { client.getAllShares.mockRejectedValueOnce(new ServiceUnavailableException()); await expect(service.search('user-1', {})).rejects.toBeInstanceOf(ServiceUnavailableException); }); it('denies missing, foreign, stale, and out-of-range pages without fetching', async () => { const first = await service.search('user-1', {}); access.local.mockResolvedValueOnce({ revision: 'revision-2' }); await expect(service.page('user-1', first.searchId, 1)).rejects.toBeInstanceOf(NotFoundException); await expect(service.page('user-2', first.searchId, 1)).rejects.toBeInstanceOf(NotFoundException); await expect(service.page('user-1', 'missing', 1)).rejects.toBeInstanceOf(NotFoundException); await expect(service.page('user-1', first.searchId, 3)).rejects.toMatchObject({ status: 400 }); expect(client.getAllShares).toHaveBeenCalledTimes(1); }); it('null-normalizes optional source fields and rejects malformed required fields', async () => { client.getAllShares.mockResolvedValueOnce([ share(1, { countryOfRisk: undefined, currency: undefined, isin: undefined, realExchange: undefined, sector: undefined, shareType: undefined, ticker: undefined, }), ]); const result = await service.search('user-1', {}); expect(result.items[0]).toEqual(expect.objectContaining({ countryOfRisk: null, currency: null, isin: null, logoName: null, ticker: null })); client.getAllShares.mockResolvedValueOnce([share(2, { name: ' ' })]); await expect(service.search('user-1', {})).rejects.toBeInstanceOf(ServiceUnavailableException); }); it('preserves the catalog logo name in screener results', async () => { client.getAllShares.mockResolvedValueOnce([share(1, { brand: { logoName: 'sber.png' } })]); const result = await service.search('user-1', {}); expect(result.items).toEqual([expect.objectContaining({ logoName: 'sber.png' })]); }); });