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/') => `ЭмитентОблигация${value}${code}REG17.04.2026`; const table = (rows: string) => `${rows}
ЛицоВыпускРейтингISINРегномерДата
`; 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([ 'Service unavailable', 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); }); });