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; 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({ providers: [ BondsService, { provide: MoexClientService, useValue: moexClient }, { provide: CacheService, useValue: cache }, ], }).compile(); service = module.get(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(); }); });