Files
backend/test/stocks.e2e-spec.ts
T
ksv741 d1c5f575cf
Backend verify / commits (pull_request) Successful in 1m25s
Backend verify / security (pull_request) Successful in 1m8s
Backend verify / verify (pull_request) Successful in 6m20s
refactor: retire legacy tbank-sync status and sync=observe (WealthLedger/backend#196)
2026-11-05 22:22:48 +03:00

248 lines
9.9 KiB
TypeScript

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<Server>;
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<ReturnType<typeof bootOnlineApp>>;
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');
});
});