Backend verify / commits (push) Skipped
Backend verify / commits (pull_request) Failing after 33s
Backend verify / security (pull_request) Skipped
Backend verify / verify (pull_request) Skipped
Backend verify / security (push) Successful in 11s
Backend verify / verify (push) Failing after 6m40s
109 lines
4.5 KiB
TypeScript
109 lines
4.5 KiB
TypeScript
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 = { online: jest.fn(), local: jest.fn() };
|
|
const client = { getAllShares: jest.fn() };
|
|
const logger = { event: jest.fn() };
|
|
let service: StockScreenerService;
|
|
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
access.online.mockImplementation((_userId: string, run: (connection: { revision: string; token: string }) => Promise<unknown>) =>
|
|
run({ 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' })]);
|
|
});
|
|
});
|