refactor: organize backend by business responsibility
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
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('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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import { Injectable, ServiceUnavailableException } from '@nestjs/common';
|
||||
import { load } from 'cheerio';
|
||||
import { LRUCache } from 'lru-cache';
|
||||
|
||||
const registryUrl = 'https://ratings.ru/ratings/issues/';
|
||||
export type NcrRating = {
|
||||
agency: 'NCR';
|
||||
value: string | null;
|
||||
status: 'active' | 'withdrawn';
|
||||
date: string;
|
||||
sourceUrl: string;
|
||||
observedAt: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class NcrRatingsClient {
|
||||
// Public registry contains no user data. Share one download across all issues.
|
||||
private readonly cache = new LRUCache<
|
||||
string,
|
||||
Promise<Map<string, NcrRating>>
|
||||
>({ max: 1, ttl: 6 * 60 * 60_000 });
|
||||
|
||||
async rating(isin: string): Promise<NcrRating | null> {
|
||||
const key = isin.trim().toUpperCase();
|
||||
if (!/^[A-Z]{2}[A-Z0-9]{9}\d$/.test(key)) return null;
|
||||
let pending = this.cache.get('registry');
|
||||
if (!pending) {
|
||||
pending = this.registry().catch(() => {
|
||||
// Bound outage traffic too; never present a failed download as no rating.
|
||||
if (this.cache.get('registry') === pending)
|
||||
this.cache.set('registry', pending, { ttl: 60_000 });
|
||||
throw new ServiceUnavailableException({ code: 'NCR_UNAVAILABLE' });
|
||||
});
|
||||
this.cache.set('registry', pending);
|
||||
}
|
||||
return (await pending).get(key) ?? null;
|
||||
}
|
||||
|
||||
private async registry(): Promise<Map<string, NcrRating>> {
|
||||
const response = await fetch(registryUrl, {
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
if (!response.ok) throw new Error('NCR unavailable');
|
||||
const $ = load(await response.text());
|
||||
const table = $('#issues-table');
|
||||
const headings = table
|
||||
.find('thead th')
|
||||
.map((_, cell) => $(cell).text().trim())
|
||||
.get();
|
||||
if (
|
||||
table.length !== 1 ||
|
||||
headings.length !== 6 ||
|
||||
headings[2] !== 'Рейтинг' ||
|
||||
headings[3] !== 'ISIN' ||
|
||||
headings[5] !== 'Дата'
|
||||
)
|
||||
throw new Error('NCR registry changed');
|
||||
const observedAt = new Date().toISOString();
|
||||
const result = new Map<string, NcrRating>();
|
||||
table.find('tbody tr').each((_, row) => {
|
||||
const cells = $(row).find('td');
|
||||
if (cells.length !== 6) throw new Error('NCR row changed');
|
||||
const isin = cells.eq(3).text().trim().toUpperCase();
|
||||
// Unassigned issues cannot safely be matched to a held instrument.
|
||||
if (!/^[A-Z]{2}[A-Z0-9]{9}\d$/.test(isin)) return;
|
||||
const raw = cells.eq(2).text().replace(/\s+/g, ' ').trim();
|
||||
const withdrawn = raw.toLowerCase() === 'рейтинг отозван';
|
||||
if (
|
||||
!withdrawn &&
|
||||
!/^(AAA|AA[+-]?|A[+-]?|BBB[+-]?|BB[+-]?|B[+-]?|CCC[+-]?|CC|C|D)\.ru$/.test(
|
||||
raw,
|
||||
)
|
||||
)
|
||||
throw new Error('NCR rating changed');
|
||||
const dateParts = /^(\d{2})\.(\d{2})\.(\d{4})$/.exec(
|
||||
cells.eq(5).text().trim(),
|
||||
);
|
||||
const href = cells.eq(3).find('a').attr('href');
|
||||
if (!dateParts || !href || !/^\/ratings\/issues\/\d+\/$/.test(href))
|
||||
throw new Error('NCR identity missing');
|
||||
const date = `${dateParts[3]}-${dateParts[2]}-${dateParts[1]}`;
|
||||
if (
|
||||
!Number.isFinite(Date.parse(date)) ||
|
||||
new Date(date).toISOString().slice(0, 10) !== date
|
||||
)
|
||||
throw new Error('NCR date invalid');
|
||||
const rating: NcrRating = {
|
||||
agency: 'NCR',
|
||||
value: withdrawn ? null : raw,
|
||||
status: withdrawn ? 'withdrawn' : 'active',
|
||||
date,
|
||||
sourceUrl: `https://ratings.ru${href}`,
|
||||
observedAt,
|
||||
};
|
||||
const previous = result.get(isin);
|
||||
if (
|
||||
previous &&
|
||||
previous.date === date &&
|
||||
(previous.value !== rating.value || previous.status !== rating.status)
|
||||
)
|
||||
throw new Error('NCR rating ambiguous');
|
||||
if (!previous || previous.date < date) result.set(isin, rating);
|
||||
});
|
||||
if (!result.size) throw new Error('NCR registry empty');
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// Public cross-module surface. Nest providers are exported by the matching module.
|
||||
export { NcrRatingsClient } from './ncr-ratings.client';
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { NcrRatingsClient } from './ncr-ratings.client';
|
||||
|
||||
@Module({
|
||||
providers: [NcrRatingsClient],
|
||||
exports: [NcrRatingsClient],
|
||||
})
|
||||
export class NcrModule {}
|
||||
Reference in New Issue
Block a user