moex-vibe/apps/backend/src/modules/bonds/bonds.service.spec.ts

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();
});
});