From 974c83d67e130a8664aa37b98a89afee74b7e70d Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Mon, 15 Jun 2026 04:56:53 +0300 Subject: [PATCH] test: make backend service specs deterministic --- .../src/modules/bonds/bonds.service.spec.ts | 146 +++++++++++++-- .../modules/candles/candles.service.spec.ts | 109 +++++++++-- .../securities/screener.service.spec.ts | 2 - .../securities/securities.service.spec.ts | 172 ++++++++++++++++-- .../src/modules/shares/shares.service.spec.ts | 134 ++++++++++++-- 5 files changed, 483 insertions(+), 80 deletions(-) diff --git a/apps/backend/src/modules/bonds/bonds.service.spec.ts b/apps/backend/src/modules/bonds/bonds.service.spec.ts index 903de5c..532214e 100644 --- a/apps/backend/src/modules/bonds/bonds.service.spec.ts +++ b/apps/backend/src/modules/bonds/bonds.service.spec.ts @@ -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; + let cache: Pick; 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); }); - 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(); + }); }); diff --git a/apps/backend/src/modules/candles/candles.service.spec.ts b/apps/backend/src/modules/candles/candles.service.spec.ts index fd37b24..d97ce7e 100644 --- a/apps/backend/src/modules/candles/candles.service.spec.ts +++ b/apps/backend/src/modules/candles/candles.service.spec.ts @@ -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; + let cache: Pick; 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); }); - 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', + ); + }); }); diff --git a/apps/backend/src/modules/securities/screener.service.spec.ts b/apps/backend/src/modules/securities/screener.service.spec.ts index 5f9f017..dc627d5 100644 --- a/apps/backend/src/modules/securities/screener.service.spec.ts +++ b/apps/backend/src/modules/securities/screener.service.spec.ts @@ -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); - moexClient = module.get(MoexClientService); cache = module.get(CacheService); }); diff --git a/apps/backend/src/modules/securities/securities.service.spec.ts b/apps/backend/src/modules/securities/securities.service.spec.ts index d0370e3..be9434c 100644 --- a/apps/backend/src/modules/securities/securities.service.spec.ts +++ b/apps/backend/src/modules/securities/securities.service.spec.ts @@ -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; + let cache: Pick; 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); }); - 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(); + }); }); diff --git a/apps/backend/src/modules/shares/shares.service.spec.ts b/apps/backend/src/modules/shares/shares.service.spec.ts index 82ab025..5873412 100644 --- a/apps/backend/src/modules/shares/shares.service.spec.ts +++ b/apps/backend/src/modules/shares/shares.service.spec.ts @@ -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; + let cache: Pick; 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); }); - 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(); + }); });