MVP реализации MoexVibe — NestJS бэкенд + React фронтенд + CI/CD #1
@ -25,19 +25,16 @@ describe('MoexClientService', () => {
|
||||
describe('searchSecurities', () => {
|
||||
it('should return results for SBER query', async () => {
|
||||
const results = await service.searchSecurities('SBER');
|
||||
expect(Array.isArray(results)).toBe(true);
|
||||
if (results.length > 0) {
|
||||
expect(results[0].secid).toBeDefined();
|
||||
}
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
expect(results[0].secid).toBeDefined();
|
||||
}, 15000);
|
||||
});
|
||||
|
||||
describe('getShareMarketData', () => {
|
||||
it('should return market data for SBER', async () => {
|
||||
const data = await service.getShareMarketData('SBER');
|
||||
if (data) {
|
||||
expect(data.secid).toBe('SBER');
|
||||
}
|
||||
expect(data).toBeDefined();
|
||||
expect(data!.secid).toBe('SBER');
|
||||
}, 15000);
|
||||
});
|
||||
});
|
||||
|
||||
@ -58,7 +58,8 @@ export class MoexClientService {
|
||||
|
||||
return this.queue.add(async () => {
|
||||
try {
|
||||
const response = await this.client.get(path, {
|
||||
const jsonPath = path + '.json';
|
||||
const response = await this.client.get(jsonPath, {
|
||||
params: { ...params, 'iss.meta': 'off' },
|
||||
});
|
||||
this.circuitErrorCount = 0;
|
||||
|
||||
25
apps/backend/src/modules/securities/dto/search-query.dto.ts
Normal file
25
apps/backend/src/modules/securities/dto/search-query.dto.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsString, IsOptional, IsEnum, MinLength, MaxLength } from 'class-validator';
|
||||
|
||||
export enum SecurityType {
|
||||
ALL = 'all',
|
||||
SHARE = 'share',
|
||||
BOND = 'bond',
|
||||
}
|
||||
|
||||
export class SearchQueryDto {
|
||||
@ApiProperty({ description: 'Поисковый запрос (тикер, название, ISIN)' })
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(100)
|
||||
q!: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: SecurityType, default: SecurityType.ALL })
|
||||
@IsOptional()
|
||||
@IsEnum(SecurityType)
|
||||
type?: SecurityType = SecurityType.ALL;
|
||||
|
||||
@ApiPropertyOptional({ default: 20 })
|
||||
@IsOptional()
|
||||
limit?: number = 20;
|
||||
}
|
||||
@ -0,0 +1,39 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { SecuritiesController } from './securities.controller';
|
||||
import { SecuritiesService } from './securities.service';
|
||||
import { SecurityType } from './dto/search-query.dto';
|
||||
|
||||
describe('SecuritiesController', () => {
|
||||
let controller: SecuritiesController;
|
||||
let service: SecuritiesService;
|
||||
|
||||
const mockResults = [
|
||||
{ secid: 'SBER', isin: 'RU0009029540', shortName: 'Сбербанк', type: 'share', listLevel: 1, currency: 'RUB', price: null },
|
||||
];
|
||||
|
||||
const mockService = {
|
||||
search: vi.fn().mockResolvedValue(mockResults),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [SecuritiesController],
|
||||
providers: [
|
||||
{ provide: SecuritiesService, useValue: mockService },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
controller = module.get<SecuritiesController>(SecuritiesController);
|
||||
service = module.get<SecuritiesService>(SecuritiesService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(controller).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return search results', async () => {
|
||||
const result = await controller.search({ q: 'SBER', type: SecurityType.ALL, limit: 5 });
|
||||
expect(result.data).toEqual(mockResults);
|
||||
expect(service.search).toHaveBeenCalledWith('SBER', SecurityType.ALL, 5);
|
||||
});
|
||||
});
|
||||
21
apps/backend/src/modules/securities/securities.controller.ts
Normal file
21
apps/backend/src/modules/securities/securities.controller.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { Controller, Get, Query, ValidationPipe } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiQuery } from '@nestjs/swagger';
|
||||
import { SecuritiesService } from './securities.service';
|
||||
import { SearchQueryDto, SecurityType } from './dto/search-query.dto';
|
||||
|
||||
@ApiTags('Securities')
|
||||
@Controller('securities')
|
||||
export class SecuritiesController {
|
||||
constructor(private readonly securitiesService: SecuritiesService) {}
|
||||
|
||||
@Get('search')
|
||||
@ApiOperation({ summary: 'Поиск по инструментам' })
|
||||
async search(@Query(ValidationPipe) query: SearchQueryDto) {
|
||||
const results = await this.securitiesService.search(
|
||||
query.q,
|
||||
query.type || SecurityType.ALL,
|
||||
query.limit || 20,
|
||||
);
|
||||
return { data: results, meta: { cachedAt: null, fromCache: false } };
|
||||
}
|
||||
}
|
||||
12
apps/backend/src/modules/securities/securities.module.ts
Normal file
12
apps/backend/src/modules/securities/securities.module.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CacheModule } from '../cache/cache.module';
|
||||
import { SecuritiesController } from './securities.controller';
|
||||
import { SecuritiesService } from './securities.service';
|
||||
|
||||
@Module({
|
||||
imports: [CacheModule],
|
||||
controllers: [SecuritiesController],
|
||||
providers: [SecuritiesService],
|
||||
exports: [SecuritiesService],
|
||||
})
|
||||
export class SecuritiesModule {}
|
||||
@ -0,0 +1,34 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { SecuritiesService } from './securities.service';
|
||||
import { MoexClientService } from '../moex-client/moex-client.service';
|
||||
import { CacheService } from '../cache/cache.service';
|
||||
import configuration from '../../config/configuration';
|
||||
import { SecurityType } from './dto/search-query.dto';
|
||||
|
||||
describe('SecuritiesService', () => {
|
||||
let service: SecuritiesService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
imports: [ConfigModule.forRoot({ load: [configuration] })],
|
||||
providers: [
|
||||
SecuritiesService,
|
||||
MoexClientService,
|
||||
{
|
||||
provide: 'CACHE_MANAGER',
|
||||
useValue: { get: () => undefined, set: () => Promise.resolve(), del: () => Promise.resolve() },
|
||||
},
|
||||
CacheService,
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<SecuritiesService>(SecuritiesService);
|
||||
});
|
||||
|
||||
it('should return search results for SBER', async () => {
|
||||
const results = await service.search('SBER', SecurityType.ALL, 5);
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
expect(results[0].secid).toBeDefined();
|
||||
}, 15000);
|
||||
});
|
||||
77
apps/backend/src/modules/securities/securities.service.ts
Normal file
77
apps/backend/src/modules/securities/securities.service.ts
Normal file
@ -0,0 +1,77 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { MoexClientService } from '../moex-client/moex-client.service';
|
||||
import { CacheService } from '../cache/cache.service';
|
||||
import { SecurityType } from './dto/search-query.dto';
|
||||
|
||||
export interface SearchResultItem {
|
||||
secid: string;
|
||||
isin: string;
|
||||
shortName: string;
|
||||
type: 'share' | 'bond';
|
||||
listLevel: number;
|
||||
currency: string | null;
|
||||
price: number | null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SecuritiesService {
|
||||
constructor(
|
||||
private readonly moexClient: MoexClientService,
|
||||
private readonly cache: CacheService,
|
||||
) {}
|
||||
|
||||
async search(query: string, type: SecurityType, limit: number): Promise<SearchResultItem[]> {
|
||||
const { data } = await this.cache.getOrFetch(
|
||||
'search',
|
||||
[query.toLowerCase()],
|
||||
async () => {
|
||||
const results = await this.moexClient.searchSecurities(query);
|
||||
return results
|
||||
.map((s): SearchResultItem | null => {
|
||||
const type = (s.group === 'stock_shares' || s.type === 'common_share' || s.type === 'preferred_share')
|
||||
? 'share' as const
|
||||
: (s.group === 'stock_bonds' ? 'bond' as const : null);
|
||||
if (!type) return null;
|
||||
return {
|
||||
secid: s.secid,
|
||||
isin: s.isin,
|
||||
shortName: s.shortName,
|
||||
type,
|
||||
listLevel: s.listLevel,
|
||||
currency: s.faceUnit === 'SUR' ? 'RUB' : s.faceUnit || null,
|
||||
price: null,
|
||||
};
|
||||
})
|
||||
.filter((r): r is SearchResultItem => r !== null);
|
||||
},
|
||||
'searchTtl',
|
||||
);
|
||||
|
||||
let filtered = data;
|
||||
if (type === SecurityType.SHARE) {
|
||||
filtered = data.filter((r) => r.type === 'share');
|
||||
} else if (type === SecurityType.BOND) {
|
||||
filtered = data.filter((r) => r.type === 'bond');
|
||||
}
|
||||
|
||||
return filtered.slice(0, limit);
|
||||
}
|
||||
|
||||
async getShareBrief(secid: string): Promise<SearchResultItem | null> {
|
||||
try {
|
||||
const desc = await this.moexClient.getSecurityDescription(secid);
|
||||
if (!desc) return null;
|
||||
return {
|
||||
secid: desc.secid,
|
||||
isin: desc.isin,
|
||||
shortName: desc.shortName,
|
||||
type: 'share',
|
||||
listLevel: desc.listLevel,
|
||||
currency: desc.faceUnit === 'SUR' ? 'RUB' : desc.faceUnit,
|
||||
price: null,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user