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.
132 lines
4.7 KiB
TypeScript
132 lines
4.7 KiB
TypeScript
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');
|
|
});
|
|
});
|