Files
backend/src/modules/investments/instruments/stocks/application/stock.spec.ts
T

376 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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',
});
});
});