test: make backend service specs deterministic

This commit is contained in:
Sergey Krylov 2026-06-15 04:56:53 +03:00
parent c994b6a2fb
commit 974c83d67e
5 changed files with 483 additions and 80 deletions

View File

@ -1,41 +1,147 @@
import { NotFoundException } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { ConfigModule } from '@nestjs/config';
import { BondsService } from './bonds.service';
import { MoexClientService } from '../moex-client/moex-client.service';
import { CacheService } from '../cache/cache.service';
import configuration from '../../config/configuration';
describe('BondsService', () => {
let service: BondsService;
let moexClient: Pick<MoexClientService, 'getBondData' | 'getBondMarketData'>;
let cache: Pick<CacheService, 'getOrFetch'>;
beforeEach(async () => {
moexClient = {
getBondData: vi.fn(),
getBondMarketData: vi.fn(),
};
cache = {
getOrFetch: vi.fn(async (_keyPrefix, _keyParts, fetchFn) => ({
data: await fetchFn(),
fromCache: false,
cachedAt: '2026-06-15T00:00:00.000Z',
})),
};
const module: TestingModule = await Test.createTestingModule({
imports: [ConfigModule.forRoot({ load: [configuration] })],
providers: [
BondsService,
MoexClientService,
{
provide: 'CACHE_MANAGER',
useValue: {
get: () => undefined,
set: () => Promise.resolve(),
del: () => Promise.resolve(),
},
},
CacheService,
{ provide: MoexClientService, useValue: moexClient },
{ provide: CacheService, useValue: cache },
],
}).compile();
service = module.get<BondsService>(BondsService);
});
it('should be defined', () => {
expect(service).toBeDefined();
it('returns normalized SU26238RMFS5 bond spec and market data without live MOEX dependency', async () => {
vi.mocked(moexClient.getBondData).mockResolvedValue({
secid: 'SU26238RMFS5',
boardid: 'TQCB',
shortName: 'ОФЗ 26238',
prevWaprice: 73.2,
yieldAtPrevWaprice: 14.1,
couponValue: 35.4,
nextCoupon: '2026-06-24',
accruedInt: 34.1,
prevPrice: 73,
lotSize: 1,
faceValue: 1000,
matDate: '2041-05-15',
couponPeriod: 182,
issueSize: 150000000,
isin: 'RU000A1038V6',
couponPercent: 7.1,
offerDate: null,
buybackDate: null,
bondType: 'ofz',
bondSubType: 'fixed',
listLevel: 1,
});
vi.mocked(moexClient.getBondMarketData).mockResolvedValue({
secid: 'SU26238RMFS5',
bid: 72.9,
offer: 73.1,
open: 72.8,
low: 72.5,
high: 73.4,
last: 73.05,
yield: 14.2,
waprice: 73,
yieldAtWaprice: 14.15,
duration: 2250,
volume: 10000,
value: 7305000,
numtrades: 450,
tradingStatus: 'T',
updateTime: '18:45:00',
});
const result = await service.getBond('SU26238RMFS5');
expect(cache.getOrFetch).toHaveBeenNthCalledWith(
1,
'bond',
['SU26238RMFS5'],
expect.any(Function),
'securityTtl',
);
expect(cache.getOrFetch).toHaveBeenNthCalledWith(
2,
'marketdata',
['bonds', 'SU26238RMFS5'],
expect.any(Function),
'marketDataTtl',
);
expect(moexClient.getBondData).toHaveBeenCalledWith('SU26238RMFS5');
expect(moexClient.getBondMarketData).toHaveBeenCalledWith('SU26238RMFS5');
expect(result).toMatchObject({
data: {
secid: 'SU26238RMFS5',
isin: 'RU000A1038V6',
name: 'ОФЗ 26238',
shortName: 'ОФЗ 26238',
latName: null,
listLevel: 1,
issueSize: 150000000,
faceValue: 1000,
faceUnit: 'RUB',
matDate: '2041-05-15',
couponValue: 35.4,
couponPercent: 7.1,
couponPeriod: 182,
nextCoupon: '2026-06-24',
accruedInt: 34.1,
bondType: 'ofz',
bondSubType: 'fixed',
offerDate: null,
buybackDate: null,
marketData: {
price: 73.05,
yieldToMaturity: 14.2,
duration: 2250,
accruedInt: 34.1,
couponValue: 35.4,
couponPercent: 7.1,
nextCouponDate: '2026-06-24',
open: 72.8,
high: 73.4,
low: 72.5,
volume: 10000,
},
},
meta: {
fromCache: false,
cachedAt: '2026-06-15T00:00:00.000Z',
},
});
expect(result.data.marketData.updatedAt).toMatch(/T18:45:00$/);
});
it('should return OFZ bond data for SU26207RMFS9', async () => {
const result = await service.getBond('SU26207RMFS9');
expect(result.data.secid).toBe('SU26207RMFS9');
expect(result.data.marketData).toBeDefined();
}, 15000);
it('throws NotFoundException when bond data is missing', async () => {
vi.mocked(moexClient.getBondData).mockResolvedValue(null);
await expect(service.getBond('UNKNOWN')).rejects.toBeInstanceOf(NotFoundException);
expect(cache.getOrFetch).toHaveBeenCalledTimes(1);
expect(moexClient.getBondMarketData).not.toHaveBeenCalled();
});
});

View File

@ -1,40 +1,51 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ConfigModule } from '@nestjs/config';
import { CandlesService } from './candles.service';
import { MoexClientService } from '../moex-client/moex-client.service';
import { CacheService } from '../cache/cache.service';
import configuration from '../../config/configuration';
import { CandleInterval } from './dto/candles-query.dto';
describe('CandlesService', () => {
let service: CandlesService;
let moexClient: Pick<MoexClientService, 'getCandles'>;
let cache: Pick<CacheService, 'getOrFetch'>;
beforeEach(async () => {
moexClient = {
getCandles: vi.fn(),
};
cache = {
getOrFetch: vi.fn(async (_keyPrefix, _keyParts, fetchFn) => ({
data: await fetchFn(),
fromCache: false,
cachedAt: '2026-06-15T00:00:00.000Z',
})),
};
const module: TestingModule = await Test.createTestingModule({
imports: [ConfigModule.forRoot({ load: [configuration] })],
providers: [
CandlesService,
MoexClientService,
{
provide: 'CACHE_MANAGER',
useValue: {
get: () => undefined,
set: () => Promise.resolve(),
del: () => Promise.resolve(),
},
},
CacheService,
{ provide: MoexClientService, useValue: moexClient },
{ provide: CacheService, useValue: cache },
],
}).compile();
service = module.get<CandlesService>(CandlesService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
it('uses MOEX interval 24 for daily share candles and maps output envelope', async () => {
vi.mocked(moexClient.getCandles).mockResolvedValue([
{
open: 320,
high: 325,
low: 318,
close: 323,
volume: 1500000,
value: 480000000,
begin: '2026-05-01 00:00:00',
end: '2026-05-01 23:59:59',
},
]);
it('should return daily candles for SBER', async () => {
const result = await service.getCandles(
'shares',
'SBER',
@ -42,7 +53,65 @@ describe('CandlesService', () => {
'2026-05-01',
'2026-06-01',
);
expect(result.data.length).toBeGreaterThan(0);
expect(result.data[0].open).toBeDefined();
}, 15000);
expect(cache.getOrFetch).toHaveBeenCalledWith(
'candles',
['shares', 'SBER', '24', '2026-05-01', '2026-06-01'],
expect.any(Function),
'candlesTtl',
);
expect(moexClient.getCandles).toHaveBeenCalledWith(
'stock',
'shares',
'SBER',
24,
'2026-05-01',
'2026-06-01',
);
expect(result).toEqual({
data: [
{
open: 320,
high: 325,
low: 318,
close: 323,
volume: 1500000,
value: 480000000,
begin: '2026-05-01 00:00:00',
end: '2026-05-01 23:59:59',
},
],
meta: {
fromCache: false,
cachedAt: '2026-06-15T00:00:00.000Z',
},
});
});
it('uses MOEX interval 60 for hourly bond candles without live MOEX dependency', async () => {
vi.mocked(moexClient.getCandles).mockResolvedValue([]);
await service.getCandles(
'bonds',
'SU26238RMFS5',
CandleInterval.HOUR,
'2026-05-01',
'2026-06-01',
);
expect(cache.getOrFetch).toHaveBeenCalledWith(
'candles',
['bonds', 'SU26238RMFS5', '60', '2026-05-01', '2026-06-01'],
expect.any(Function),
'candlesTtl',
);
expect(moexClient.getCandles).toHaveBeenCalledWith(
'stock',
'bonds',
'SU26238RMFS5',
60,
'2026-05-01',
'2026-06-01',
);
});
});

View File

@ -6,7 +6,6 @@ import { ScreenerType } from './dto/screener-query.dto';
describe('ScreenerService', () => {
let service: ScreenerService;
let moexClient: MoexClientService;
let cache: CacheService;
beforeEach(async () => {
@ -30,7 +29,6 @@ describe('ScreenerService', () => {
}).compile();
service = module.get<ScreenerService>(ScreenerService);
moexClient = module.get<MoexClientService>(MoexClientService);
cache = module.get<CacheService>(CacheService);
});

View File

@ -1,38 +1,174 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ConfigModule } from '@nestjs/config';
import { SecuritiesService } from './securities.service';
import { MoexClientService } from '../moex-client/moex-client.service';
import { CacheService } from '../cache/cache.service';
import configuration from '../../config/configuration';
import { SecurityType } from './dto/search-query.dto';
describe('SecuritiesService', () => {
let service: SecuritiesService;
let moexClient: Pick<MoexClientService, 'searchSecurities'>;
let cache: Pick<CacheService, 'getOrFetch'>;
beforeEach(async () => {
moexClient = {
searchSecurities: vi.fn(),
};
cache = {
getOrFetch: vi.fn(async (_keyPrefix, _keyParts, fetchFn) => ({
data: await fetchFn(),
fromCache: false,
cachedAt: '2026-06-15T00:00:00.000Z',
})),
};
const module: TestingModule = await Test.createTestingModule({
imports: [ConfigModule.forRoot({ load: [configuration] })],
providers: [
SecuritiesService,
MoexClientService,
{
provide: 'CACHE_MANAGER',
useValue: {
get: () => undefined,
set: () => Promise.resolve(),
del: () => Promise.resolve(),
},
},
CacheService,
{ provide: MoexClientService, useValue: moexClient },
{ provide: CacheService, useValue: cache },
],
}).compile();
service = module.get<SecuritiesService>(SecuritiesService);
});
it('should return search results for SBER', async () => {
const results = await service.search('SBER', SecurityType.ALL, 5);
expect(results.length).toBeGreaterThan(0);
expect(results[0].secid).toBeDefined();
}, 15000);
it('returns supported securities only and normalizes SUR currency to RUB', async () => {
vi.mocked(moexClient.searchSecurities).mockResolvedValue([
{
secid: 'SBER',
isin: 'RU0009029540',
name: 'Сбербанк России ПАО ао',
shortName: 'Сбербанк',
latName: 'Sberbank',
listLevel: 1,
issueSize: 21586948000,
faceValue: 3,
faceUnit: 'SUR',
issueDate: '2007-07-20',
typeName: 'Акция обыкновенная',
group: 'stock_shares',
type: 'common_share',
isQualifiedInvestors: false,
morningSession: true,
eveningSession: true,
},
{
secid: 'SU26238RMFS5',
isin: 'RU000A1038V6',
name: 'ОФЗ 26238',
shortName: 'ОФЗ 26238',
latName: null,
listLevel: 1,
issueSize: 100000000,
faceValue: 1000,
faceUnit: 'RUB',
issueDate: '2021-06-23',
typeName: 'Государственная облигация',
group: 'stock_bonds',
type: 'ofz_bond',
isQualifiedInvestors: false,
morningSession: false,
eveningSession: false,
},
{
secid: 'SiM6',
isin: '',
name: 'USD/RUB Futures',
shortName: 'SiM6',
latName: null,
listLevel: 0,
issueSize: 0,
faceValue: 0,
faceUnit: '',
issueDate: '',
typeName: 'Фьючерс',
group: 'futures',
type: 'futures',
isQualifiedInvestors: false,
morningSession: false,
eveningSession: true,
},
]);
const results = await service.search('SbEr', SecurityType.ALL, 10);
expect(results).toEqual([
{
secid: 'SBER',
isin: 'RU0009029540',
shortName: 'Сбербанк',
type: 'share',
listLevel: 1,
currency: 'RUB',
price: null,
},
{
secid: 'SU26238RMFS5',
isin: 'RU000A1038V6',
shortName: 'ОФЗ 26238',
type: 'bond',
listLevel: 1,
currency: 'RUB',
price: null,
},
]);
expect(cache.getOrFetch).toHaveBeenCalledWith(
'search',
['sber'],
expect.any(Function),
'searchTtl',
);
expect(moexClient.searchSecurities).toHaveBeenCalledWith('SbEr');
});
it('filters by type and applies limit without live MOEX dependency', async () => {
vi.mocked(cache.getOrFetch).mockResolvedValue({
data: [
{
secid: 'SBER',
isin: 'RU0009029540',
shortName: 'Сбербанк',
type: 'share',
listLevel: 1,
currency: 'RUB',
price: null,
},
{
secid: 'GAZP',
isin: 'RU0007661625',
shortName: 'Газпром',
type: 'share',
listLevel: 1,
currency: 'RUB',
price: null,
},
{
secid: 'SU26238RMFS5',
isin: 'RU000A1038V6',
shortName: 'ОФЗ 26238',
type: 'bond',
listLevel: 1,
currency: 'RUB',
price: null,
},
],
fromCache: true,
cachedAt: null,
});
const results = await service.search('ru', SecurityType.SHARE, 1);
expect(results).toEqual([
{
secid: 'SBER',
isin: 'RU0009029540',
shortName: 'Сбербанк',
type: 'share',
listLevel: 1,
currency: 'RUB',
price: null,
},
]);
expect(moexClient.searchSecurities).not.toHaveBeenCalled();
});
});

View File

@ -1,41 +1,135 @@
import { NotFoundException } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { ConfigModule } from '@nestjs/config';
import { SharesService } from './shares.service';
import { MoexClientService } from '../moex-client/moex-client.service';
import { CacheService } from '../cache/cache.service';
import configuration from '../../config/configuration';
describe('SharesService', () => {
let service: SharesService;
let moexClient: Pick<MoexClientService, 'getSecurityDescription' | 'getShareMarketData'>;
let cache: Pick<CacheService, 'getOrFetch'>;
beforeEach(async () => {
moexClient = {
getSecurityDescription: vi.fn(),
getShareMarketData: vi.fn(),
};
cache = {
getOrFetch: vi.fn(async (_keyPrefix, _keyParts, fetchFn) => ({
data: await fetchFn(),
fromCache: false,
cachedAt: '2026-06-15T00:00:00.000Z',
})),
};
const module: TestingModule = await Test.createTestingModule({
imports: [ConfigModule.forRoot({ load: [configuration] })],
providers: [
SharesService,
MoexClientService,
{
provide: 'CACHE_MANAGER',
useValue: {
get: () => undefined,
set: () => Promise.resolve(),
del: () => Promise.resolve(),
},
},
CacheService,
{ provide: MoexClientService, useValue: moexClient },
{ provide: CacheService, useValue: cache },
],
}).compile();
service = module.get<SharesService>(SharesService);
});
it('should be defined', () => {
expect(service).toBeDefined();
it('returns normalized SBER share spec and market data without live MOEX dependency', async () => {
vi.mocked(moexClient.getSecurityDescription).mockResolvedValue({
secid: 'SBER',
isin: 'RU0009029540',
name: 'Сбербанк России ПАО ао',
shortName: 'Сбербанк',
latName: 'Sberbank',
listLevel: 1,
issueSize: 21586948000,
faceValue: 3,
faceUnit: 'SUR',
issueDate: '2007-07-20',
typeName: 'Акция обыкновенная',
group: 'stock_shares',
type: 'common_share',
isQualifiedInvestors: false,
morningSession: true,
eveningSession: true,
});
vi.mocked(moexClient.getShareMarketData).mockResolvedValue({
secid: 'SBER',
boardid: 'TQBR',
shortName: 'Сбербанк',
bid: 320,
offer: 321,
open: 318,
low: 317,
high: 325,
last: 323,
lastChange: 4,
lastChangePrcnt: 1.25,
volume: 1500000,
value: 480000000,
waprice: 321,
numtrades: 4200,
issueCapitalization: 6900000000000,
tradingStatus: 'T',
updateTime: '18:45:00',
});
const result = await service.getShare('SBER');
expect(moexClient.getSecurityDescription).toHaveBeenCalledWith('SBER');
expect(cache.getOrFetch).toHaveBeenCalledWith(
'marketdata',
['shares', 'SBER'],
expect.any(Function),
'marketDataTtl',
);
expect(moexClient.getShareMarketData).toHaveBeenCalledWith('SBER');
expect(result).toMatchObject({
secid: 'SBER',
isin: 'RU0009029540',
name: 'Сбербанк России ПАО ао',
shortName: 'Сбербанк',
latName: 'Sberbank',
listLevel: 1,
issueSize: 21586948000,
faceValue: 3,
faceUnit: 'RUB',
type: 'common_share',
marketData: {
price: 323,
change: 4,
changePercent: 1.25,
open: 318,
high: 325,
low: 317,
volume: 1500000,
value: 480000000,
issueCapitalization: 6900000000000,
},
});
expect(result.marketData.updatedAt).toMatch(/T18:45:00$/);
});
it('should return SBER share data', async () => {
const share = await service.getShare('SBER');
expect(share.secid).toBe('SBER');
expect(share.marketData).toBeDefined();
}, 15000);
it('throws NotFoundException for non-share security', async () => {
vi.mocked(moexClient.getSecurityDescription).mockResolvedValue({
secid: 'SU26238RMFS5',
isin: 'RU000A1038V6',
name: 'ОФЗ 26238',
shortName: 'ОФЗ 26238',
latName: null,
listLevel: 1,
issueSize: 100000000,
faceValue: 1000,
faceUnit: 'RUB',
issueDate: '2021-06-23',
typeName: 'Государственная облигация',
group: 'stock_bonds',
type: 'ofz_bond',
isQualifiedInvestors: false,
morningSession: false,
eveningSession: false,
});
await expect(service.getShare('SU26238RMFS5')).rejects.toBeInstanceOf(NotFoundException);
expect(cache.getOrFetch).not.toHaveBeenCalled();
});
});