feat: add stock details and ticker resolution

Expose stock search, market data, company information, dividends and portfolio history with exact instrument identity and verified ticker aliases. Include stock and bond position counts in account summaries.
This commit is contained in:
2026-09-10 21:23:14 +03:00
parent 05747dc375
commit f24fd98fbc
22 changed files with 2584 additions and 13 deletions
+131
View File
@@ -0,0 +1,131 @@
import {
INestApplication,
ConflictException,
NotFoundException,
ServiceUnavailableException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { Test } from '@nestjs/testing';
import request from 'supertest';
import type { Server } from 'node:http';
import { AppModule } from '../src/app.module';
import { configureApp } from '../src/main';
import { StockService } from '../src/tbank/stock.service';
const uid = 'e6123145-9665-43e0-8413-cd61b8aa9b13';
describe('Stock HTTP contract', () => {
let app: INestApplication<Server>;
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(StockService)
.useValue(stocks)
.compile();
app = module.createNestApplication();
configureApp(app);
await app.init();
});
beforeEach(() => {
jest.clearAllMocks();
stocks.get.mockResolvedValue({ instrumentUid: uid, name: 'Test' });
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);
});
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([item]);
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([item]);
});
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');
});
});