Backend security / security (push) Successful in 8s
Backend verify / verify (push) Successful in 11m9s
Conventional commits / commits (pull_request) Successful in 28s
Backend security / security (pull_request) Successful in 7s
Backend verify / verify (pull_request) Successful in 10m38s
Resolve A01-A10 and C01-C07 from the backend audit. Preserve auth failures, forced candle reconciliation and forecast freshness. Validate MOEX responses and nested HTTP errors; remove unused legacy paths. Verified: 209 unit tests, 262 SQLite e2e tests on Linux, OpenAPI and durability. Record the remaining macOS native SQLite abort in the audit.
272 lines
7.9 KiB
TypeScript
272 lines
7.9 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)
|
|
await expect(result).rejects.toMatchObject({
|
|
response: { code: 'MOEX_UNAVAILABLE' },
|
|
});
|
|
else
|
|
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']);
|
|
},
|
|
);
|
|
});
|