import { ConflictException, INestApplication, NotFoundException, ServiceUnavailableException, } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { Test } from '@nestjs/testing'; import type { Server } from 'node:http'; import request from 'supertest'; import { AppModule } from '../src/app.module'; import { configureApp } from '../src/main'; import { StockIncomeService } from '../src/modules/investments/instruments/stocks/application/stock-income.service'; import { StockMarketService } from '../src/modules/investments/instruments/stocks/application/stock-market.service'; import { StockCatalogService } from '../src/modules/investments/instruments/stocks/application/stock-catalog.service'; const uid = 'e6123145-9665-43e0-8413-cd61b8aa9b13'; describe('Stock HTTP contract', () => { let app: INestApplication; const stocks = { getByTicker: jest.fn(), get: jest.fn(), search: jest.fn(), candles: jest.fn(), }; const token = new JwtService().sign( { sub: 'stock-reader', type: 'access' }, { secret: process.env.JWT_SECRET }, ); beforeAll(async () => { const module = await Test.createTestingModule({ imports: [AppModule] }) .overrideProvider(StockCatalogService) .useValue(stocks) .overrideProvider(StockMarketService) .useValue(stocks) .overrideProvider(StockIncomeService) .useValue(stocks) .compile(); app = module.createNestApplication(); configureApp(app); await app.init(); await app.listen(0, '127.0.0.1'); }); beforeEach(() => { jest.clearAllMocks(); stocks.get.mockResolvedValue({ data: { instrumentUid: uid, name: 'Test' }, meta: { availability: 'complete' }, }); stocks.search.mockResolvedValue([]); stocks.getByTicker.mockResolvedValue({ instrumentUid: uid, slug: 'SBERP' }); }); afterAll(() => app.close()); it('requires authentication before invoking the use case', async () => { await request(app.getHttpServer()).get(`/api/v1/stocks/${uid}`).expect(401); expect(stocks.get).not.toHaveBeenCalled(); }); it.each([ '/stocks/not-a-uid', '/stocks/by-ticker/%20', '/stocks/by-ticker/' + 'S'.repeat(81), '/stocks?query=x', '/stocks?query=', `/stocks/${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(stocks.get).not.toHaveBeenCalled(); expect(stocks.candles).not.toHaveBeenCalled(); }); it('uses verified identity and supports a direct instrument URL', async () => { await request(app.getHttpServer()) .get(`/api/v1/stocks/${uid}`) .set('Authorization', `Bearer ${token}`) .expect(200); expect(stocks.get).toHaveBeenCalledWith('stock-reader', uid, false); }); it.each(['SBERP', 'sberp', 'F', 'SBER@GS'])( 'resolves ticker %s through the authenticated endpoint', async (ticker) => { const path = `/api/v1/stocks/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: 'SBERP', }); expect(stocks.getByTicker).toHaveBeenCalledWith( 'stock-reader', ticker, false, ); }, ); it('returns an explicit conflict for an ambiguous ticker', async () => { stocks.getByTicker.mockRejectedValueOnce( new ConflictException({ code: 'STOCK_TICKER_AMBIGUOUS' }), ); const response = await request(app.getHttpServer()) .get('/api/v1/stocks/by-ticker/SAME') .set('Authorization', `Bearer ${token}`) .expect(409); expect(response.body).toMatchObject({ code: 'STOCK_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, }; stocks.search.mockResolvedValue({ data: [item], meta: { availability: 'complete' }, }); const response = await request(app.getHttpServer()) .get('/api/v1/stocks?query=SBER') .set('Authorization', `Bearer ${token}`) .expect(200); expect(stocks.search).toHaveBeenCalledWith('stock-reader', 'SBER', false); expect(response.body).toEqual({ data: [item], meta: { availability: 'complete' }, }); }); it('preserves safe not-found and upstream errors', async () => { stocks.get.mockRejectedValueOnce(new NotFoundException()); await request(app.getHttpServer()) .get(`/api/v1/stocks/${uid}`) .set('Authorization', `Bearer ${token}`) .expect(404); stocks.get.mockRejectedValueOnce( new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }), ); const response = await request(app.getHttpServer()) .get(`/api/v1/stocks/${uid}`) .set('Authorization', `Bearer ${token}`) .expect(503); expect(response.body).toMatchObject({ code: 'TBANK_UNAVAILABLE' }); expect(response.body).not.toHaveProperty('stack'); }); });