refactor: organize backend by business responsibility

This commit is contained in:
2026-09-13 13:52:57 +03:00
parent 7fde3ece6e
commit 8ba005346d
145 changed files with 3027 additions and 2346 deletions
@@ -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);
});
});
+107
View File
@@ -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;
}
}
+2
View File
@@ -0,0 +1,2 @@
// Public cross-module surface. Nest providers are exported by the matching module.
export { NcrRatingsClient } from './ncr-ratings.client';
+8
View File
@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { NcrRatingsClient } from './ncr-ratings.client';
@Module({
providers: [NcrRatingsClient],
exports: [NcrRatingsClient],
})
export class NcrModule {}