Backend verify / commits (push) Skipped
Backend verify / commits (pull_request) Failing after 33s
Backend verify / security (pull_request) Skipped
Backend verify / verify (pull_request) Skipped
Backend verify / security (push) Successful in 11s
Backend verify / verify (push) Failing after 6m40s
248 lines
9.0 KiB
TypeScript
248 lines
9.0 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 { BondCatalogService } from '../src/modules/investments/instruments/bonds/application/bond-catalog.service';
|
|
import { BondIncomeService } from '../src/modules/investments/instruments/bonds/application/bond-income.service';
|
|
import { BondMarketService } from '../src/modules/investments/instruments/bonds/application/bond-market.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('Bond HTTP contract', () => {
|
|
let app: INestApplication<Server>;
|
|
const bonds = {
|
|
getByTicker: jest.fn(),
|
|
get: jest.fn(),
|
|
search: jest.fn(),
|
|
candles: jest.fn(),
|
|
metrics: jest.fn(),
|
|
};
|
|
const token = new JwtService().sign(
|
|
{ sub: 'bond-reader', type: 'access' },
|
|
{ secret: process.env.JWT_SECRET },
|
|
);
|
|
beforeAll(async () => {
|
|
const module = await Test.createTestingModule({ imports: [AppModule] })
|
|
.overrideProvider(BondCatalogService)
|
|
.useValue(bonds)
|
|
.overrideProvider(BondMarketService)
|
|
.useValue(bonds)
|
|
.overrideProvider(BondIncomeService)
|
|
.useValue(bonds)
|
|
.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();
|
|
bonds.get.mockResolvedValue({
|
|
data: { instrumentUid: uid, name: 'Test' },
|
|
meta: { availability: 'complete' },
|
|
});
|
|
bonds.search.mockResolvedValue([]);
|
|
bonds.getByTicker.mockResolvedValue({
|
|
instrumentUid: uid,
|
|
slug: 'SU26238RMFS4',
|
|
});
|
|
});
|
|
afterAll(() => app.close());
|
|
it('requires authentication before invoking the use case', async () => {
|
|
await request(app.getHttpServer()).get(`/api/v1/bonds/${uid}`).expect(401);
|
|
expect(bonds.get).not.toHaveBeenCalled();
|
|
});
|
|
it.each([
|
|
'/bonds/not-a-uid',
|
|
'/bonds/not-a-uid/metrics',
|
|
'/bonds/by-ticker/%20',
|
|
'/bonds/by-ticker/' + 'S'.repeat(81),
|
|
'/bonds?query=x',
|
|
'/bonds?query=',
|
|
`/bonds/${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(bonds.get).not.toHaveBeenCalled();
|
|
expect(bonds.candles).not.toHaveBeenCalled();
|
|
});
|
|
it('serves coupon, yield and agency rating through the authenticated metrics endpoint', async () => {
|
|
const metrics = {
|
|
coupon: { currency: 'RUB', value: '30' },
|
|
couponDate: '2026-10-10',
|
|
couponType: 'COUPON_TYPE_FIX',
|
|
couponPeriodDays: 90,
|
|
couponYieldPercent: '12.5',
|
|
couponYieldPriceAt: '2026-09-10T10:00:00Z',
|
|
effectiveYield: {
|
|
value: '15.6',
|
|
pricePercent: '53.4',
|
|
date: '2041-05-15',
|
|
dateType: 'MATDATE',
|
|
tradeAt: '2026-09-10T10:00:00Z',
|
|
},
|
|
creditRating: {
|
|
agency: 'NCR',
|
|
value: 'AAA.ru',
|
|
status: 'active',
|
|
date: '2026-04-17',
|
|
sourceUrl: 'https://ratings.ru/ratings/issues/123/',
|
|
observedAt: '2026-09-10T10:00:00Z',
|
|
},
|
|
unavailable: [],
|
|
observedAt: '2026-09-10T10:00:00Z',
|
|
};
|
|
bonds.metrics.mockResolvedValue(metrics);
|
|
await request(app.getHttpServer())
|
|
.get(`/api/v1/bonds/${uid}/metrics`)
|
|
.expect(401);
|
|
expect(bonds.metrics).not.toHaveBeenCalled();
|
|
const response = await request(app.getHttpServer())
|
|
.get(`/api/v1/bonds/${uid}/metrics`)
|
|
.set('Authorization', `Bearer ${token}`)
|
|
.expect(200);
|
|
expect(response.body).toEqual(metrics);
|
|
expect(bonds.metrics).toHaveBeenCalledWith('bond-reader', uid, false);
|
|
});
|
|
it('uses verified identity and supports a direct instrument URL', async () => {
|
|
await request(app.getHttpServer())
|
|
.get(`/api/v1/bonds/${uid}`)
|
|
.set('Authorization', `Bearer ${token}`)
|
|
.expect(200);
|
|
expect(bonds.get).toHaveBeenCalledWith('bond-reader', uid, false);
|
|
});
|
|
it.each(['SU26238RMFS4', 'su26238rmfs4', 'F', 'SU26238RMFS4@GS'])(
|
|
'resolves ticker %s through the authenticated endpoint',
|
|
async (ticker) => {
|
|
const path = `/api/v1/bonds/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: 'SU26238RMFS4',
|
|
});
|
|
expect(bonds.getByTicker).toHaveBeenCalledWith(
|
|
'bond-reader',
|
|
ticker,
|
|
false,
|
|
);
|
|
},
|
|
);
|
|
it('returns an explicit conflict for an ambiguous ticker', async () => {
|
|
bonds.getByTicker.mockRejectedValueOnce(
|
|
new ConflictException({ code: 'BOND_TICKER_AMBIGUOUS' }),
|
|
);
|
|
const response = await request(app.getHttpServer())
|
|
.get('/api/v1/bonds/by-ticker/SAME')
|
|
.set('Authorization', `Bearer ${token}`)
|
|
.expect(409);
|
|
expect(response.body).toMatchObject({ code: 'BOND_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,
|
|
};
|
|
bonds.search.mockResolvedValue({
|
|
data: [item],
|
|
meta: { availability: 'complete' },
|
|
});
|
|
const response = await request(app.getHttpServer())
|
|
.get('/api/v1/bonds?query=SU26238RMFS4')
|
|
.set('Authorization', `Bearer ${token}`)
|
|
.expect(200);
|
|
expect(bonds.search).toHaveBeenCalledWith(
|
|
'bond-reader',
|
|
'SU26238RMFS4',
|
|
false,
|
|
);
|
|
expect(response.body).toEqual({
|
|
data: [item],
|
|
meta: { availability: 'complete' },
|
|
});
|
|
});
|
|
it('preserves safe not-found and upstream errors', async () => {
|
|
bonds.get.mockRejectedValueOnce(new NotFoundException());
|
|
await request(app.getHttpServer())
|
|
.get(`/api/v1/bonds/${uid}`)
|
|
.set('Authorization', `Bearer ${token}`)
|
|
.expect(404);
|
|
bonds.get.mockRejectedValueOnce(
|
|
new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }),
|
|
);
|
|
const response = await request(app.getHttpServer())
|
|
.get(`/api/v1/bonds/${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.
|
|
// Bonds expose no company or screener route; their online entrypoints are search, detail and ticker.
|
|
describe('Bond 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/bonds?query=SBER'), 'getLastPrices'],
|
|
['detail', () => get(`/api/v1/bonds/${uid}`), 'bondBy'],
|
|
['ticker', () => get('/api/v1/bonds/by-ticker/SBER'), 'bondBy'],
|
|
];
|
|
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, 'bond', 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, 'bond', status.UNAVAILABLE);
|
|
const response = await send().set('Authorization', ctx.bearer).expect(503);
|
|
expectSafeBody(response.body, 503, 'TBANK_UNAVAILABLE');
|
|
});
|
|
});
|