feat: add stock screener
This commit is contained in:
+106
@@ -0,0 +1,106 @@
|
||||
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> = {}): 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' })]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user