148 lines
4.1 KiB
TypeScript
148 lines
4.1 KiB
TypeScript
import { NotFoundException } from '@nestjs/common';
|
|
import { Test, TestingModule } from '@nestjs/testing';
|
|
import { BondsService } from './bonds.service';
|
|
import { MoexClientService } from '../moex-client/moex-client.service';
|
|
import { CacheService } from '../cache/cache.service';
|
|
|
|
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({
|
|
providers: [
|
|
BondsService,
|
|
{ provide: MoexClientService, useValue: moexClient },
|
|
{ provide: CacheService, useValue: cache },
|
|
],
|
|
}).compile();
|
|
|
|
service = module.get<BondsService>(BondsService);
|
|
});
|
|
|
|
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('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();
|
|
});
|
|
});
|