import { MoexClient } from './moex.client'; const emptyEvents = { coupons: { columns: ['coupondate', 'value', 'faceunit'], data: [] }, offers: { columns: ['offerdate'], data: [] }, description: { columns: ['name', 'value'], data: [] }, }; describe('MoexClient', () => { afterEach(() => jest.restoreAllMocks()); it('uses effective yield only for the exact ISIN and board and preserves its own price and date', async () => { jest.spyOn(global, 'fetch').mockResolvedValue({ ok: true, json: () => Promise.resolve({ securities: { columns: ['SECID', 'ISIN', 'BOARDID'], data: [['BOND', 'RU000TEST', 'TQOB']], }, marketdata_yields: { columns: [ 'SECID', 'BOARDID', 'EFFECTIVEYIELD', 'PRICE', 'YIELDDATE', 'YIELDDATETYPE', 'TRADEMOMENT', ], data: [ [ 'BOND', 'OTHER', 99, 20, '2028-12-01', 'OFFERDATE', '2026-09-10 18:49:00', ], [ 'BOND', 'TQOB', -0.5, 101, '2028-12-01', 'OFFERDATE', '2026-09-10 18:49:00', ], ], }, }), } as Response); const client = new MoexClient(); expect(await client.bondYield('BOND', 'RU000TEST', 'TQOB')).toEqual({ value: '-0.5', pricePercent: '101', date: '2028-12-01', dateType: 'OFFERDATE', tradeAt: '2026-09-10T15:49:00.000Z', }); expect(await client.bondYield('BOND', 'WRONG', 'TQOB')).toBeNull(); expect(await client.bondYield('BOND', 'RU000TEST', 'OTHER')).toBeNull(); }); it.each([null, 0])( 'does not report unknown yield as zero: %s', async (value) => { jest.spyOn(global, 'fetch').mockResolvedValue({ ok: true, json: () => Promise.resolve({ securities: { columns: ['SECID', 'ISIN', 'BOARDID'], data: [['BOND', 'ISIN', 'TQOB']], }, marketdata_yields: { columns: [ 'SECID', 'BOARDID', 'EFFECTIVEYIELD', 'PRICE', 'YIELDDATE', 'TRADEMOMENT', ], data: [ [ 'BOND', 'TQOB', value, 100, '2030-01-01', '2026-09-10 12:00:00', ], ], }, }), } as Response); const result = await new MoexClient().bondYield('BOND', 'ISIN', 'TQOB'); expect(result?.value ?? null).toBe(value === null ? null : '0'); }, ); it('accepts confirmed empty blocks', async () => { jest .spyOn(global, 'fetch') .mockImplementation(() => Promise.resolve(Response.json(emptyEvents))); await expect(new MoexClient().bondEvents('BOND')).resolves.toEqual([]); }); it('preserves not-found semantics independently of malformed HTTP 200', async () => { jest .spyOn(global, 'fetch') .mockImplementation(() => Promise.resolve(new Response(null, { status: 404 })), ); const client = new MoexClient(); await expect(client.bondEvents('MISSING')).resolves.toEqual([]); await expect( client.bondYield('MISSING', 'ISIN', 'TQOB'), ).resolves.toBeNull(); }); it.each([ ['missing block', { coupons: undefined }], ['missing columns', { coupons: { data: [] } }], ['invalid column', { coupons: { columns: [null], data: [] } }], ['invalid row', { coupons: { columns: ['coupondate'], data: [null] } }], [ 'short row', { coupons: { columns: ['coupondate', 'value'], data: [['2099-02-01']] } }, ], [ 'long row', { coupons: { columns: ['coupondate'], data: [['2099-02-01', 2]] } }, ], [ 'empty cursor', { 'coupons.cursor': { columns: ['INDEX', 'TOTAL', 'PAGESIZE'], data: [] }, }, ], [ 'missing cursor field', { 'coupons.cursor': { columns: ['INDEX', 'TOTAL'], data: [[0, 1]] } }, ], [ 'null cursor index', { 'coupons.cursor': { columns: ['INDEX', 'TOTAL', 'PAGESIZE'], data: [[null, 0, 1]], }, }, ], [ 'boolean cursor total', { 'coupons.cursor': { columns: ['INDEX', 'TOTAL', 'PAGESIZE'], data: [[0, false, 1]], }, }, ], [ 'empty cursor total', { 'coupons.cursor': { columns: ['INDEX', 'TOTAL', 'PAGESIZE'], data: [[0, '', 1]], }, }, ], [ 'short page', { 'coupons.cursor': { columns: ['INDEX', 'TOTAL', 'PAGESIZE'], data: [[0, 1, 1]], }, }, ], ])( 'rejects %s without returning a partial calendar', async (_name, invalid) => { jest .spyOn(global, 'fetch') .mockImplementation(() => Promise.resolve(Response.json({ ...emptyEvents, ...invalid })), ); await expect(new MoexClient().bondEvents('BOND')).rejects.toMatchObject({ response: { code: 'MOEX_UNAVAILABLE' }, }); }, ); it.each([0, -1])( 'rejects nonpositive page size %s before repeating a fetch', async (pageSize) => { const starts: string[] = []; jest.spyOn(global, 'fetch').mockImplementation((input) => { const url = new URL(input instanceof Request ? input.url : input); if (url.searchParams.get('iss.only')?.startsWith('coupons,')) { starts.push(url.searchParams.get('start') ?? ''); if (starts.length > 1) { return Promise.reject(new Error('Repeated page')); } } return Promise.resolve( Response.json({ ...emptyEvents, 'coupons.cursor': { columns: ['INDEX', 'TOTAL', 'PAGESIZE'], data: [[0, 1, pageSize]], }, }), ); }); await expect(new MoexClient().bondEvents('BOND')).rejects.toMatchObject({ response: { code: 'MOEX_UNAVAILABLE' }, }); expect(starts).toEqual(['0']); }, ); it.each([false, true])( 'reads all cursor pages and rejects a repeated index: %s', async (repeatIndex) => { const starts: string[] = []; jest.spyOn(global, 'fetch').mockImplementation((input) => { const url = new URL(input instanceof Request ? input.url : input); const start = Number(url.searchParams.get('start')); if (url.searchParams.get('iss.only')?.startsWith('coupons,')) { starts.push(String(start)); if (starts.length > 2) { return Promise.reject(new Error('Repeated page')); } } return Promise.resolve( Response.json({ ...emptyEvents, 'coupons': { columns: ['coupondate', 'value', 'faceunit'], data: [[`2099-02-0${start + 1}`, '2.000000001', 'SUR']], }, 'coupons.cursor': { columns: ['INDEX', 'TOTAL', 'PAGESIZE'], data: [[repeatIndex ? 0 : start, 2, 1]], }, }), ); }); const result = new MoexClient().bondEvents('BOND'); if (repeatIndex) { // eslint-disable-next-line jest/no-conditional-expect -- The parameterized fixture chooses the scenario; each branch asserts its outcome and the test also asserts common invariants. await expect(result).rejects.toMatchObject({ response: { code: 'MOEX_UNAVAILABLE' }, }); } else { // eslint-disable-next-line jest/no-conditional-expect -- The parameterized fixture chooses the scenario; each branch asserts its outcome and the test also asserts common invariants. await expect(result).resolves.toEqual([ { type: 'coupon', date: '2099-02-01', value: '2.000000001', currency: 'RUB', }, { type: 'coupon', date: '2099-02-02', value: '2.000000001', currency: 'RUB', }, ]); } expect(starts).toEqual(['0', '1']); }, ); });