import type { Server } from 'node:http'; import { status } from '@grpc/grpc-js'; 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 { StockCatalogService } from '../src/modules/investments/instruments/stocks/application/stock-catalog.service'; 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 { StockScreenerService } from '../src/modules/investments/instruments/stocks/application/stock-screener.service'; import { TbankHourlyScheduler } from '../src/modules/investments/tbank-sync/tbank-hourly.scheduler'; import { bootOnlineApp, expectSafeBody, failUpstream, onlineUserId, refusals, } from './online-upstream'; 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 screener = { page: jest.fn(), search: 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) .overrideProvider(StockScreenerService) .useValue(screener) .overrideProvider(TbankHourlyScheduler) .useValue({ tick: jest.fn() }) .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' }); screener.search.mockResolvedValue({ asOf: '2026-10-10T12:00:00.000Z', hasNext: false, items: [], page: 1, searchId: 'search-id' }); }); 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([ [() => request(app.getHttpServer()).post('/api/v1/stocks/screener/search'), 'post'], [() => request(app.getHttpServer()).get('/api/v1/stocks/screener/search/search-id?page=1'), 'get'], ])('requires authentication for screener route', async (send) => { await send().expect(401); expect(screener.search).not.toHaveBeenCalled(); expect(screener.page).not.toHaveBeenCalled(); }); it('validates screener input and returns raw snapshot pages', async () => { await request(app.getHttpServer()) .post('/api/v1/stocks/screener/search') .set('Authorization', `Bearer ${token}`) .send({ query: ' ', unknown: 'value' }) .expect(400); expect(screener.search).not.toHaveBeenCalled(); const response = await request(app.getHttpServer()) .post('/api/v1/stocks/screener/search') .set('Authorization', `Bearer ${token}`) .send({ query: '' }) .expect(200); expect(response.body).toEqual({ asOf: '2026-10-10T12:00:00.000Z', hasNext: false, items: [], page: 1, searchId: 'search-id' }); expect(screener.search).toHaveBeenCalledWith('stock-reader', {}); }); it.each([ '/api/v1/stocks/screener/search/search-id', '/api/v1/stocks/screener/search/search-id?page=0', '/api/v1/stocks/screener/search/search-id?page=one', ])('validates required screener page on %s', async (path) => { await request(app.getHttpServer()) .get(path) .set('Authorization', `Bearer ${token}`) .expect(400); expect(screener.page).not.toHaveBeenCalled(); }); it('reads a screener snapshot page with the verified identity', async () => { await request(app.getHttpServer()) .get('/api/v1/stocks/screener/search/search-id?page=2') .set('Authorization', `Bearer ${token}`) .expect(200); expect(screener.page).toHaveBeenCalledWith('stock-reader', 'search-id', 2); }); 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); }); 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, ); }, ); 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'); 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'); }); }); // Real services and TbankClient error mapping; only the gRPC transport is stubbed. describe('Stock online reads: upstream failure translation', () => { const rpc = jest.fn(); let ctx: Awaited>; const get = (path: string) => request(ctx.app.getHttpServer()).get(path); // [entrypoint, request, RPC that fails second]: a failure after the first call must translate too. const entrypoints: [string, () => request.Test, string][] = [ ['search', () => get('/api/v1/stocks?query=SBER'), 'getLastPrices'], ['detail', () => get(`/api/v1/stocks/${uid}`), 'shareBy'], ['ticker', () => get('/api/v1/stocks/by-ticker/SBER'), 'shareBy'], ['company', () => get(`/api/v1/stocks/${uid}/company`), 'getAssetBy'], ['screener', () => request(ctx.app.getHttpServer()).post('/api/v1/stocks/screener/search').send({}), 'shares'], ]; const cases = entrypoints.flatMap(([name, send, later]) => refusals.flatMap((code) => [undefined, later].map((at) => [name, code, at, send] as const)), ); beforeAll(async () => { ctx = await bootOnlineApp(rpc); }); afterAll(() => ctx.app.close()); it.each(cases)('%s: upstream auth refusal %s (failing RPC %s) becomes 400/INVALID_TBANK_TOKEN', async (_name, code, at, send) => { failUpstream(rpc, 'share', code, at); const response = await send().set('Authorization', ctx.bearer).expect(400); expectSafeBody(response.body, 400, 'INVALID_TBANK_TOKEN'); const saved = await ctx.prisma.tbankConnection.findUniqueOrThrow({ where: { userId: onlineUserId } }); expect(saved).toMatchObject({ invalidToken: false, revision: ctx.connection.revision }); }); it.each(entrypoints)('%s: temporary unavailability stays 503/TBANK_UNAVAILABLE', async (_name, send) => { failUpstream(rpc, 'share', status.UNAVAILABLE); const response = await send().set('Authorization', ctx.bearer).expect(503); expectSafeBody(response.body, 503, 'TBANK_UNAVAILABLE'); }); });