277 lines
8.3 KiB
TypeScript
277 lines
8.3 KiB
TypeScript
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']);
|
|
},
|
|
);
|
|
});
|