Files
backend/src/integrations/ncr/ncr-ratings.client.spec.ts
T

99 lines
4.0 KiB
TypeScript

import { ServiceUnavailableException } from '@nestjs/common';
import { NcrRatingsClient } from './ncr-ratings.client';
const isin = 'RU000A10EW44';
const row = (value = 'AAA.ru', code = isin, href = '/ratings/issues/123/') =>
`<tr><td>Эмитент</td><td>Облигация</td><td>${value}</td><td><a href="${href}">${code}</a></td><td>REG</td><td>17.04.2026</td></tr>`;
const table = (rows: string) =>
`<table id="issues-table"><thead><tr><th>Лицо</th><th>Выпуск</th><th>Рейтинг</th><th>ISIN</th><th>Регномер</th><th>Дата</th></tr></thead><tbody>${rows}</tbody></table>`;
const response = (html: string) =>
({ ok: true, text: () => Promise.resolve(html) }) as Response;
const cacheClock = performance;
describe('public NCR issue ratings', () => {
beforeEach(() => {
jest.useFakeTimers().setSystemTime(new Date('2026-09-10T10:00:00Z'));
jest.spyOn(cacheClock, 'now').mockImplementation(() => Date.now());
});
afterEach(() => {
jest.restoreAllMocks();
jest.useRealTimers();
});
it('matches only exact ISIN and shares one public registry download', async () => {
const fetchMock = jest
.spyOn(global, 'fetch')
.mockResolvedValue(response(table(row())));
const client = new NcrRatingsClient();
const [rating, missing] = await Promise.all([
client.rating(isin),
client.rating('RU000A1065B1'),
]);
expect(rating).toEqual({
agency: 'NCR',
value: 'AAA.ru',
status: 'active',
date: '2026-04-17',
sourceUrl: 'https://ratings.ru/ratings/issues/123/',
observedAt: '2026-09-10T10:00:00.000Z',
});
expect(missing).toBeNull();
expect(await client.rating('issuer-name')).toBeNull();
expect(fetchMock).toHaveBeenCalledTimes(1);
await jest.advanceTimersByTimeAsync(6 * 60 * 60_000 + 1);
await client.rating(isin);
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it('preserves withdrawal and never displays an issuer rating as an issue rating', async () => {
jest
.spyOn(global, 'fetch')
.mockResolvedValue(response(table(row('рейтинг отозван'))));
expect(await new NcrRatingsClient().rating(isin)).toMatchObject({
status: 'withdrawn',
value: null,
});
});
it.each([
'<html>Service unavailable</html>',
table(row('AAA.ru', isin, 'https://untrusted.test/')),
table(row('unrecognized')),
table(row() + row('AA.ru')),
table(''),
])(
'reports changed or ambiguous registry as unavailable, not unrated',
async (html) => {
jest.spyOn(global, 'fetch').mockResolvedValue(response(html));
await expect(new NcrRatingsClient().rating(isin)).rejects.toBeInstanceOf(
ServiceUnavailableException,
);
},
);
it.each(['AA+.ru', 'B-.ru', 'BBB+.ru', 'CCC-.ru', 'CC.ru', 'D.ru'])(
'accepts the public rating grammar: %s', async (value) => {
jest.spyOn(global, 'fetch').mockResolvedValue(response(table(row(value))));
expect(await new NcrRatingsClient().rating(isin)).toMatchObject({ value });
},
);
it.each(['AAA+.ru', 'CC-.ru', 'BBBB.ru', 'BBB++.ru'])(
'rejects invalid rating grades: %s', async (value) => {
jest.spyOn(global, 'fetch').mockResolvedValue(response(table(row(value))));
await expect(new NcrRatingsClient().rating(isin)).rejects.toBeInstanceOf(ServiceUnavailableException);
},
);
it('bounds outage retries then recovers without retaining a false missing rating', async () => {
const fetchMock = jest
.spyOn(global, 'fetch')
.mockRejectedValueOnce(new Error('offline'))
.mockResolvedValue(response(table(row())));
const client = new NcrRatingsClient();
await expect(client.rating(isin)).rejects.toBeInstanceOf(
ServiceUnavailableException,
);
await expect(client.rating(isin)).rejects.toBeInstanceOf(
ServiceUnavailableException,
);
expect(fetchMock).toHaveBeenCalledTimes(1);
await jest.advanceTimersByTimeAsync(60_001);
expect(await client.rating(isin)).toMatchObject({ value: 'AAA.ru' });
expect(fetchMock).toHaveBeenCalledTimes(2);
});
});