import type { Server } from 'node:http'; import { ConflictException, INestApplication, NotFoundException, ServiceUnavailableException, } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { Test } from '@nestjs/testing'; import request from 'supertest'; import { AppModule } from '../src/app.module'; import { configureApp } from '../src/main'; import { BondCatalogService } from '../src/modules/investments/instruments/bonds/application/bond-catalog.service'; import { BondIncomeService } from '../src/modules/investments/instruments/bonds/application/bond-income.service'; import { BondMarketService } from '../src/modules/investments/instruments/bonds/application/bond-market.service'; const uid = 'e6123145-9665-43e0-8413-cd61b8aa9b13'; describe('Bond HTTP contract', () => { let app: INestApplication; const bonds = { getByTicker: jest.fn(), get: jest.fn(), search: jest.fn(), candles: jest.fn(), metrics: jest.fn(), }; const token = new JwtService().sign( { sub: 'bond-reader', type: 'access' }, { secret: process.env.JWT_SECRET }, ); beforeAll(async () => { const module = await Test.createTestingModule({ imports: [AppModule] }) .overrideProvider(BondCatalogService) .useValue(bonds) .overrideProvider(BondMarketService) .useValue(bonds) .overrideProvider(BondIncomeService) .useValue(bonds) .compile(); app = module.createNestApplication(); configureApp(app); await app.init(); await app.listen(0, '127.0.0.1'); }); beforeEach(() => { jest.clearAllMocks(); bonds.get.mockResolvedValue({ data: { instrumentUid: uid, name: 'Test' }, meta: { availability: 'complete' }, }); bonds.search.mockResolvedValue([]); bonds.getByTicker.mockResolvedValue({ instrumentUid: uid, slug: 'SU26238RMFS4', }); }); afterAll(() => app.close()); it('requires authentication before invoking the use case', async () => { await request(app.getHttpServer()).get(`/api/v1/bonds/${uid}`).expect(401); expect(bonds.get).not.toHaveBeenCalled(); }); it.each([ '/bonds/not-a-uid', '/bonds/not-a-uid/metrics', '/bonds/by-ticker/%20', '/bonds/by-ticker/' + 'S'.repeat(81), '/bonds?query=x', '/bonds?query=', `/bonds/${uid}/candles?period=century`, '/brokerage-accounts/all/operations?instrumentUid=not-a-uid', ])('validates %s in the configured pipeline', async (path) => { await request(app.getHttpServer()) .get(`/api/v1${path}`) .set('Authorization', `Bearer ${token}`) .expect(400); expect(bonds.get).not.toHaveBeenCalled(); expect(bonds.candles).not.toHaveBeenCalled(); }); it('serves coupon, yield and agency rating through the authenticated metrics endpoint', async () => { const metrics = { coupon: { currency: 'RUB', value: '30' }, couponDate: '2026-10-10', couponType: 'COUPON_TYPE_FIX', couponPeriodDays: 90, couponYieldPercent: '12.5', couponYieldPriceAt: '2026-09-10T10:00:00Z', effectiveYield: { value: '15.6', pricePercent: '53.4', date: '2041-05-15', dateType: 'MATDATE', tradeAt: '2026-09-10T10:00:00Z', }, creditRating: { agency: 'NCR', value: 'AAA.ru', status: 'active', date: '2026-04-17', sourceUrl: 'https://ratings.ru/ratings/issues/123/', observedAt: '2026-09-10T10:00:00Z', }, unavailable: [], observedAt: '2026-09-10T10:00:00Z', }; bonds.metrics.mockResolvedValue(metrics); await request(app.getHttpServer()) .get(`/api/v1/bonds/${uid}/metrics`) .expect(401); expect(bonds.metrics).not.toHaveBeenCalled(); const response = await request(app.getHttpServer()) .get(`/api/v1/bonds/${uid}/metrics`) .set('Authorization', `Bearer ${token}`) .expect(200); expect(response.body).toEqual(metrics); expect(bonds.metrics).toHaveBeenCalledWith('bond-reader', uid, false); }); it('uses verified identity and supports a direct instrument URL', async () => { await request(app.getHttpServer()) .get(`/api/v1/bonds/${uid}`) .set('Authorization', `Bearer ${token}`) .expect(200); expect(bonds.get).toHaveBeenCalledWith('bond-reader', uid, false); }); it.each(['SU26238RMFS4', 'su26238rmfs4', 'F', 'SU26238RMFS4@GS'])( 'resolves ticker %s through the authenticated endpoint', async (ticker) => { const path = `/api/v1/bonds/by-ticker/${encodeURIComponent(ticker)}`; await request(app.getHttpServer()).get(path).expect(401); const response = await request(app.getHttpServer()) .get(path) .set('Authorization', `Bearer ${token}`) .expect(200); expect(response.body).toMatchObject({ instrumentUid: uid, slug: 'SU26238RMFS4', }); expect(bonds.getByTicker).toHaveBeenCalledWith( 'bond-reader', ticker, false, ); }, ); it('returns an explicit conflict for an ambiguous ticker', async () => { bonds.getByTicker.mockRejectedValueOnce( new ConflictException({ code: 'BOND_TICKER_AMBIGUOUS' }), ); const response = await request(app.getHttpServer()) .get('/api/v1/bonds/by-ticker/SAME') .set('Authorization', `Bearer ${token}`) .expect(409); expect(response.body).toMatchObject({ code: 'BOND_TICKER_AMBIGUOUS' }); }); it('returns listing identity and nullable search prices through the search endpoint', async () => { const item = { instrumentUid: uid, name: 'Sber', classCode: 'TQBR', isin: 'ISIN', apiTradeAvailable: true, price: { currency: 'RUB', value: '306.12' }, priceAt: null, }; bonds.search.mockResolvedValue({ data: [item], meta: { availability: 'complete' }, }); const response = await request(app.getHttpServer()) .get('/api/v1/bonds?query=SU26238RMFS4') .set('Authorization', `Bearer ${token}`) .expect(200); expect(bonds.search).toHaveBeenCalledWith( 'bond-reader', 'SU26238RMFS4', false, ); expect(response.body).toEqual({ data: [item], meta: { availability: 'complete' }, }); }); it('preserves safe not-found and upstream errors', async () => { bonds.get.mockRejectedValueOnce(new NotFoundException()); await request(app.getHttpServer()) .get(`/api/v1/bonds/${uid}`) .set('Authorization', `Bearer ${token}`) .expect(404); bonds.get.mockRejectedValueOnce( new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }), ); const response = await request(app.getHttpServer()) .get(`/api/v1/bonds/${uid}`) .set('Authorization', `Bearer ${token}`) .expect(503); expect(response.body).toMatchObject({ code: 'TBANK_UNAVAILABLE' }); expect(response.body).not.toHaveProperty('stack'); }); });