Files
backend/test/bonds.e2e-spec.ts
T

175 lines
6.1 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 { BondService } from '../src/tbank/bond.service';
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(BondService)
.useValue(bonds)
.compile();
app = module.createNestApplication();
configureApp(app);
await app.init();
});
beforeEach(() => {
jest.clearAllMocks();
bonds.get.mockResolvedValue({ instrumentUid: uid, name: 'Test' });
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);
});
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);
});
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);
},
);
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([item]);
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');
expect(response.body).toEqual([item]);
});
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');
});
});