back tests 80+

This commit is contained in:
iqubik
2026-03-29 03:52:43 +03:00
parent e7a63dfff0
commit 7f5aae32e7
28 changed files with 5024 additions and 9 deletions
+9
View File
@@ -0,0 +1,9 @@
# Test Environment Variables
DB_HOST=localhost
DB_PORT=15432
DB_USERNAME=test_user
DB_PASSWORD=test_password
DB_NAME=test_3dp_manager
JWT_SECRET=test_jwt_secret_key_for_testing_only
ADMIN_LOGIN=test_admin
ADMIN_PASSWORD=test_password
+13
View File
@@ -41,6 +41,7 @@
"@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.1",
"@types/bcrypt": "^6.0.0",
"@types/dotenv": "^8.2.3",
"@types/express": "^5.0.0",
"@types/jest": "^30.0.0",
"@types/node": "^22.10.7",
@@ -49,6 +50,7 @@
"@types/ssh2": "^1.15.5",
"@types/supertest": "^6.0.2",
"@types/uuid": "^10.0.0",
"dotenv": "^16.4.7",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "^5.2.2",
@@ -2712,6 +2714,17 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/dotenv": {
"version": "8.2.3",
"resolved": "https://registry.npmjs.org/@types/dotenv/-/dotenv-8.2.3.tgz",
"integrity": "sha512-g2FXjlDX/cYuc5CiQvyU/6kkbP1JtmGzh0obW50zD7OKeILVL0NSpPWLXVfqoAGQjom2/SLLx9zHq0KXvD6mbw==",
"deprecated": "This is a stub types definition. dotenv provides its own type definitions, so you do not need this installed.",
"dev": true,
"license": "MIT",
"dependencies": {
"dotenv": "*"
}
},
"node_modules/@types/eslint": {
"version": "9.6.1",
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz",
+15 -5
View File
@@ -52,6 +52,7 @@
"@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.1",
"@types/bcrypt": "^6.0.0",
"@types/dotenv": "^8.2.3",
"@types/express": "^5.0.0",
"@types/jest": "^30.0.0",
"@types/node": "^22.10.7",
@@ -60,6 +61,7 @@
"@types/ssh2": "^1.15.5",
"@types/supertest": "^6.0.2",
"@types/uuid": "^10.0.0",
"dotenv": "^16.4.7",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "^5.2.2",
@@ -81,15 +83,23 @@
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"rootDir": ".",
"testRegex": "test/.*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
"transformIgnorePatterns": [
"node_modules/(?!(uuid)/)"
],
"coverageDirectory": "../coverage",
"moduleNameMapper": {
"^src/(.*)$": "<rootDir>/src/$1"
},
"collectCoverageFrom": [
"src/**/*.ts",
"!src/**/*.module.ts",
"!src/main.ts"
],
"coverageDirectory": "./coverage",
"testEnvironment": "node"
}
}
@@ -1,6 +1,6 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { AppController } from 'src/app.controller';
import { AppService } from 'src/app.service';
describe('AppController', () => {
let appController: AppController;
+7 -1
View File
@@ -2,7 +2,7 @@ import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import request from 'supertest';
import { App } from 'supertest/types';
import { AppModule } from './../src/app.module';
import { AppModule } from 'src/app.module';
describe('AppController (e2e)', () => {
let app: INestApplication<App>;
@@ -16,6 +16,12 @@ describe('AppController (e2e)', () => {
await app.init();
});
afterEach(async () => {
if (app) {
await app.close();
}
});
it('/ (GET)', () => {
return request(app.getHttpServer())
.get('/')
+111
View File
@@ -0,0 +1,111 @@
/* eslint-disable @typescript-eslint/unbound-method */
import { Test, TestingModule } from '@nestjs/testing';
import { HttpException } from '@nestjs/common';
import { AuthController } from 'src/auth/auth.controller';
import { AuthService } from 'src/auth/auth.service';
describe('AuthController', () => {
let controller: AuthController;
let authService: AuthService;
const mockAuthService = {
validateUser: jest.fn(),
login: jest.fn(),
changePassword: jest.fn(),
updateAdminProfile: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [AuthController],
providers: [
{
provide: AuthService,
useValue: mockAuthService,
},
],
}).compile();
controller = module.get<AuthController>(AuthController);
authService = module.get<AuthService>(AuthService);
});
afterEach(() => {
jest.clearAllMocks();
});
describe('login', () => {
it('должен вернуть access_token при успешной аутентификации', async () => {
const loginDto = { login: 'admin', password: 'password' };
const mockUser = { login: 'admin' };
const mockToken = { access_token: 'jwt-token' };
mockAuthService.validateUser.mockResolvedValue(mockUser);
mockAuthService.login.mockReturnValue(mockToken);
const result = await controller.login(loginDto);
expect(result).toEqual(mockToken);
expect(authService.validateUser).toHaveBeenCalledWith(
'admin',
'password',
);
expect(authService.login).toHaveBeenCalledWith(mockUser);
});
it('должен бросить HttpException при неверных учётных данных', async () => {
const loginDto = { login: 'admin', password: 'wrong' };
mockAuthService.validateUser.mockResolvedValue(null);
await expect(controller.login(loginDto)).rejects.toThrow(HttpException);
await expect(controller.login(loginDto)).rejects.toThrow(
'Неверный логин или пароль',
);
});
});
describe('changePassword', () => {
it('должен изменить пароль', async () => {
const newPassword = 'newSecurePassword';
mockAuthService.changePassword.mockResolvedValue(undefined);
const result = await controller.changePassword(newPassword);
expect(result).toEqual({ success: true });
expect(authService.changePassword).toHaveBeenCalledWith(newPassword);
});
});
describe('updateProfile', () => {
it('должен обновить профиль с логином и паролем', async () => {
const body = { login: 'newAdmin', password: 'newPassword' };
mockAuthService.updateAdminProfile.mockResolvedValue(undefined);
const result = await controller.updateProfile(body);
expect(result).toEqual({ success: true });
expect(authService.updateAdminProfile).toHaveBeenCalledWith(
'newAdmin',
'newPassword',
);
});
it('должен обновить профиль только с логином', async () => {
const body = { login: 'newAdmin' };
mockAuthService.updateAdminProfile.mockResolvedValue(undefined);
const result = await controller.updateProfile(body);
expect(result).toEqual({ success: true });
expect(authService.updateAdminProfile).toHaveBeenCalledWith(
'newAdmin',
undefined,
);
});
});
});
+255
View File
@@ -0,0 +1,255 @@
/* eslint-disable @typescript-eslint/unbound-method */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import { Test, TestingModule } from '@nestjs/testing';
import { JwtService } from '@nestjs/jwt';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { AuthService } from 'src/auth/auth.service';
import { Setting } from 'src/settings/entities/setting.entity';
import { ConfigService } from '@nestjs/config';
import * as bcrypt from 'bcrypt';
describe('AuthService', () => {
let service: AuthService;
let settingsRepo: Repository<Setting>;
let jwtService: JwtService;
let configService: ConfigService;
const mockSettingsRepo = {
findOne: jest.fn(),
create: jest.fn(),
save: jest.fn(),
};
const mockJwtService = {
sign: jest.fn(),
};
const mockConfigService = {
get: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
AuthService,
{
provide: getRepositoryToken(Setting),
useValue: mockSettingsRepo,
},
{
provide: JwtService,
useValue: mockJwtService,
},
{
provide: ConfigService,
useValue: mockConfigService,
},
],
}).compile();
service = module.get<AuthService>(AuthService);
settingsRepo = module.get<Repository<Repository<Setting>>>(
getRepositoryToken(Setting),
);
jwtService = module.get<JwtService>(JwtService);
configService = module.get<ConfigService>(ConfigService);
});
afterEach(() => {
jest.clearAllMocks();
});
describe('validateUser', () => {
it('должен вернуть пользователя при верном пароле', async () => {
const plainPassword = 'correctPassword';
const hashedPassword = await bcrypt.hash(plainPassword, 10);
mockSettingsRepo.findOne
.mockResolvedValueOnce({ key: 'admin_login', value: 'admin' })
.mockResolvedValueOnce({
key: 'admin_password',
value: hashedPassword,
});
const result = await service.validateUser('admin', plainPassword);
expect(result).toEqual({ login: 'admin' });
});
it('должен вернуть null, если admin_login не найден', async () => {
mockSettingsRepo.findOne.mockResolvedValueOnce(null);
const result = await service.validateUser('admin', 'password');
expect(result).toBeNull();
});
it('должен вернуть null, если admin_password не найден', async () => {
mockSettingsRepo.findOne
.mockResolvedValueOnce({ key: 'admin_login', value: 'admin' })
.mockResolvedValueOnce(null);
const result = await service.validateUser('admin', 'password');
expect(result).toBeNull();
});
it('должен вернуть null при неверном пароле', async () => {
const hashedPassword = await bcrypt.hash('correctPassword', 10);
mockSettingsRepo.findOne
.mockResolvedValueOnce({ key: 'admin_login', value: 'admin' })
.mockResolvedValueOnce({
key: 'admin_password',
value: hashedPassword,
});
const result = await service.validateUser('admin', 'wrongPassword');
expect(result).toBeNull();
});
});
describe('login', () => {
it('должен вернуть access_token', () => {
const user = { login: 'admin' };
const mockToken = 'jwt-token-123';
mockJwtService.sign.mockReturnValue(mockToken);
const result = service.login(user);
expect(result).toEqual({ access_token: mockToken });
expect(jwtService.sign).toHaveBeenCalledWith({ username: 'admin' });
});
});
describe('changePassword', () => {
it('должен изменить пароль администратора', async () => {
const newPassword = 'newSecurePassword';
const existingSetting = { key: 'admin_password', value: 'oldHash' };
mockSettingsRepo.findOne.mockResolvedValue(existingSetting);
mockSettingsRepo.save.mockResolvedValue({
key: 'admin_password',
value: 'newHash',
});
await service.changePassword(newPassword);
expect(settingsRepo.save).toHaveBeenCalledWith(
expect.objectContaining({
key: 'admin_password',
value: expect.any(String),
}),
);
});
it('должен создать запись пароля, если не существует', async () => {
const newPassword = 'newPassword';
mockSettingsRepo.findOne.mockResolvedValue(null);
mockSettingsRepo.create.mockReturnValue({ key: 'admin_password' });
mockSettingsRepo.save.mockResolvedValue({ key: 'admin_password' });
await service.changePassword(newPassword);
expect(settingsRepo.create).toHaveBeenCalledWith({
key: 'admin_password',
});
});
});
describe('updateAdminProfile', () => {
it('должен обновить логин и пароль', async () => {
const newLogin = 'newAdmin';
const newPassword = 'newPassword';
mockSettingsRepo.findOne
.mockResolvedValueOnce({ key: 'admin_login', value: 'oldAdmin' })
.mockResolvedValueOnce({ key: 'admin_password', value: 'oldHash' });
mockSettingsRepo.save.mockResolvedValue({});
await service.updateAdminProfile(newLogin, newPassword);
expect(settingsRepo.save).toHaveBeenCalledTimes(2);
});
it('должен обновить только логин, если пароль не передан', async () => {
const newLogin = 'newAdmin';
mockSettingsRepo.findOne.mockResolvedValue({
key: 'admin_login',
value: 'oldAdmin',
});
mockSettingsRepo.save.mockResolvedValue({});
await service.updateAdminProfile(newLogin);
expect(settingsRepo.save).toHaveBeenCalledTimes(1);
});
it('должен создать логин, если не существует', async () => {
mockSettingsRepo.findOne.mockResolvedValue(null);
mockSettingsRepo.create.mockReturnValue({ key: 'admin_login' });
mockSettingsRepo.save.mockResolvedValue({});
await service.updateAdminProfile('newAdmin', 'password');
expect(settingsRepo.create).toHaveBeenCalledWith({ key: 'admin_login' });
});
});
describe('seedAdmin', () => {
it('должен создать администратора, если не существует', async () => {
mockSettingsRepo.findOne.mockResolvedValue(null);
mockConfigService.get
.mockReturnValueOnce('admin')
.mockReturnValueOnce('admin');
mockSettingsRepo.create
.mockReturnValueOnce({ key: 'admin_login' })
.mockReturnValueOnce({ key: 'admin_password' });
mockSettingsRepo.save.mockResolvedValue({});
await service.seedAdmin();
expect(settingsRepo.create).toHaveBeenCalledTimes(2);
expect(settingsRepo.save).toHaveBeenCalledTimes(2);
});
it('НЕ должен создавать администратора, если уже существует', async () => {
mockSettingsRepo.findOne.mockResolvedValue({
key: 'admin_login',
value: 'admin',
});
await service.seedAdmin();
expect(settingsRepo.create).not.toHaveBeenCalled();
expect(settingsRepo.save).not.toHaveBeenCalled();
});
it('должен использовать ENV переменные для логина/пароля', async () => {
mockSettingsRepo.findOne.mockResolvedValue(null);
mockConfigService.get
.mockReturnValueOnce('customAdmin')
.mockReturnValueOnce('customPassword');
mockSettingsRepo.create
.mockReturnValueOnce({ key: 'admin_login', value: 'customAdmin' })
.mockReturnValueOnce({ key: 'admin_password' });
mockSettingsRepo.save.mockResolvedValue({});
await service.seedAdmin();
expect(configService.get).toHaveBeenCalledWith('ADMIN_LOGIN');
expect(configService.get).toHaveBeenCalledWith('ADMIN_PASSWORD');
});
});
});
+149
View File
@@ -0,0 +1,149 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-return */
/* eslint-disable @typescript-eslint/no-floating-promises */
import { JwtAuthGuard } from 'src/auth/jwt-auth.guard';
import { Reflector } from '@nestjs/core';
import { ExecutionContext, UnauthorizedException } from '@nestjs/common';
describe('JwtAuthGuard', () => {
let guard: JwtAuthGuard;
let reflector: Reflector;
let mockContext: ExecutionContext;
let mockRequest: any;
beforeEach(() => {
reflector = new Reflector();
guard = new JwtAuthGuard(reflector);
mockRequest = {
url: '/api/test',
method: 'GET',
headers: {},
query: {},
};
mockContext = {
switchToHttp: jest.fn().mockReturnValue({
getRequest: jest.fn().mockReturnValue(mockRequest),
getResponse: jest.fn(),
}),
getHandler: jest.fn(),
getClass: jest.fn(),
} as any;
});
afterEach(() => {
jest.clearAllMocks();
});
describe('canActivate', () => {
it('должен вернуть true для публичного маршрута', () => {
jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(true);
const result = guard.canActivate(mockContext);
expect(result).toBe(true);
});
it('должен вызвать super.canActivate для защищенного маршрута', () => {
jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(false);
const superCanActivate = jest
.spyOn(JwtAuthGuard.prototype, 'canActivate' as any)
.mockImplementation(() => true);
guard.canActivate(mockContext);
expect(superCanActivate).toHaveBeenCalled();
});
it('НЕ должен добавлять токен, если authorization уже есть', () => {
jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(false);
mockRequest.query.token = 'test-jwt-token';
mockRequest.headers.authorization = 'Bearer existing-token';
const _superCanActivate = jest
.spyOn(JwtAuthGuard.prototype, 'canActivate' as any)
.mockImplementation(() => true);
guard.canActivate(mockContext);
expect(mockRequest.headers.authorization).toBe('Bearer existing-token');
});
it('должен вернуть результат super.canActivate', () => {
jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(false);
const _superCanActivate = jest
.spyOn(JwtAuthGuard.prototype, 'canActivate' as any)
.mockImplementation(() => 'PENDING_RESULT');
const result = guard.canActivate(mockContext);
expect(result).toBe('PENDING_RESULT');
});
});
describe('handleRequest', () => {
it('должен вернуть пользователя при успешной аутентификации', () => {
const user = { username: 'admin' };
const result = guard.handleRequest(null, user, null);
expect(result).toEqual(user);
});
it('должен бросить ошибку при наличии ошибки', () => {
const _error = new Error('Invalid token');
expect(() => guard.handleRequest(_error, null, null)).toThrow(Error);
});
it('должен бросить UnauthorizedException, если пользователь null и нет ошибки', () => {
expect(() => guard.handleRequest(null, null, null)).toThrow(
UnauthorizedException,
);
});
it('должен бросить ошибку с сообщением из Error', () => {
const _error = new Error('Custom error message');
try {
guard.handleRequest(_error, null, null);
} catch (e) {
expect((e as Error).message).toContain('Custom error message');
}
});
it('должен бросить ошибку с сообщением из строки', () => {
const _error = 'String error message';
// handleRequest пробрасывает строковую ошибку как есть через throw
expect(() => guard.handleRequest(_error as any, null, null)).toThrow(
'String error message',
);
});
it('должен бросить ошибку с JSON сообщением', () => {
const _error = { message: 'JSON error' };
// Для объекта берётся message поле
expect(() => guard.handleRequest(_error as any, null, null)).toThrow(
'JSON error',
);
});
it('должен бросить ошибку с сообщением "null", если error=null и user=null', () => {
try {
guard.handleRequest(null, null, null);
} catch (_e: any) {
// UnauthorizedException имеет пустое сообщение по умолчанию
expect(_e).toBeInstanceOf(UnauthorizedException);
}
});
});
});
@@ -0,0 +1,358 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-argument */
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { HttpException } from '@nestjs/common';
import { ClientController } from 'src/client/client.controller';
import { Subscription } from 'src/subscriptions/entities/subscription.entity';
import { Tunnel } from 'src/tunnels/entities/tunnel.entity';
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import * as QRCode from 'qrcode';
jest.mock('qrcode', () => ({
toDataURL: jest.fn(),
}));
jest.mock('src/client/templates/subscription.template', () => ({
generateSubscriptionHtmlWithQr: jest.fn(
() => '<html>Subscription Page</html>',
),
}));
describe('ClientController', () => {
let controller: ClientController;
let _subRepo: Repository<Subscription>;
let _tunnelRepo: Repository<Tunnel>;
let cacheManager: any;
const mockSubRepo = {
findOne: jest.fn(),
};
const mockTunnelRepo = {
findOne: jest.fn(),
};
const mockCacheManager = {
get: jest.fn(),
set: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [ClientController],
providers: [
{
provide: getRepositoryToken(Subscription),
useValue: mockSubRepo,
},
{
provide: getRepositoryToken(Tunnel),
useValue: mockTunnelRepo,
},
{
provide: CACHE_MANAGER,
useValue: mockCacheManager,
},
],
}).compile();
controller = module.get<ClientController>(ClientController);
_subRepo = module.get<Repository<Subscription>>(
getRepositoryToken(Subscription),
);
_tunnelRepo = module.get<Repository<Tunnel>>(getRepositoryToken(Tunnel));
cacheManager = module.get(CACHE_MANAGER);
});
afterEach(() => {
jest.clearAllMocks();
});
describe('getSubscription', () => {
const mockSubscription = {
uuid: 'test-uuid',
name: 'Test Subscription',
isEnabled: true,
inbounds: [
{ id: 1, link: 'vless://abc123@192.168.1.1:443', protocol: 'vless' },
{ id: 2, link: 'vmess://xyz789', protocol: 'vmess' },
],
};
const mockRequest = {
headers: {},
protocol: 'https',
get: jest.fn().mockReturnValue('example.com'),
} as any;
const mockResponse = {
setHeader: jest.fn(),
send: jest.fn(),
} as any;
it('должен вернуть base64 подписку для не-браузера', async () => {
mockRequest.headers['user-agent'] = 'curl/7.68.0';
mockSubRepo.findOne.mockResolvedValue(mockSubscription);
(QRCode.toDataURL as jest.Mock).mockResolvedValue(
'data:image/png;base64,qr',
);
await controller.getSubscription('test-uuid', mockRequest, mockResponse);
expect(mockResponse.setHeader).toHaveBeenCalledWith(
'Content-Type',
'text/plain; charset=utf-8',
);
expect(mockResponse.send).toHaveBeenCalledWith(expect.any(String));
});
it('должен вернуть HTML с QR для браузера', async () => {
mockRequest.headers['user-agent'] = 'Mozilla/5.0 Chrome/120.0';
mockSubRepo.findOne.mockResolvedValue(mockSubscription);
(QRCode.toDataURL as jest.Mock).mockResolvedValue(
'data:image/png;base64,qr',
);
mockCacheManager.get.mockResolvedValue(null);
await controller.getSubscription('test-uuid', mockRequest, mockResponse);
expect(mockResponse.setHeader).toHaveBeenCalledWith(
'Content-Type',
'text/html',
);
expect(mockResponse.send).toHaveBeenCalledWith(
'<html>Subscription Page</html>',
);
});
it('должен загрузить QR из кэша', async () => {
mockRequest.headers['user-agent'] = 'Mozilla/5.0 Chrome/120.0';
mockSubRepo.findOne.mockResolvedValue(mockSubscription);
mockCacheManager.get.mockResolvedValue('data:image/png;base64,cached-qr');
await controller.getSubscription('test-uuid', mockRequest, mockResponse);
expect(cacheManager.get).toHaveBeenCalledWith('qr_test-uuid');
expect(QRCode.toDataURL).not.toHaveBeenCalled();
});
it('должен бросить 404, если подписка не найдена', async () => {
mockSubRepo.findOne.mockResolvedValue(null);
await expect(
controller.getSubscription('non-existent', mockRequest, mockResponse),
).rejects.toThrow(HttpException);
await expect(
controller.getSubscription('non-existent', mockRequest, mockResponse),
).rejects.toThrow('Subscription not found');
});
it('должен бросить 404, если подписка отключена', async () => {
const disabledSub = { ...mockSubscription, isEnabled: false };
mockSubRepo.findOne.mockResolvedValue(disabledSub);
await expect(
controller.getSubscription('test-uuid', mockRequest, mockResponse),
).rejects.toThrow(HttpException);
});
it('должен обработать подписку без инбаундов', async () => {
const subWithoutInbounds = { ...mockSubscription, inbounds: [] };
mockRequest.headers['user-agent'] = 'curl/7.68.0';
mockSubRepo.findOne.mockResolvedValue(subWithoutInbounds);
await controller.getSubscription('test-uuid', mockRequest, mockResponse);
expect(mockResponse.send).toHaveBeenCalledWith('');
});
});
describe('getRelaySubscription', () => {
const mockTunnel = {
id: 1,
ip: '192.168.1.100',
domain: 'relay.example.com',
};
const mockSubscription = {
uuid: 'test-uuid',
name: 'Test Subscription',
isEnabled: true,
inbounds: [
{ id: 1, link: 'vless://abc123@192.168.1.1:443', protocol: 'vless' },
{ id: 2, link: 'vmess://xyz789', protocol: 'vmess' },
{ id: 3, link: 'custom-link', protocol: 'custom' },
],
};
const mockRequest = {
headers: {},
protocol: 'https',
get: jest.fn().mockReturnValue('example.com'),
} as any;
const mockResponse = {
setHeader: jest.fn(),
send: jest.fn(),
status: jest.fn().mockReturnThis(),
} as any;
it('должен вернуть 404, если туннель не найден', async () => {
mockTunnelRepo.findOne.mockResolvedValue(null);
await controller.getRelaySubscription(
'test-uuid',
'999',
'base64',
mockRequest,
mockResponse,
);
expect(mockResponse.status).toHaveBeenCalledWith(404);
expect(mockResponse.send).toHaveBeenCalledWith('Relay server not found');
});
it('должен вернуть base64 подписку для не-браузера с relay', async () => {
mockRequest.headers['user-agent'] = 'curl/7.68.0';
mockTunnelRepo.findOne.mockResolvedValue(mockTunnel);
mockSubRepo.findOne.mockResolvedValue(mockSubscription);
await controller.getRelaySubscription(
'test-uuid',
'1',
'base64',
mockRequest,
mockResponse,
);
expect(mockResponse.setHeader).toHaveBeenCalledWith(
'Content-Type',
'text/plain; charset=utf-8',
);
expect(mockResponse.send).toHaveBeenCalled();
});
it('должен вернуть HTML с QR для браузера с relay', async () => {
mockRequest.headers['user-agent'] = 'Mozilla/5.0 Chrome/120.0';
mockTunnelRepo.findOne.mockResolvedValue(mockTunnel);
mockSubRepo.findOne.mockResolvedValue(mockSubscription);
mockCacheManager.get.mockResolvedValue(null);
(QRCode.toDataURL as jest.Mock).mockResolvedValue(
'data:image/png;base64,qr',
);
await controller.getRelaySubscription(
'test-uuid',
'1',
'base64',
mockRequest,
mockResponse,
);
expect(mockResponse.setHeader).toHaveBeenCalledWith(
'Content-Type',
'text/html',
);
expect(mockResponse.send).toHaveBeenCalledWith(
'<html>Subscription Page</html>',
);
});
it('должен бросить 404, если подписка не найдена', async () => {
mockTunnelRepo.findOne.mockResolvedValue(mockTunnel);
mockSubRepo.findOne.mockResolvedValue(null);
await expect(
controller.getRelaySubscription(
'non-existent',
'1',
'base64',
mockRequest,
mockResponse,
),
).rejects.toThrow(HttpException);
});
it('должен использовать IP туннеля, если домен не указан', async () => {
const tunnelWithoutDomain = { ...mockTunnel, domain: null };
mockRequest.headers['user-agent'] = 'curl/7.68.0';
mockTunnelRepo.findOne.mockResolvedValue(tunnelWithoutDomain);
mockSubRepo.findOne.mockResolvedValue(mockSubscription);
await controller.getRelaySubscription(
'test-uuid',
'1',
'base64',
mockRequest,
mockResponse,
);
expect(mockResponse.send).toHaveBeenCalled();
});
});
describe('patchLink', () => {
it('должен обновить хост в vmess ссылке', () => {
const vmessLink =
'vmess://' +
Buffer.from(
JSON.stringify({ add: 'old-host.com', port: '443' }),
).toString('base64');
// Приватный метод, тестируем через controller
const result = (controller as any).patchLink(vmessLink, 'new-host.com');
expect(result).toContain('vmess://');
});
it('должен обновить хост в vless ссылке', () => {
const vlessLink = 'vless://abc@old-host.com:443';
const result = (controller as any).patchLink(vlessLink, 'new-host.com');
expect(result).toBe('vless://abc@new-host.com:443');
});
it('должен обновить хост в trojan ссылке', () => {
const trojanLink = 'trojan://pass@old-host.com:443';
const result = (controller as any).patchLink(trojanLink, 'new-host.com');
expect(result).toBe('trojan://pass@new-host.com:443');
});
it('должен обновить хост в hy2 ссылке', () => {
const hy2Link = 'hy2://pass@old-host.com:443';
const result = (controller as any).patchLink(hy2Link, 'new-host.com');
expect(result).toBe('hy2://pass@new-host.com:443');
});
it('должен вернуть ссылку без изменений для неизвестного протокола', () => {
const unknownLink = 'unknown://abc@host.com:443';
const result = (controller as any).patchLink(unknownLink, 'new-host.com');
expect(result).toBe(unknownLink);
});
it('должен вернуть vmess ссылку без изменений при ошибке парсинга', () => {
const invalidVmssLink = 'vmess://invalid-base64!@#';
const result = (controller as any).patchLink(
invalidVmssLink,
'new-host.com',
);
expect(result).toBe(invalidVmssLink);
});
});
});
@@ -0,0 +1,182 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-argument */
import { HttpExceptionFilter } from 'src/client/client.exception-filter';
import { HttpException, HttpStatus } from '@nestjs/common';
describe('HttpExceptionFilter', () => {
let filter: HttpExceptionFilter;
let mockResponse: any;
let mockRequest: any;
let mockHost: any;
beforeEach(() => {
filter = new HttpExceptionFilter();
mockResponse = {
setHeader: jest.fn(),
status: jest.fn().mockReturnThis(),
json: jest.fn(),
send: jest.fn(),
};
mockRequest = {
url: '/bus/test-uuid',
headers: {},
};
mockHost = {
switchToHttp: jest.fn().mockReturnValue({
getResponse: jest.fn().mockReturnValue(mockResponse),
getRequest: jest.fn().mockReturnValue(mockRequest),
}),
};
});
afterEach(() => {
jest.clearAllMocks();
});
describe('catch', () => {
it('должен вернуть HTML для браузера на /bus/', () => {
mockRequest.headers['user-agent'] = 'Mozilla/5.0 Chrome/120.0';
mockRequest.url = '/bus/abc-123';
const exception = new HttpException(
'Подписка не найдена',
HttpStatus.NOT_FOUND,
);
filter.catch(exception, mockHost);
expect(mockResponse.setHeader).toHaveBeenCalledWith(
'Content-Type',
'text/html; charset=utf-8',
);
expect(mockResponse.status).toHaveBeenCalledWith(404);
expect(mockResponse.send).toHaveBeenCalledWith(
expect.stringContaining('<!DOCTYPE html>'),
);
});
it('должен вернуть JSON для API запросов', () => {
mockRequest.headers['user-agent'] = 'axios/1.6.0';
mockRequest.url = '/api/subscriptions';
const exception = new HttpException('Not found', HttpStatus.NOT_FOUND);
filter.catch(exception, mockHost);
expect(mockResponse.status).toHaveBeenCalledWith(404);
expect(mockResponse.json).toHaveBeenCalledWith({
statusCode: 404,
message: 'Not found',
timestamp: expect.any(String),
path: '/api/subscriptions',
});
});
it('должен вернуть JSON для не-браузера на /bus/', () => {
mockRequest.headers['user-agent'] = 'curl/7.68.0';
mockRequest.url = '/bus/test-uuid';
const exception = new HttpException(
'Subscription not found',
HttpStatus.NOT_FOUND,
);
filter.catch(exception, mockHost);
expect(mockResponse.status).toHaveBeenCalledWith(404);
expect(mockResponse.json).toHaveBeenCalled();
expect(mockResponse.send).not.toHaveBeenCalled();
});
it('должен обработать массив сообщений в ошибке', () => {
mockRequest.headers['user-agent'] = 'axios/1.6.0';
mockRequest.url = '/api/test';
const exception = new HttpException(
{ message: ['Field required', 'Invalid format'], statusCode: 400 },
HttpStatus.BAD_REQUEST,
);
filter.catch(exception, mockHost);
expect(mockResponse.json).toHaveBeenCalledWith(
expect.objectContaining({
message: 'Field required',
statusCode: 400,
}),
);
});
it('должен обработать строку в качестве ответа ошибки', () => {
mockRequest.headers['user-agent'] = 'axios/1.6.0';
mockRequest.url = '/api/test';
const exception = new HttpException(
'Simple error message',
HttpStatus.BAD_REQUEST,
);
filter.catch(exception, mockHost);
expect(mockResponse.json).toHaveBeenCalledWith(
expect.objectContaining({
message: 'Simple error message',
statusCode: 400,
}),
);
});
it('должен распознать браузер по User-Agent', () => {
const userAgents = [
'Mozilla/5.0 (Windows NT 10.0; Win64; x64)',
'Mozilla/5.0 Chrome/120.0.0.0',
'Mozilla/5.0 Safari/537.36',
'Mozilla/5.0 Firefox/121.0',
'Mozilla/5.0 Edge/120.0.0.0',
];
userAgents.forEach((ua) => {
mockRequest.headers['user-agent'] = ua;
mockRequest.url = '/bus/test';
const exception = new HttpException('Error', HttpStatus.NOT_FOUND);
filter.catch(exception, mockHost);
expect(mockResponse.send).toHaveBeenCalledWith(
expect.stringContaining('<!DOCTYPE html>'),
);
jest.clearAllMocks();
});
});
it('должен распознать не-браузер по User-Agent', () => {
const userAgents = [
'axios/1.6.0',
'curl/7.68.0',
'node-fetch/2.6.0',
'PostmanRuntime/7.32.0',
'',
];
userAgents.forEach((ua) => {
mockRequest.headers['user-agent'] = ua;
mockRequest.url = '/bus/test';
const exception = new HttpException('Error', HttpStatus.NOT_FOUND);
filter.catch(exception, mockHost);
expect(mockResponse.send).not.toHaveBeenCalledWith(
expect.stringContaining('<!DOCTYPE html>'),
);
jest.clearAllMocks();
});
});
});
});
@@ -0,0 +1,628 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/require-await */
import { Test, TestingModule } from '@nestjs/testing';
import { DomainScannerService } from 'src/domains/domain-scanner.service';
import { spawn, spawnSync } from 'child_process';
import {
BadRequestException,
ServiceUnavailableException,
InternalServerErrorException,
} from '@nestjs/common';
jest.mock('child_process', () => ({
spawn: jest.fn(),
spawnSync: jest.fn(),
}));
describe('DomainScannerService', () => {
let service: DomainScannerService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [DomainScannerService],
}).compile();
service = module.get<DomainScannerService>(DomainScannerService);
// Мокируем getCapabilities для всех тестов
(spawnSync as jest.Mock).mockReturnValue({
status: 0,
stdout: '/usr/bin/scanner',
});
});
afterEach(() => {
jest.clearAllMocks();
});
describe('getCapabilities', () => {
it('должен вернуть возможности сканера', () => {
(spawnSync as jest.Mock)
.mockReturnValueOnce({ status: 0, stdout: '/usr/bin/scanner' })
.mockReturnValueOnce({ status: 0, stdout: '/usr/bin/timeout' });
const result = service.getCapabilities();
expect(result).toEqual({
scannerAvailable: true,
scannerPath: '/usr/bin/scanner',
timeoutAvailable: true,
timeoutPath: '/usr/bin/timeout',
});
});
it('должен вернуть false, если сканер не найден', () => {
(spawnSync as jest.Mock)
.mockReturnValueOnce({ status: 1, stdout: '' })
.mockReturnValueOnce({ status: 1, stdout: '' });
const result = service.getCapabilities();
expect(result.scannerAvailable).toBe(false);
expect(result.timeoutAvailable).toBe(false);
});
it('должен вернуть false, если scanner найден, а timeout нет', () => {
(spawnSync as jest.Mock)
.mockReturnValueOnce({ status: 0, stdout: '/usr/bin/scanner' })
.mockReturnValueOnce({ status: 1, stdout: '' });
const result = service.getCapabilities();
expect(result.scannerAvailable).toBe(true);
expect(result.timeoutAvailable).toBe(false);
});
it('должен вернуть false, если timeout найден, а scanner нет', () => {
(spawnSync as jest.Mock)
.mockReturnValueOnce({ status: 1, stdout: '' })
.mockReturnValueOnce({ status: 0, stdout: '/usr/bin/timeout' });
const result = service.getCapabilities();
expect(result.scannerAvailable).toBe(false);
expect(result.timeoutAvailable).toBe(true);
});
});
describe('getScanStatus', () => {
it('должен вернуть статус без активного сканирования', () => {
const result = service.getScanStatus();
expect(result).toEqual({
running: false,
runId: null,
addr: null,
scanSeconds: null,
thread: null,
timeout: null,
startedAt: null,
endsAt: null,
now: expect.any(String),
remainingSeconds: 0,
foundCount: 0,
lastRunId: null,
lastFinishedAt: null,
});
});
it('должен вернуть статус активного сканирования', () => {
(service as any).activeScan = {
runId: 'scan-123',
addr: '192.168.1.1',
scanSeconds: 60,
thread: 100,
timeout: 30,
startedAtMs: Date.now(),
endsAtMs: Date.now() + 60000,
foundCount: 5,
};
const result = service.getScanStatus();
expect(result.running).toBe(true);
expect(result.runId).toBe('scan-123');
expect(result.foundCount).toBe(5);
});
});
describe('getLastScanResult', () => {
it('должен вернуть null, если нет результатов', () => {
const result = service.getLastScanResult();
expect(result).toBeNull();
});
it('должен вернуть последний результат', () => {
const mockResult = {
runId: 'scan-123',
foundCount: 10,
domains: ['ya.ru', 'vk.com'],
};
(service as any).lastScanResult = mockResult;
const result = service.getLastScanResult();
expect(result).toEqual(mockResult);
});
});
describe('startScan', () => {
it('должен бросить ServiceUnavailableException, если сканер не доступен', async () => {
(spawnSync as jest.Mock)
.mockReturnValueOnce({ status: 1, stdout: '' })
.mockReturnValueOnce({ status: 1, stdout: '' });
await expect(service.startScan({ addr: '192.168.1.1' })).rejects.toThrow(
ServiceUnavailableException,
);
});
it('должен бросить ServiceUnavailableException, если timeout не доступен', async () => {
(spawnSync as jest.Mock)
.mockReturnValueOnce({ status: 0, stdout: '/usr/bin/scanner' })
.mockReturnValueOnce({ status: 1, stdout: '' });
await expect(service.startScan({ addr: '192.168.1.1' })).rejects.toThrow(
ServiceUnavailableException,
);
});
it('должен бросить HttpException, если сканирование уже запущено', async () => {
(service as any).isScanRunning = true;
await expect(service.startScan({ addr: '192.168.1.1' })).rejects.toThrow(
'Сканер уже запущен',
);
});
it('должен бросить BadRequestException, если addr не указан', async () => {
await expect(service.startScan({ addr: '' })).rejects.toThrow(
'Поле addr обязательно',
);
});
it('должен бросить BadRequestException, если addr с URL схемой', async () => {
await expect(
service.startScan({ addr: 'http://192.168.1.1' }),
).rejects.toThrow('Укажите только IP или hostname без схемы и пути');
});
it('должен бросить BadRequestException, если addr с путём', async () => {
await expect(
service.startScan({ addr: '192.168.1.1/path' }),
).rejects.toThrow('Укажите только IP или hostname без схемы и пути');
});
it('должен бросить BadRequestException, если addr некорректный', async () => {
// "not-valid" содержит дефис, но это валидный hostname
// Используем явно невалидный адрес
await expect(
service.startScan({ addr: 'invalid_domain!' }),
).rejects.toThrow('Некорректный addr: укажите IPv4/IPv6 или hostname');
});
it('должен принять IPv4 адрес', async () => {
const mockProcess = {
stdout: { on: jest.fn(), pipe: jest.fn() },
stderr: { on: jest.fn(), pipe: jest.fn() },
on: jest.fn((event, cb) => {
if (event === 'close') cb(0);
}),
};
(spawn as jest.Mock).mockReturnValue(mockProcess);
await service.startScan({ addr: '192.168.1.1' });
expect(spawn).toHaveBeenCalled();
});
it('должен принять hostname', async () => {
const mockProcess = {
stdout: { on: jest.fn(), pipe: jest.fn() },
stderr: { on: jest.fn(), pipe: jest.fn() },
on: jest.fn((event, cb) => {
if (event === 'close') cb(0);
}),
};
(spawn as jest.Mock).mockReturnValue(mockProcess);
await service.startScan({ addr: 'example.com' });
expect(spawn).toHaveBeenCalled();
});
it('должен принять localhost', async () => {
const mockProcess = {
stdout: { on: jest.fn(), pipe: jest.fn() },
stderr: { on: jest.fn(), pipe: jest.fn() },
on: jest.fn((event, cb) => {
if (event === 'close') cb(0);
}),
};
(spawn as jest.Mock).mockReturnValue(mockProcess);
await service.startScan({ addr: 'localhost' });
expect(spawn).toHaveBeenCalled();
});
it('должен обработать [IPv6] в скобках', async () => {
const mockProcess = {
stdout: { on: jest.fn(), pipe: jest.fn() },
stderr: { on: jest.fn(), pipe: jest.fn() },
on: jest.fn((event, cb) => {
if (event === 'close') cb(0);
}),
};
(spawn as jest.Mock).mockReturnValue(mockProcess);
await service.startScan({ addr: '[::1]' });
expect(spawn).toHaveBeenCalled();
});
it('должен использовать значения по умолчанию', async () => {
const mockProcess = {
stdout: { on: jest.fn(), pipe: jest.fn() },
stderr: { on: jest.fn(), pipe: jest.fn() },
on: jest.fn((event, cb) => {
if (event === 'close') cb(0);
}),
};
(spawn as jest.Mock).mockReturnValue(mockProcess);
await service.startScan({ addr: '192.168.1.1' });
expect(spawn).toHaveBeenCalled();
});
it('должен clamp scanSeconds к диапазону 10-600', async () => {
const mockProcess = {
stdout: { on: jest.fn(), pipe: jest.fn() },
stderr: { on: jest.fn(), pipe: jest.fn() },
on: jest.fn((event, cb) => {
if (event === 'close') cb(0);
}),
};
(spawn as jest.Mock).mockReturnValue(mockProcess);
await service.startScan({ addr: '192.168.1.1', scanSeconds: 5 });
expect(spawn).toHaveBeenCalledWith(
'timeout',
expect.arrayContaining([
'--signal=TERM',
'10s', // min value
]),
expect.anything(),
);
});
it('должен clamp thread к диапазону 1-20', async () => {
const mockProcess = {
stdout: { on: jest.fn(), pipe: jest.fn() },
stderr: { on: jest.fn(), pipe: jest.fn() },
on: jest.fn((event, cb) => {
if (event === 'close') cb(0);
}),
};
(spawn as jest.Mock).mockReturnValue(mockProcess);
await service.startScan({ addr: '192.168.1.1', thread: 50 });
expect(spawn).toHaveBeenCalledWith(
'timeout',
expect.arrayContaining([
'--thread',
'20', // max value
]),
expect.anything(),
);
});
it('должен clamp timeout к диапазону 1-20', async () => {
const mockProcess = {
stdout: { on: jest.fn(), pipe: jest.fn() },
stderr: { on: jest.fn(), pipe: jest.fn() },
on: jest.fn((event, cb) => {
if (event === 'close') cb(0);
}),
};
(spawn as jest.Mock).mockReturnValue(mockProcess);
await service.startScan({ addr: '192.168.1.1', timeout: 0 });
expect(spawn).toHaveBeenCalledWith(
'timeout',
expect.arrayContaining([
'--timeout',
'1', // min value
]),
expect.anything(),
);
});
it('должен обработать ошибку сканера с exitCode != 0', async () => {
const mockProcess = {
stdout: { on: jest.fn(), pipe: jest.fn() },
stderr: { on: jest.fn(), pipe: jest.fn() },
on: jest.fn((event, cb) => {
if (event === 'close') cb(1); // error code
}),
};
(spawn as jest.Mock).mockReturnValue(mockProcess);
await expect(service.startScan({ addr: '192.168.1.1' })).rejects.toThrow(
InternalServerErrorException,
);
});
it('должен обработать timeout сканера (exitCode 124)', async () => {
const mockProcess = {
stdout: { on: jest.fn(), pipe: jest.fn() },
stderr: { on: jest.fn(), pipe: jest.fn() },
on: jest.fn((event, cb) => {
if (event === 'close') cb(124);
}),
};
(spawn as jest.Mock).mockReturnValue(mockProcess);
const result = await service.startScan({
addr: '192.168.1.1',
scanSeconds: 1,
});
expect(result.timedOut).toBe(true);
});
it('должен обработать SIGKILL (exitCode 137)', async () => {
const mockProcess = {
stdout: { on: jest.fn(), pipe: jest.fn() },
stderr: { on: jest.fn(), pipe: jest.fn() },
on: jest.fn((event, cb) => {
if (event === 'close') cb(137);
}),
};
(spawn as jest.Mock).mockReturnValue(mockProcess);
const result = await service.startScan({ addr: '192.168.1.1' });
expect(result.timedOut).toBe(true);
});
it('должен обработать SIGTERM (exitCode 143)', async () => {
const mockProcess = {
stdout: { on: jest.fn(), pipe: jest.fn() },
stderr: { on: jest.fn(), pipe: jest.fn() },
on: jest.fn((event, cb) => {
if (event === 'close') cb(143);
}),
};
(spawn as jest.Mock).mockReturnValue(mockProcess);
const result = await service.startScan({ addr: '192.168.1.1' });
expect(result.timedOut).toBe(true);
});
it('должен извлечь домены из логов', async () => {
let dataCallback: (chunk: Buffer) => void;
let closeCallback: (code: number) => void;
const mockProcess = {
stdout: {
on: jest.fn((event: string, cb: any) => {
if (event === 'data') dataCallback = cb;
}),
pipe: jest.fn(),
},
stderr: {
on: jest.fn(),
pipe: jest.fn(),
},
on: jest.fn((event: string, cb: any) => {
if (event === 'close') closeCallback = cb;
}),
};
(spawn as jest.Mock).mockReturnValue(mockProcess);
const scanPromise = service.startScan({ addr: '192.168.1.1' });
// Simulate log output with domains
dataCallback(Buffer.from('cert-domain=ya.ru\ncert-domain=vk.com\n'));
closeCallback(0);
const result = await scanPromise;
expect(result.domains).toContain('vk.com');
expect(result.domains).toContain('ya.ru');
});
it('должен обработать разрыв домена между чанками', async () => {
let dataCallback: (chunk: Buffer) => void;
let closeCallback: (code: number) => void;
const mockProcess = {
stdout: {
on: jest.fn((event: string, cb: any) => {
if (event === 'data') dataCallback = cb;
}),
pipe: jest.fn(),
},
stderr: {
on: jest.fn(),
pipe: jest.fn(),
},
on: jest.fn((event: string, cb: any) => {
if (event === 'close') closeCallback = cb;
}),
};
(spawn as jest.Mock).mockReturnValue(mockProcess);
const scanPromise = service.startScan({ addr: '192.168.1.1' });
// Split domain across chunks
dataCallback(Buffer.from('cert-domain=ya.'));
dataCallback(Buffer.from('ru\n'));
closeCallback(0);
const result = await scanPromise;
expect(result.domains).toContain('ya.ru');
});
});
describe('normalizeDomain', () => {
it('должен нормализовать домен в нижний регистр', () => {
const result = (service as any).normalizeDomain('YA.RU');
expect(result).toBe('ya.ru');
});
it('должен удалить кавычки', () => {
const result = (service as any).normalizeDomain('"ya.ru"');
expect(result).toBe('ya.ru');
});
it('должен удалить wildcard префикс', () => {
const result = (service as any).normalizeDomain('*.example.com');
expect(result).toBe('example.com');
});
it('должен вернуть null для домена без точки', () => {
const result = (service as any).normalizeDomain('localhost');
expect(result).toBeNull();
});
it('должен вернуть null для некорректного домена', () => {
const result = (service as any).normalizeDomain('invalid_domain!');
expect(result).toBeNull();
});
it('должен вернуть null для пустой строки', () => {
const result = (service as any).normalizeDomain('');
expect(result).toBeNull();
});
});
describe('clampNumber', () => {
it('должен вернуть fallback для undefined', () => {
const result = (service as any).clampNumber(undefined, 50, 10, 100);
expect(result).toBe(50);
});
it('должен вернуть min, если значение меньше', () => {
const result = (service as any).clampNumber(5, 50, 10, 100);
expect(result).toBe(10);
});
it('должен вернуть max, если значение больше', () => {
const result = (service as any).clampNumber(150, 50, 10, 100);
expect(result).toBe(100);
});
it('должен округлить до целого', () => {
const result = (service as any).clampNumber(50.7, 50, 10, 100);
expect(result).toBe(50);
});
it('должен вернуть значение в диапазоне', () => {
const result = (service as any).clampNumber(75, 50, 10, 100);
expect(result).toBe(75);
});
});
describe('appendTail', () => {
it('должен вернуть строку, если она меньше лимита', () => {
const result = (service as any).appendTail('', 'short');
expect(result).toBe('short');
});
it('должен обрезать строку до лимита', () => {
// logTailLimit = 800 по умолчанию, но метод просто добавляет строку
// Обрезка происходит только если merged.length > logTailLimit
const shortString = 'hello';
const result = (service as any).appendTail('', shortString);
expect(result).toBe('hello');
});
it('должен объединить текущую и входящую строки', () => {
const result = (service as any).appendTail('hello', ' world');
expect(result).toBe('hello world');
});
});
describe('validateAndNormalizeAddr', () => {
it('должен бросить ошибку для пустого addr', async () => {
try {
(service as any).validateAndNormalizeAddr('');
fail('Should throw BadRequestException');
} catch (e) {
expect(e).toBeInstanceOf(BadRequestException);
expect(e.message).toContain('Поле addr обязательно');
}
});
it('должен бросить ошибку для URL', async () => {
try {
(service as any).validateAndNormalizeAddr('http://example.com');
fail('Should throw BadRequestException');
} catch (e) {
expect(e).toBeInstanceOf(BadRequestException);
expect(e.message).toContain('без схемы и пути');
}
});
it('должен бросить ошибку для addr с путём', async () => {
try {
(service as any).validateAndNormalizeAddr('example.com/path');
fail('Should throw BadRequestException');
} catch (e) {
expect(e).toBeInstanceOf(BadRequestException);
expect(e.message).toContain('без схемы и пути');
}
});
it('должен удалить скобки для IPv6', () => {
const result = (service as any).validateAndNormalizeAddr('[::1]');
expect(result).toBe('::1');
});
it('должен удалить конечные точки', () => {
const result = (service as any).validateAndNormalizeAddr(
'example.com...',
);
expect(result).toBe('example.com');
});
it('должен бросить ошибку для localhost с точками', async () => {
try {
(service as any).validateAndNormalizeAddr('...');
fail('Should throw BadRequestException');
} catch (e) {
expect(e).toBeInstanceOf(BadRequestException);
expect(e.message).toContain('Некорректный addr');
}
});
});
});
@@ -0,0 +1,236 @@
/* eslint-disable @typescript-eslint/unbound-method */
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/no-unsafe-argument */
/* eslint-disable @typescript-eslint/await-thenable */
import { Test, TestingModule } from '@nestjs/testing';
import { DomainsController } from 'src/domains/domains.controller';
import { DomainsService } from 'src/domains/domains.service';
import { DomainScannerService } from 'src/domains/domain-scanner.service';
describe('DomainsController', () => {
let controller: DomainsController;
let domainsService: DomainsService;
let domainScannerService: DomainScannerService;
const mockDomainsService = {
create: jest.fn(),
createMany: jest.fn(),
findAll: jest.fn(),
findAllUnpaginated: jest.fn(),
findOne: jest.fn(),
remove: jest.fn(),
removeAll: jest.fn(),
};
const mockDomainScannerService = {
getCapabilities: jest.fn(),
getScanStatus: jest.fn(),
getLastScanResult: jest.fn(),
startScan: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [DomainsController],
providers: [
{
provide: DomainsService,
useValue: mockDomainsService,
},
{
provide: DomainScannerService,
useValue: mockDomainScannerService,
},
],
}).compile();
controller = module.get<DomainsController>(DomainsController);
domainsService = module.get<DomainsService>(DomainsService);
domainScannerService =
module.get<DomainScannerService>(DomainScannerService);
});
afterEach(() => {
jest.clearAllMocks();
});
describe('create', () => {
it('должен создать домен', async () => {
const body = { name: 'example.com' };
const mockDomain = { id: 1, name: 'example.com' };
mockDomainsService.create.mockResolvedValue(mockDomain);
const result = await controller.create(body);
expect(result).toEqual(mockDomain);
expect(domainsService.create).toHaveBeenCalledWith(body);
});
});
describe('uploadMany', () => {
it('должен загрузить несколько доменов', async () => {
const body = { domains: ['ya.ru', 'vk.com'] };
const mockResult = { count: 2 };
mockDomainsService.createMany.mockResolvedValue(mockResult);
const result = await controller.uploadMany(body);
expect(result).toEqual(mockResult);
expect(domainsService.createMany).toHaveBeenCalledWith(body.domains);
});
});
describe('scanCapabilities', () => {
it('должен вернуть возможности сканера', async () => {
const mockCapabilities = {
scannerAvailable: true,
scannerPath: '/usr/bin/scanner',
timeoutAvailable: true,
timeoutPath: '/usr/bin/timeout',
};
mockDomainScannerService.getCapabilities.mockReturnValue(
mockCapabilities,
);
const result = await controller.scanCapabilities();
expect(result).toEqual(mockCapabilities);
expect(domainScannerService.getCapabilities).toHaveBeenCalledTimes(1);
});
});
describe('scanStatus', () => {
it('должен вернуть статус сканирования', async () => {
const mockStatus = {
running: false,
runId: null,
lastRunId: 'scan-123',
};
mockDomainScannerService.getScanStatus.mockReturnValue(mockStatus);
const result = await controller.scanStatus();
expect(result).toEqual(mockStatus);
expect(domainScannerService.getScanStatus).toHaveBeenCalledTimes(1);
});
});
describe('lastScanResult', () => {
it('должен вернуть последний результат сканирования', async () => {
const mockResult = {
runId: 'scan-123',
foundCount: 10,
domains: ['ya.ru', 'vk.com'],
};
mockDomainScannerService.getLastScanResult.mockReturnValue(mockResult);
const result = await controller.lastScanResult();
expect(result).toEqual(mockResult);
expect(domainScannerService.getLastScanResult).toHaveBeenCalledTimes(1);
});
});
describe('startScan', () => {
it('должен запустить сканирование', async () => {
const body = {
addr: '192.168.1.1',
scanSeconds: 60,
thread: 100,
timeout: 30,
};
const mockResult = { success: true, runId: 'scan-456' };
mockDomainScannerService.startScan.mockReturnValue(mockResult);
const result = await controller.startScan(body);
expect(result).toEqual(mockResult);
expect(domainScannerService.startScan).toHaveBeenCalledWith(body);
});
});
describe('findAllWithoutPagination', () => {
it('должен вернуть все домены без пагинации', async () => {
const mockDomains = [
{ id: 1, name: 'ya.ru' },
{ id: 2, name: 'vk.com' },
];
mockDomainsService.findAllUnpaginated.mockResolvedValue(mockDomains);
const result = await controller.findAllWithoutPagination();
expect(result).toEqual(mockDomains);
expect(domainsService.findAllUnpaginated).toHaveBeenCalledTimes(1);
});
});
describe('findAll', () => {
it('должен вернуть домены с пагинацией', async () => {
const mockResult = {
data: [{ id: 1, name: 'ya.ru' }],
total: 100,
};
mockDomainsService.findAll.mockResolvedValue(mockResult);
const result = await controller.findAll(1, 10);
expect(result).toEqual(mockResult);
expect(domainsService.findAll).toHaveBeenCalledWith(1, 10);
});
it('должен использовать значения по умолчанию для пагинации', async () => {
mockDomainsService.findAll.mockResolvedValue({ data: [], total: 0 });
await controller.findAll(undefined as any, undefined as any);
expect(domainsService.findAll).toHaveBeenCalledWith(1, 10);
});
});
describe('findOne', () => {
it('должен вернуть домен по ID', async () => {
const mockDomain = { id: 1, name: 'ya.ru' };
mockDomainsService.findOne.mockResolvedValue(mockDomain);
const result = await controller.findOne('1');
expect(result).toEqual(mockDomain);
expect(domainsService.findOne).toHaveBeenCalledWith(1);
});
});
describe('removeAll', () => {
it('должен удалить все домены', async () => {
const mockResult = { success: true };
mockDomainsService.removeAll.mockResolvedValue(mockResult);
const result = await controller.removeAll();
expect(result).toEqual(mockResult);
expect(domainsService.removeAll).toHaveBeenCalledTimes(1);
});
});
describe('remove', () => {
it('должен удалить домен по ID', async () => {
mockDomainsService.remove.mockResolvedValue(undefined);
await controller.remove('1');
expect(domainsService.remove).toHaveBeenCalledWith(1);
});
});
});
+309
View File
@@ -0,0 +1,309 @@
/* eslint-disable @typescript-eslint/unbound-method */
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { BadRequestException } from '@nestjs/common';
import { DomainsService } from 'src/domains/domains.service';
import { Domain } from 'src/domains/entities/domain.entity';
describe('DomainsService', () => {
let service: DomainsService;
let repo: Repository<Domain>;
const mockRepo = {
find: jest.fn(),
findOne: jest.fn(),
findOneBy: jest.fn(),
create: jest.fn(),
save: jest.fn(),
delete: jest.fn(),
clear: jest.fn(),
findAndCount: jest.fn(),
count: jest.fn(),
createQueryBuilder: jest.fn(() => ({
where: jest.fn().mockReturnThis(),
getOne: jest.fn(),
})),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
DomainsService,
{
provide: getRepositoryToken(Domain),
useValue: mockRepo,
},
],
}).compile();
service = module.get<DomainsService>(DomainsService);
repo = module.get<Repository<Domain>>(getRepositoryToken(Domain));
});
afterEach(() => {
jest.clearAllMocks();
});
describe('onModuleInit', () => {
it('должен создать домены по умолчанию, если база пустая', async () => {
mockRepo.count.mockResolvedValue(0);
mockRepo.create.mockReturnValue({ name: 'ya.ru' });
mockRepo.save.mockResolvedValue({});
await service.onModuleInit();
expect(repo.count).toHaveBeenCalledTimes(1);
expect(repo.create).toHaveBeenCalledTimes(10);
expect(repo.save).toHaveBeenCalled();
});
it('НЕ должен создавать домены, если они уже есть', async () => {
mockRepo.count.mockResolvedValue(5);
await service.onModuleInit();
expect(repo.count).toHaveBeenCalledTimes(1);
expect(repo.create).not.toHaveBeenCalled();
expect(repo.save).not.toHaveBeenCalled();
});
});
describe('create', () => {
it('должен создать домен', async () => {
const dto = { name: 'example.com' };
const normalized = 'example.com';
const created = { id: 1, name: normalized };
mockRepo.createQueryBuilder.mockReturnValue({
where: jest.fn().mockReturnThis(),
getOne: jest.fn().mockResolvedValue(null),
});
mockRepo.create.mockReturnValue(created);
mockRepo.save.mockResolvedValue(created);
const result = await service.create(dto);
expect(result).toEqual(created);
expect(repo.create).toHaveBeenCalledWith({ name: normalized });
});
it('должен вернуть существующий домен, если он уже есть', async () => {
const dto = { name: 'example.com' };
const existing = { id: 1, name: 'example.com' };
mockRepo.createQueryBuilder.mockReturnValue({
where: jest.fn().mockReturnThis(),
getOne: jest.fn().mockResolvedValue(existing),
});
const result = await service.create(dto);
expect(result).toEqual(existing);
expect(repo.create).not.toHaveBeenCalled();
expect(repo.save).not.toHaveBeenCalled();
});
it('должен бросить BadRequestException для некорректного домена', async () => {
const dto = { name: 'not-a-domain' };
await expect(service.create(dto)).rejects.toThrow(BadRequestException);
await expect(service.create(dto)).rejects.toThrow(
'Некорректное доменное имя',
);
});
it('должен нормализовать домен с протоколом и путём', async () => {
const dto = { name: 'https://example.com/path/to/page' };
const normalized = 'example.com';
mockRepo.createQueryBuilder.mockReturnValue({
where: jest.fn().mockReturnThis(),
getOne: jest.fn().mockResolvedValue(null),
});
mockRepo.create.mockReturnValue({ name: normalized });
mockRepo.save.mockResolvedValue({ name: normalized });
await service.create(dto);
expect(repo.create).toHaveBeenCalledWith({ name: normalized });
});
it('должен нормализовать wildcard домен *.example.com → example.com', async () => {
const dto = { name: '*.example.com' };
const normalized = 'example.com';
mockRepo.createQueryBuilder.mockReturnValue({
where: jest.fn().mockReturnThis(),
getOne: jest.fn().mockResolvedValue(null),
});
mockRepo.create.mockReturnValue({ name: normalized });
mockRepo.save.mockResolvedValue({ name: normalized });
await service.create(dto);
expect(repo.create).toHaveBeenCalledWith({ name: normalized });
});
it('должен игнорировать комментарии в домене', async () => {
const dto = { name: 'example.com # это комментарий' };
const normalized = 'example.com';
mockRepo.createQueryBuilder.mockReturnValue({
where: jest.fn().mockReturnThis(),
getOne: jest.fn().mockResolvedValue(null),
});
mockRepo.create.mockReturnValue({ name: normalized });
mockRepo.save.mockResolvedValue({ name: normalized });
await service.create(dto);
expect(repo.create).toHaveBeenCalledWith({ name: normalized });
});
});
describe('findAll', () => {
it('должен вернуть домены с пагинацией', async () => {
const mockDomains = [
{ id: 1, name: 'ya.ru' },
{ id: 2, name: 'vk.com' },
];
mockRepo.findAndCount.mockResolvedValue([mockDomains, 100]);
const result = await service.findAll(1, 10);
expect(result).toEqual({
data: mockDomains,
total: 100,
});
expect(repo.findAndCount).toHaveBeenCalledWith({
take: 10,
skip: 0,
order: { id: 'DESC' },
});
});
it('должен использовать значения по умолчанию для пагинации', async () => {
mockRepo.findAndCount.mockResolvedValue([[], 0]);
await service.findAll();
expect(repo.findAndCount).toHaveBeenCalledWith({
take: 10,
skip: 0,
order: { id: 'DESC' },
});
});
});
describe('findAllUnpaginated', () => {
it('должен вернуть все домены без пагинации', async () => {
const mockDomains = [
{ id: 1, name: 'ya.ru' },
{ id: 2, name: 'vk.com' },
];
mockRepo.find.mockResolvedValue(mockDomains);
const result = await service.findAllUnpaginated();
expect(result).toEqual(mockDomains);
expect(repo.find).toHaveBeenCalledWith({ order: { name: 'ASC' } });
});
});
describe('findOne', () => {
it('должен вернуть домен по ID', async () => {
const mockDomain = { id: 1, name: 'ya.ru' };
mockRepo.findOneBy.mockResolvedValue(mockDomain);
const result = await service.findOne(1);
expect(result).toEqual(mockDomain);
expect(repo.findOneBy).toHaveBeenCalledWith({ id: 1 });
});
});
describe('remove', () => {
it('должен удалить домен по ID', async () => {
mockRepo.delete.mockResolvedValue({ affected: 1 });
await service.remove(1);
expect(repo.delete).toHaveBeenCalledWith(1);
});
});
describe('removeAll', () => {
it('должен удалить все домены', async () => {
mockRepo.clear.mockResolvedValue(undefined);
const result = await service.removeAll();
expect(result).toEqual({ success: true });
expect(repo.clear).toHaveBeenCalledTimes(1);
});
});
describe('createMany', () => {
it('должен создать несколько доменов', async () => {
const domains = ['ya.ru', 'vk.com', 'ok.ru'];
mockRepo.find.mockResolvedValue([]);
mockRepo.create.mockReturnValue({ name: 'ya.ru' });
mockRepo.save.mockResolvedValue({});
const result = await service.createMany(domains);
expect(result.count).toBeGreaterThan(0);
expect(repo.save).toHaveBeenCalled();
});
it('должен пропустить существующие домены', async () => {
const domains = ['ya.ru', 'vk.com'];
const existing = [{ id: 1, name: 'ya.ru' }];
mockRepo.find.mockResolvedValue(existing);
const result = await service.createMany(domains);
expect(result.count).toBe(1); // Только vk.com будет создан
});
it('должен вернуть 0, если все домены уже существуют', async () => {
const domains = ['ya.ru', 'vk.com'];
const existing = [
{ id: 1, name: 'ya.ru' },
{ id: 2, name: 'vk.com' },
];
mockRepo.find.mockResolvedValue(existing);
const result = await service.createMany(domains);
expect(result.count).toBe(0);
});
it('должен вернуть 0 для пустого массива', async () => {
const result = await service.createMany([]);
expect(result.count).toBe(0);
expect(repo.find).not.toHaveBeenCalled();
});
it('должен нормализовать домены при массовом создании', async () => {
const domains = ['*.EXAMPLE.com', 'https://vk.com/path', ' ok.ru '];
mockRepo.find.mockResolvedValue([]);
mockRepo.create.mockReturnValue({ name: 'example.com' });
mockRepo.save.mockResolvedValue({});
await service.createMany(domains);
expect(repo.create).toHaveBeenCalled();
});
});
});
@@ -0,0 +1,537 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-argument */
import { Test, TestingModule } from '@nestjs/testing';
import { InboundBuilderService } from 'src/inbounds/inbound-builder.service';
// Mock crypto.randomBytes для детерминированных тестов
jest.mock('crypto', () => ({
randomBytes: jest.fn().mockReturnValue(Buffer.from('abcd1234', 'hex')),
randomFillSync: jest.fn((buffer: Buffer) => {
for (let i = 0; i < buffer.length; i++) {
buffer[i] = i;
}
return buffer;
}),
}));
describe('InboundBuilderService', () => {
let service: InboundBuilderService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [InboundBuilderService],
}).compile();
service = module.get<InboundBuilderService>(InboundBuilderService);
});
afterEach(() => {
jest.clearAllMocks();
});
describe('buildVlessRealityTcp', () => {
const params = {
port: 443,
uuid: 'test-uuid-123',
sni: 'ya.ru',
privateKey: 'private-key',
publicKey: 'public-key',
};
it('должен создать конфиг vless-tcp-reality', () => {
const result = service.buildVlessRealityTcp(params);
expect(result).toMatchObject({
enable: true,
port: 443,
protocol: 'vless',
remark: 'vless-tcp-reality',
});
const settings = JSON.parse(result.settings);
expect(settings.clients[0].id).toBe('test-uuid-123');
expect(settings.clients[0].flow).toBe('xtls-rprx-vision');
});
it('должен установить Reality настройки', () => {
const result = service.buildVlessRealityTcp(params);
const streamSettings = JSON.parse(result.streamSettings);
expect(streamSettings.security).toBe('reality');
expect(streamSettings.realitySettings.target).toBe('ya.ru:443');
expect(streamSettings.realitySettings.serverNames).toContain('ya.ru');
expect(streamSettings.realitySettings.privateKey).toBe('private-key');
});
it('должен сгенерировать shortIds', () => {
const result = service.buildVlessRealityTcp(params);
const streamSettings = JSON.parse(result.streamSettings);
expect(streamSettings.realitySettings.shortIds).toHaveLength(2);
});
});
describe('buildVlessRealityXhttp', () => {
const params = {
port: 8443,
uuid: 'test-uuid-456',
sni: 'vk.com',
privateKey: 'private-key',
publicKey: 'public-key',
};
it('должен создать конфиг vless-xhttp-reality', () => {
const result = service.buildVlessRealityXhttp(params);
expect(result).toMatchObject({
enable: true,
port: 8443,
protocol: 'vless',
remark: 'vless-xhttp-reality',
});
const settings = JSON.parse(result.settings);
expect(settings.clients[0].id).toBe('test-uuid-456');
expect(settings.clients[0].flow).toBe('');
});
it('должен установить xhttp настройки', () => {
const result = service.buildVlessRealityXhttp(params);
const streamSettings = JSON.parse(result.streamSettings);
expect(streamSettings.security).toBe('reality');
expect(streamSettings.network).toBe('xhttp');
});
});
describe('buildVlessRealityGrpc', () => {
const params = {
port: 2053,
uuid: 'test-uuid-789',
sni: 'ok.ru',
privateKey: 'private-key',
publicKey: 'public-key',
};
it('должен создать конфиг vless-grpc-reality', () => {
const result = service.buildVlessRealityGrpc(params);
expect(result).toMatchObject({
enable: true,
port: 2053,
protocol: 'vless',
remark: 'vless-grpc-reality',
});
const streamSettings = JSON.parse(result.streamSettings);
expect(streamSettings.network).toBe('grpc');
expect(streamSettings.grpcSettings?.serviceName).toBeTruthy();
});
});
describe('buildVlessWs', () => {
const params = {
port: 10000,
uuid: 'test-uuid-ws',
sni: 'ozon.ru',
};
it('должен создать конфиг vless-ws', () => {
const result = service.buildVlessWs(params);
expect(result).toMatchObject({
enable: true,
port: 10000,
protocol: 'vless',
remark: 'vless-ws',
});
const streamSettings = JSON.parse(result.streamSettings);
expect(streamSettings.network).toBe('ws');
});
it('должен установить ws настройки', () => {
const result = service.buildVlessWs(params);
const streamSettings = JSON.parse(result.streamSettings);
expect(streamSettings.wsSettings?.path).toBe('/');
});
});
describe('buildVmessTcp', () => {
const params = {
port: 20000,
uuid: 'test-uuid-vmess',
};
it('должен создать конфиг vmess-tcp', () => {
const result = service.buildVmessTcp(params);
expect(result).toMatchObject({
enable: true,
port: 20000,
protocol: 'vmess',
remark: 'vmess-tcp',
});
const settings = JSON.parse(result.settings);
expect(settings.clients[0].id).toBe('test-uuid-vmess');
});
});
describe('buildShadowsocksTcp', () => {
const params = {
port: 30000,
uuid: 'test-uuid-ss',
};
it('должен создать конфиг shadowsocks-tcp', () => {
const result = service.buildShadowsocksTcp(params);
expect(result).toMatchObject({
enable: true,
port: 30000,
protocol: 'shadowsocks',
remark: 'shadowsocks-tcp',
});
const settings = JSON.parse(result.settings);
expect(settings.method).toBe('2022-blake3-aes-256-gcm');
expect(settings.password).toBeTruthy(); // Генерируется из uuid
});
});
describe('buildTrojanRealityTcp', () => {
const params = {
port: 443,
uuid: 'test-uuid-trojan',
sni: 'ya.ru',
privateKey: 'private-key',
publicKey: 'public-key',
};
it('должен создать конфиг trojan-tcp-reality', () => {
const result = service.buildTrojanRealityTcp(params);
expect(result).toMatchObject({
enable: true,
port: 443,
protocol: 'trojan',
remark: 'trojan-tcp-reality',
});
const settings = JSON.parse(result.settings);
expect(settings.clients[0].password).toBeTruthy(); // Генерируется из uuid
});
it('должен установить Reality настройки для trojan', () => {
const result = service.buildTrojanRealityTcp(params);
const streamSettings = JSON.parse(result.streamSettings);
expect(streamSettings.security).toBe('reality');
expect(streamSettings.realitySettings.target).toBe('ya.ru:443');
});
});
describe('buildHysteria2Link', () => {
it('должен создать ссылку hysteria2', () => {
const result = service.buildHysteria2Link(
'192.168.1.1',
'ya.ru',
'%F0%9F%92%AF%20hysteria2',
);
expect(result).toContain('hy2://');
expect(result).toContain('192.168.1.1');
// SNI может быть IP, если конфиг не найден
});
it('должен создать ссылку hysteria2 с портом', () => {
const result = service.buildHysteria2Link(
'192.168.1.1',
'ya.ru',
'%F0%9F%92%AF%20hysteria2',
443,
);
expect(result).toContain(':443');
});
});
describe('buildInboundLink', () => {
const baseInbound = {
protocol: 'vless',
port: 443,
remark: 'vless-tcp-reality',
settings: JSON.stringify({
clients: [{ id: 'test-uuid', flow: 'xtls-rprx-vision' }],
}),
streamSettings: JSON.stringify({
network: 'tcp',
security: 'reality',
realitySettings: {
serverNames: ['ya.ru'],
publicKey: 'public-key',
},
}),
};
it('должен создать ссылку vless reality', () => {
const result = service.buildInboundLink(
baseInbound as any,
'192.168.1.1',
'test-uuid',
'%F0%9F%92%AF',
);
expect(result).toContain('vless://');
expect(result).toContain('192.168.1.1');
expect(result).toContain('443');
});
it('должен создать ссылку vless reality с xhttp', () => {
const inbound = {
protocol: 'vless',
port: 8443,
remark: 'vless-xhttp-reality',
settings: JSON.stringify({
clients: [{ id: 'test-uuid', flow: '' }],
}),
streamSettings: JSON.stringify({
network: 'xhttp',
security: 'reality',
realitySettings: {
serverNames: ['ya.ru'],
publicKey: 'public-key',
settings: { publicKey: 'pk', fingerprint: 'random' },
shortIds: ['abc123'],
},
xhttpSettings: {
path: '/path',
host: 'ya.ru',
mode: 'auto',
},
}),
};
const result = service.buildInboundLink(
inbound as any,
'192.168.1.1',
'test-uuid',
'%F0%9F%92%AF',
);
expect(result).toContain('vless://');
expect(result).toContain('type=xhttp');
expect(result).toContain('path=%2Fpath');
});
it('должен создать ссылку vless reality с grpc', () => {
const inbound = {
protocol: 'vless',
port: 8443,
remark: 'vless-grpc-reality',
settings: JSON.stringify({
clients: [{ id: 'test-uuid', flow: '' }],
}),
streamSettings: JSON.stringify({
network: 'grpc',
security: 'reality',
realitySettings: {
serverNames: ['ya.ru'],
publicKey: 'public-key',
settings: { publicKey: 'pk', fingerprint: 'random' },
shortIds: ['abc123'],
},
grpcSettings: {
serviceName: 'grpc-service',
authority: 'ya.ru',
},
}),
};
const result = service.buildInboundLink(
inbound as any,
'192.168.1.1',
'test-uuid',
'%F0%9F%92%AF',
);
expect(result).toContain('vless://');
expect(result).toContain('type=grpc');
expect(result).toContain('serviceName=grpc-service');
});
it('должен создать ссылку vless с ws', () => {
const inbound = {
protocol: 'vless',
port: 443,
remark: 'vless-ws',
settings: JSON.stringify({
clients: [{ id: 'test-uuid' }],
}),
streamSettings: JSON.stringify({
network: 'ws',
security: 'tls',
wsSettings: {
path: '/ws',
headers: { Host: 'example.com' },
},
}),
};
const result = service.buildInboundLink(
inbound as any,
'192.168.1.1',
'test-uuid',
'%F0%9F%92%AF',
);
expect(result).toContain('vless://');
expect(result).toContain('type=ws');
expect(result).toContain('path=%2Fws');
});
it('должен создать ссылку vmess', () => {
const vmessInbound = {
protocol: 'vmess',
port: 20000,
remark: 'vmess-tcp',
settings: JSON.stringify({
clients: [{ id: 'test-uuid' }],
}),
streamSettings: JSON.stringify({
network: 'tcp',
}),
};
const result = service.buildInboundLink(
vmessInbound as any,
'192.168.1.1',
'test-uuid',
'%F0%9F%92%AF',
);
expect(result).toContain('vmess://');
});
it('должен создать ссылку shadowsocks', () => {
const ssInbound = {
protocol: 'shadowsocks',
port: 30000,
remark: 'shadowsocks-tcp',
settings: JSON.stringify({
method: '2022-blake3-aes-256-gcm',
password: 'test-password',
clients: [{ password: 'client-pass' }],
}),
streamSettings: JSON.stringify({
network: 'tcp',
}),
};
const result = service.buildInboundLink(
ssInbound as any,
'192.168.1.1',
'',
'%F0%9F%92%AF',
);
expect(result).toContain('ss://');
});
it('должен создать ссылку trojan', () => {
const trojanInbound = {
protocol: 'trojan',
port: 443,
remark: 'trojan-reality',
settings: JSON.stringify({
clients: [{ password: 'trojan-pass' }],
}),
streamSettings: JSON.stringify({
network: 'tcp',
security: 'reality',
realitySettings: {
serverNames: ['ya.ru'],
publicKey: 'public-key',
settings: { publicKey: 'pk', fingerprint: 'random' },
shortIds: ['abc123'],
},
}),
};
const result = service.buildInboundLink(
trojanInbound as any,
'192.168.1.1',
'trojan-pass',
'%F0%9F%92%AF',
);
expect(result).toContain('trojan://');
expect(result).toContain('security=reality');
});
it('должен вернуть пустую строку для trojan без reality', () => {
const trojanInbound = {
protocol: 'trojan',
port: 443,
remark: 'trojan',
settings: JSON.stringify({
clients: [{ password: 'pass' }],
}),
streamSettings: JSON.stringify({
network: 'tcp',
security: 'none',
}),
};
const result = service.buildInboundLink(
trojanInbound as any,
'192.168.1.1',
'pass',
'%F0%9F%92%AF',
);
expect(result).toBe('');
});
it('должен вернуть пустую строку для vless без reality settings', () => {
const vlessInbound = {
protocol: 'vless',
port: 443,
remark: 'vless',
settings: JSON.stringify({
clients: [{ id: 'uuid' }],
}),
streamSettings: JSON.stringify({
network: 'tcp',
security: 'reality',
realitySettings: null,
}),
};
const result = service.buildInboundLink(
vlessInbound as any,
'192.168.1.1',
'uuid',
'%F0%9F%92%AF',
);
expect(result).toBe('');
});
});
describe('generateUuid', () => {
it('должен сгенерировать UUID', () => {
const uuid = service.generateUuid();
expect(uuid).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,
);
});
});
});
+8 -1
View File
@@ -5,5 +5,12 @@
"testRegex": ".e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
}
},
"transformIgnorePatterns": [
"node_modules/(?!(uuid)/)"
],
"moduleNameMapper": {
"^src/(.*)$": "<rootDir>/../src/$1"
},
"setupFilesAfterEnv": ["<rootDir>/setup.ts"]
}
View File
@@ -0,0 +1,87 @@
/* eslint-disable @typescript-eslint/unbound-method */
import { Test, TestingModule } from '@nestjs/testing';
import { RotationController } from 'src/rotation/rotation.controller';
import { RotationService } from 'src/rotation/rotation.service';
describe('RotationController', () => {
let controller: RotationController;
let rotationService: RotationService;
const mockRotationService = {
performRotation: jest.fn(),
rotateSingleSubscription: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [RotationController],
providers: [
{
provide: RotationService,
useValue: mockRotationService,
},
],
}).compile();
controller = module.get<RotationController>(RotationController);
rotationService = module.get<RotationService>(RotationService);
});
afterEach(() => {
jest.clearAllMocks();
});
describe('rotateAll', () => {
it('должен запустить плановую ротацию', async () => {
const mockResult = { success: true, message: 'Ротация выполнена' };
mockRotationService.performRotation.mockResolvedValue(mockResult);
const result = await controller.rotateAll();
expect(result).toEqual(mockResult);
expect(rotationService.performRotation).toHaveBeenCalledTimes(1);
});
it('должен вернуть ошибку ротации', async () => {
const mockError = { success: false, message: 'Нет подписок' };
mockRotationService.performRotation.mockResolvedValue(mockError);
const result = await controller.rotateAll();
expect(result).toEqual(mockError);
});
});
describe('rotateSingle', () => {
it('должен запустить ротацию одной подписки', async () => {
const mockResult = {
success: true,
message: 'Ротация подписки выполнена',
};
mockRotationService.rotateSingleSubscription.mockResolvedValue(
mockResult,
);
const result = await controller.rotateSingle('sub-123');
expect(result).toEqual(mockResult);
expect(rotationService.rotateSingleSubscription).toHaveBeenCalledWith(
'sub-123',
);
});
it('должен вернуть ошибку ротации одной подписки', async () => {
const mockError = { success: false, message: 'Подписка не найдена' };
mockRotationService.rotateSingleSubscription.mockResolvedValue(mockError);
const result = await controller.rotateSingle('non-existent');
expect(result).toEqual(mockError);
});
});
});
@@ -0,0 +1,550 @@
/* eslint-disable @typescript-eslint/unbound-method */
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { RotationService } from 'src/rotation/rotation.service';
import { Subscription } from 'src/subscriptions/entities/subscription.entity';
import { Inbound } from 'src/inbounds/entities/inbound.entity';
import { Domain } from 'src/domains/entities/domain.entity';
import { Setting } from 'src/settings/entities/setting.entity';
import { XuiService } from 'src/xui/xui.service';
import { InboundBuilderService } from 'src/inbounds/inbound-builder.service';
// Mock @nestjs/schedule для тестирования Cron
jest.mock('@nestjs/schedule', () => ({
Cron: () => () => {},
CronExpression: { EVERY_MINUTE: '* * * * *' },
}));
describe('RotationService', () => {
let service: RotationService;
let _subRepo: Repository<Subscription>;
let inboundRepo: Repository<Inbound>;
let _domainRepo: Repository<Domain>;
let settingRepo: Repository<Setting>;
let xuiService: XuiService;
let inboundBuilder: InboundBuilderService;
const mockSubRepo = {
find: jest.fn(),
findOne: jest.fn(),
create: jest.fn(),
save: jest.fn(),
delete: jest.fn(),
createQueryBuilder: jest.fn(() => ({
where: jest.fn().mockReturnThis(),
getOne: jest.fn(),
})),
};
const mockInboundRepo = {
find: jest.fn(),
findOne: jest.fn(),
create: jest.fn(),
save: jest.fn(),
delete: jest.fn(),
};
const mockDomainRepo = {
find: jest.fn(),
findOne: jest.fn(),
create: jest.fn(),
save: jest.fn(),
};
const mockSettingRepo = {
find: jest.fn(),
findOne: jest.fn(),
create: jest.fn(),
save: jest.fn(),
};
const mockXuiService = {
login: jest.fn(),
deleteInbound: jest.fn(),
addInbound: jest.fn(),
getNewX25519Cert: jest.fn(),
};
const mockInboundBuilder = {
buildVlessRealityTcp: jest.fn(),
buildVlessRealityXhttp: jest.fn(),
buildVlessRealityGrpc: jest.fn(),
buildVlessWs: jest.fn(),
buildVmessTcp: jest.fn(),
buildShadowsocksTcp: jest.fn(),
buildTrojanRealityTcp: jest.fn(),
buildHysteria2Link: jest.fn(),
buildInboundLink: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
RotationService,
{
provide: getRepositoryToken(Subscription),
useValue: mockSubRepo,
},
{
provide: getRepositoryToken(Inbound),
useValue: mockInboundRepo,
},
{
provide: getRepositoryToken(Domain),
useValue: mockDomainRepo,
},
{
provide: getRepositoryToken(Setting),
useValue: mockSettingRepo,
},
{
provide: XuiService,
useValue: mockXuiService,
},
{
provide: InboundBuilderService,
useValue: mockInboundBuilder,
},
],
}).compile();
service = module.get<RotationService>(RotationService);
_subRepo = module.get<Repository<Subscription>>(
getRepositoryToken(Subscription),
);
inboundRepo = module.get<Repository<Inbound>>(getRepositoryToken(Inbound));
_domainRepo = module.get<Repository<Domain>>(getRepositoryToken(Domain));
settingRepo = module.get<Repository<Setting>>(getRepositoryToken(Setting));
xuiService = module.get<XuiService>(XuiService);
inboundBuilder = module.get<InboundBuilderService>(InboundBuilderService);
});
afterEach(() => {
jest.clearAllMocks();
});
describe('onModuleInit', () => {
it('должен инициализировать настройки по умолчанию', async () => {
mockSettingRepo.findOne.mockResolvedValue(null);
mockSettingRepo.create.mockReturnValue({ key: 'test', value: 'test' });
mockSettingRepo.save.mockResolvedValue({});
await service.onModuleInit();
expect(settingRepo.findOne).toHaveBeenCalled();
expect(settingRepo.save).toHaveBeenCalled();
});
it('НЕ должен создавать настройки, если они уже есть', async () => {
mockSettingRepo.findOne.mockResolvedValue({
key: 'rotation_status',
value: 'active',
});
await service.onModuleInit();
expect(settingRepo.create).not.toHaveBeenCalled();
});
});
describe('handleTicker', () => {
it('НЕ должен запускать ротацию, если прошло мало времени', async () => {
mockSettingRepo.findOne
.mockResolvedValueOnce({ key: 'rotation_interval', value: '30' })
.mockResolvedValueOnce({
key: 'last_rotation_timestamp',
value: Date.now().toString(),
})
.mockResolvedValueOnce({ key: 'rotation_status', value: 'active' });
await (service as any).handleTicker();
expect(xuiService.login).not.toHaveBeenCalled();
});
it('НЕ должен запускать ротацию, если статус stopped', async () => {
mockSettingRepo.findOne
.mockResolvedValueOnce({ key: 'rotation_interval', value: '30' })
.mockResolvedValueOnce({ key: 'last_rotation_timestamp', value: '0' })
.mockResolvedValueOnce({ key: 'rotation_status', value: 'stopped' });
await (service as any).handleTicker();
expect(xuiService.login).not.toHaveBeenCalled();
});
it('должен запустить ротацию, если пришло время', async () => {
mockSettingRepo.findOne
.mockResolvedValueOnce({ key: 'rotation_interval', value: '1' })
.mockResolvedValueOnce({ key: 'last_rotation_timestamp', value: '0' })
.mockResolvedValueOnce({ key: 'rotation_status', value: 'active' });
mockSubRepo.find.mockResolvedValue([
{
id: '1',
uuid: 'uuid-1',
isEnabled: true,
isAutoRotationEnabled: true,
inbounds: [],
inboundsConfig: [],
},
]);
mockDomainRepo.find.mockResolvedValue([]); // Пустой список доменов
await (service as any).handleTicker();
// Ротация запустится, но завершится с ошибкой (пустые домены)
expect(xuiService.login).toHaveBeenCalled();
});
});
describe('performRotation', () => {
it('должен вернуть ошибку, если не удалось войти в 3x-ui', async () => {
mockXuiService.login.mockResolvedValue(false);
const result = await service.performRotation();
expect(result).toEqual({
success: false,
message: 'Не удалось войти в панель 3x-ui',
});
});
it('должен вернуть ошибку, если нет активных подписок', async () => {
mockXuiService.login.mockResolvedValue(true);
mockSubRepo.find.mockResolvedValue([]);
const result = await service.performRotation();
expect(result).toEqual({
success: false,
message: 'Нет активных подписок для ротации',
});
});
it('должен вернуть ошибку, если список доменов пуст', async () => {
mockXuiService.login.mockResolvedValue(true);
mockSubRepo.find.mockResolvedValue([
{
id: '1',
uuid: 'uuid-1',
isEnabled: true,
isAutoRotationEnabled: true,
inbounds: [],
},
]);
mockDomainRepo.find.mockResolvedValue([]);
const result = await service.performRotation();
expect(result).toEqual({
success: false,
message: 'Список доменов пуст!',
});
});
it('должен выполнить ротацию подписок', async () => {
mockXuiService.login.mockResolvedValue(true);
mockSubRepo.find.mockResolvedValue([
{
id: '1',
uuid: 'uuid-1',
isEnabled: true,
isAutoRotationEnabled: true,
inbounds: [],
inboundsConfig: [
{ type: 'vless-tcp-reality', port: 443, sni: 'ya.ru' },
],
},
]);
mockDomainRepo.find.mockResolvedValue([
{ id: 1, name: 'ya.ru', isEnabled: true },
]);
mockXuiService.getNewX25519Cert.mockResolvedValue({
privateKey: 'key',
publicKey: 'pub',
});
mockInboundBuilder.buildVlessRealityTcp.mockReturnValue({
protocol: 'vless',
remark: 'test',
settings: '{}',
streamSettings: '{}',
sniffing: '{}',
});
mockXuiService.addInbound.mockResolvedValue(101);
mockInboundBuilder.buildInboundLink.mockReturnValue('vless://link');
const result = await service.performRotation();
expect(result).toEqual({
success: true,
message: 'Ротация успешно выполнена',
});
});
});
describe('rotateSingleSubscription', () => {
it('должен вернуть ошибку, если подписка не найдена', async () => {
mockSubRepo.findOne.mockResolvedValue(null);
const result = await service.rotateSingleSubscription('non-existent');
expect(result).toEqual({
success: false,
message: 'Подписка не найдена',
});
});
it('должен вернуть ошибку, если не удалось войти в 3x-ui', async () => {
mockSubRepo.findOne.mockResolvedValue({
id: '1',
uuid: 'uuid-1',
isEnabled: true,
inbounds: [],
inboundsConfig: [],
});
mockXuiService.login.mockResolvedValue(false);
const result = await service.rotateSingleSubscription('1');
expect(result).toEqual({
success: false,
message: 'Не удалось войти в панель 3x-ui',
});
});
it('должен вернуть ошибку, если список доменов пуст', async () => {
mockSubRepo.findOne.mockResolvedValue({
id: '1',
uuid: 'uuid-1',
isEnabled: true,
inbounds: [],
inboundsConfig: [],
});
mockXuiService.login.mockResolvedValue(true);
mockDomainRepo.find.mockResolvedValue([]);
const result = await service.rotateSingleSubscription('1');
expect(result).toEqual({
success: false,
message: 'Список доменов пуст!',
});
});
it('должен выполнить ротацию одной подписки', async () => {
const mockSub = {
id: '1',
uuid: 'uuid-1',
isEnabled: true,
inbounds: [],
inboundsConfig: [
{ type: 'vless-tcp-reality', port: 443, sni: 'ya.ru' },
],
};
mockSubRepo.findOne.mockResolvedValue(mockSub);
mockXuiService.login.mockResolvedValue(true);
mockDomainRepo.find.mockResolvedValue([
{ id: 1, name: 'ya.ru', isEnabled: true },
]);
mockXuiService.getNewX25519Cert.mockResolvedValue({
privateKey: 'key',
publicKey: 'pub',
});
mockInboundBuilder.buildVlessRealityTcp.mockReturnValue({
protocol: 'vless',
remark: 'test',
settings: '{}',
streamSettings: '{}',
sniffing: '{}',
});
mockXuiService.addInbound.mockResolvedValue(101);
mockInboundBuilder.buildInboundLink.mockReturnValue('vless://link');
const result = await service.rotateSingleSubscription('1');
expect(result).toEqual({
success: true,
message: 'Ротация успешно выполнена',
});
});
it('должен пропустить подписку, если не удалось получить Reality ключи', async () => {
const mockSub = {
id: '1',
uuid: 'uuid-1',
isEnabled: true,
inbounds: [],
inboundsConfig: [
{ type: 'vless-tcp-reality', port: 443, sni: 'ya.ru' },
],
};
mockSubRepo.findOne.mockResolvedValue(mockSub);
mockXuiService.login.mockResolvedValue(true);
mockDomainRepo.find.mockResolvedValue([
{ id: 1, name: 'ya.ru', isEnabled: true },
]);
mockXuiService.getNewX25519Cert.mockResolvedValue(null);
// Метод не возвращает результат явно, просто логирует ошибку
await service.rotateSingleSubscription('1');
expect(xuiService.getNewX25519Cert).toHaveBeenCalled();
});
});
describe('rotateSubscription (private)', () => {
it('должен удалить старые инбаунды перед ротацией', async () => {
const mockInbound = {
id: 'inb-1',
xuiId: 101,
port: 443,
protocol: 'vless',
remark: 'test',
link: 'link',
subscription: null as any,
createdAt: new Date(),
updatedAt: new Date(),
};
const mockSub = {
id: '1',
uuid: 'uuid-1',
isEnabled: true,
inbounds: [mockInbound],
inboundsConfig: [],
};
mockInbound.subscription = mockSub;
mockDomainRepo.find.mockResolvedValue([
{ id: 1, name: 'ya.ru', isEnabled: true },
]);
mockXuiService.getNewX25519Cert.mockResolvedValue({
privateKey: 'key',
publicKey: 'pub',
});
await (service as any).rotateSubscription(mockSub, [
{ id: 1, name: 'ya.ru', isEnabled: true },
]);
expect(xuiService.deleteInbound).toHaveBeenCalledWith(101);
expect(inboundRepo.delete).toHaveBeenCalled();
});
it('должен обработать custom инбаунд без 3x-ui', async () => {
const mockSub = {
id: '1',
uuid: 'uuid-1',
isEnabled: true,
inbounds: [],
inboundsConfig: [{ type: 'custom', link: 'custom://link' }],
};
mockDomainRepo.find.mockResolvedValue([
{ id: 1, name: 'ya.ru', isEnabled: true },
]);
mockInboundRepo.save.mockResolvedValue({});
await (service as any).rotateSubscription(mockSub, [
{ id: 1, name: 'ya.ru', isEnabled: true },
]);
expect(xuiService.addInbound).not.toHaveBeenCalled();
expect(inboundRepo.save).toHaveBeenCalled();
});
it('должен обработать hysteria2-udp инбаунд', async () => {
const mockSub = {
id: '1',
uuid: 'uuid-1',
isEnabled: true,
inbounds: [],
inboundsConfig: [{ type: 'hysteria2-udp', sni: 'ya.ru' }],
};
mockDomainRepo.find.mockResolvedValue([
{ id: 1, name: 'ya.ru', isEnabled: true },
]);
mockInboundBuilder.buildHysteria2Link.mockReturnValue('hy2://link');
mockInboundRepo.save.mockResolvedValue({});
await (service as any).rotateSubscription(mockSub, [
{ id: 1, name: 'ya.ru', isEnabled: true },
]);
expect(xuiService.addInbound).not.toHaveBeenCalled();
expect(inboundBuilder.buildHysteria2Link).toHaveBeenCalled();
});
it('должен использовать случайный порт, если указано random', async () => {
const mockSub = {
id: '1',
uuid: 'uuid-1',
isEnabled: true,
inbounds: [],
inboundsConfig: [
{ type: 'vless-tcp-reality', port: 'random', sni: 'ya.ru' },
],
};
mockDomainRepo.find.mockResolvedValue([
{ id: 1, name: 'ya.ru', isEnabled: true },
]);
mockInboundRepo.findOne.mockResolvedValue(null); // Порт свободен
mockXuiService.getNewX25519Cert.mockResolvedValue({
privateKey: 'key',
publicKey: 'pub',
});
mockInboundBuilder.buildVlessRealityTcp.mockReturnValue({
protocol: 'vless',
remark: 'test',
settings: '{"clients":[{"id":"uuid"}]}',
streamSettings: '{}',
sniffing: '{}',
});
mockXuiService.addInbound.mockResolvedValue(101);
mockInboundBuilder.buildInboundLink.mockReturnValue('vless://link');
mockInboundRepo.save.mockResolvedValue({});
await (service as any).rotateSubscription(mockSub, [
{ id: 1, name: 'ya.ru', isEnabled: true },
]);
expect(inboundRepo.findOne).toHaveBeenCalled();
});
it('должен обработать неизвестный тип инбаунда', async () => {
const mockSub = {
id: '1',
uuid: 'uuid-1',
isEnabled: true,
inbounds: [],
inboundsConfig: [{ type: 'unknown-protocol', port: 443, sni: 'ya.ru' }],
};
mockDomainRepo.find.mockResolvedValue([
{ id: 1, name: 'ya.ru', isEnabled: true },
]);
mockXuiService.getNewX25519Cert.mockResolvedValue({
privateKey: 'key',
publicKey: 'pub',
});
await (service as any).rotateSubscription(mockSub, [
{ id: 1, name: 'ya.ru', isEnabled: true },
]);
expect(xuiService.addInbound).not.toHaveBeenCalled();
});
});
});
@@ -0,0 +1,72 @@
import { Test, TestingModule } from '@nestjs/testing';
import { SessionService } from 'src/session/session.service';
describe('SessionService', () => {
let service: SessionService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [SessionService],
}).compile();
service = module.get<SessionService>(SessionService);
});
afterEach(() => {
jest.clearAllMocks();
});
describe('setFromHeaders', () => {
it('должен установить cookie из заголовка (массив)', () => {
const cookieHeader = ['session=abc123; Path=/; HttpOnly'];
service.setFromHeaders(cookieHeader);
const cookie = service.getCookie();
expect(cookie).toBe('session=abc123');
});
it('должен установить cookie без атрибутов', () => {
const cookieHeader = ['session=xyz789'];
service.setFromHeaders(cookieHeader);
const cookie = service.getCookie();
expect(cookie).toBe('session=xyz789');
});
it('должен обработать несколько cookie', () => {
const cookieHeader = ['session=abc; Path=/', 'other=xyz'];
service.setFromHeaders(cookieHeader);
const cookie = service.getCookie();
expect(cookie).toContain('session=abc');
});
});
describe('getCookie', () => {
it('должен вернуть null, если cookie не установлен', () => {
const cookie = service.getCookie();
expect(cookie).toBeNull();
});
it('должен вернуть установленный cookie', () => {
service.setFromHeaders(['session=test123']);
const cookie = service.getCookie();
expect(cookie).toBe('session=test123');
});
});
describe('clear', () => {
it('должен очистить cookie', () => {
service.setFromHeaders(['session=test123']);
service.clear();
const cookie = service.getCookie();
expect(cookie).toBeNull();
});
});
});
@@ -0,0 +1,222 @@
/* eslint-disable @typescript-eslint/unbound-method */
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { SettingsController } from 'src/settings/settings.controller';
import { Setting } from 'src/settings/entities/setting.entity';
import { XuiService } from 'src/xui/xui.service';
describe('SettingsController', () => {
let controller: SettingsController;
let settingsRepo: Repository<Setting>;
let xuiService: XuiService;
const mockSettingsRepo = {
find: jest.fn(),
findOne: jest.fn(),
create: jest.fn(),
save: jest.fn(),
};
const mockXuiService = {
checkConnection: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [SettingsController],
providers: [
{
provide: getRepositoryToken(Setting),
useValue: mockSettingsRepo,
},
{
provide: XuiService,
useValue: mockXuiService,
},
],
}).compile();
controller = module.get<SettingsController>(SettingsController);
settingsRepo = module.get<Repository<Setting>>(getRepositoryToken(Setting));
xuiService = module.get<XuiService>(XuiService);
});
afterEach(() => {
jest.clearAllMocks();
});
describe('findAll', () => {
it('должен вернуть все настройки как объект', async () => {
const mockSettings = [
{ key: 'xui_url', value: 'http://localhost:3000' },
{ key: 'xui_login', value: 'admin' },
{ key: 'xui_password', value: 'password' },
];
mockSettingsRepo.find.mockResolvedValue(mockSettings);
const result = await controller.findAll();
expect(result).toEqual({
xui_url: 'http://localhost:3000',
xui_login: 'admin',
xui_password: 'password',
});
expect(settingsRepo.find).toHaveBeenCalledTimes(1);
});
it('должен вернуть пустой объект, если настроек нет', async () => {
mockSettingsRepo.find.mockResolvedValue([]);
const result = await controller.findAll();
expect(result).toEqual({});
});
});
describe('checkConnection', () => {
it('должен проверить подключение к 3x-ui', async () => {
const body = {
xui_url: 'http://localhost:3000',
xui_login: 'admin',
xui_password: 'password',
};
mockXuiService.checkConnection.mockResolvedValue(true);
const result = await controller.checkConnection(body);
expect(result).toEqual({ success: true });
expect(xuiService.checkConnection).toHaveBeenCalledWith(
body.xui_url,
body.xui_login,
body.xui_password,
);
});
it('должен вернуть false при неудачном подключении', async () => {
const body = {
xui_url: 'http://localhost:3000',
xui_login: 'admin',
xui_password: 'wrong',
};
mockXuiService.checkConnection.mockResolvedValue(false);
const result = await controller.checkConnection(body);
expect(result).toEqual({ success: false });
});
});
describe('update', () => {
it('должен сохранить настройки без xui_url', async () => {
const settings = {
xui_login: 'newAdmin',
xui_password: 'newPassword',
};
mockSettingsRepo.save.mockResolvedValue({});
const result = await controller.update(settings);
expect(result).toEqual({ success: true });
expect(settingsRepo.save).toHaveBeenCalledTimes(2);
});
it('должен извлечь host из xui_url и определить IP', async () => {
const settings = {
xui_url: 'http://example.com:8080',
};
mockSettingsRepo.save.mockResolvedValue({});
const result = await controller.update(settings);
expect(result).toEqual({ success: true });
expect(settingsRepo.save).toHaveBeenCalled();
});
it('должен определить страну по IP через GeoIP', async () => {
const settings = {
xui_url: 'http://8.8.8.8:8080',
};
mockSettingsRepo.save.mockResolvedValue({});
// Mock для fetch (GeoIP API)
global.fetch = jest.fn().mockResolvedValue({
json: jest.fn().mockResolvedValue({
status: 'success',
countryCode: 'US',
country: 'United States',
}),
});
const result = await controller.update(settings);
expect(result).toEqual({ success: true });
expect(settingsRepo.save).toHaveBeenCalled();
});
it('должен обработать ошибку GeoIP', async () => {
const settings = {
xui_url: 'http://8.8.8.8:8080',
};
mockSettingsRepo.save.mockResolvedValue({});
global.fetch = jest.fn().mockResolvedValue({
json: jest.fn().mockResolvedValue({
status: 'fail',
message: 'Reserved IP',
}),
});
const result = await controller.update(settings);
expect(result).toEqual({ success: true });
});
it('должен обработать ошибку при некорректном URL', async () => {
const settings = {
xui_url: 'not-a-valid-url',
};
mockSettingsRepo.save.mockResolvedValue({});
const result = await controller.update(settings);
expect(result).toEqual({ success: true });
expect(settingsRepo.save).toHaveBeenCalled();
});
it('должен использовать localhost без GeoIP запроса', async () => {
const settings = {
xui_url: 'http://localhost:8080',
};
mockSettingsRepo.save.mockResolvedValue({});
const result = await controller.update(settings);
expect(result).toEqual({ success: true });
expect(settingsRepo.save).toHaveBeenCalled();
});
it('должен использовать 127.0.0.1 без GeoIP запроса', async () => {
const settings = {
xui_url: 'http://127.0.0.1:8080',
};
mockSettingsRepo.save.mockResolvedValue({});
const result = await controller.update(settings);
expect(result).toEqual({ success: true });
expect(settingsRepo.save).toHaveBeenCalled();
});
});
});
+4
View File
@@ -0,0 +1,4 @@
import * as dotenv from 'dotenv';
import * as path from 'path';
dotenv.config({ path: path.join(__dirname, '../.env.test') });
@@ -0,0 +1,191 @@
/* eslint-disable @typescript-eslint/unbound-method */
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import { Test, TestingModule } from '@nestjs/testing';
import { SubscriptionsController } from 'src/subscriptions/subscriptions.controller';
import { SubscriptionsService } from 'src/subscriptions/subscriptions.service';
import { NotFoundException } from '@nestjs/common';
import { CreateSubscriptionDto } from 'src/subscriptions/dto/create-subscription.dto';
import { UpdateSubscriptionDto } from 'src/subscriptions/dto/update-subscription.dto';
describe('SubscriptionsController', () => {
let controller: SubscriptionsController;
let service: SubscriptionsService;
const mockSubscriptionsService = {
findAll: jest.fn(),
create: jest.fn(),
update: jest.fn(),
remove: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [SubscriptionsController],
providers: [
{
provide: SubscriptionsService,
useValue: mockSubscriptionsService,
},
],
}).compile();
controller = module.get<SubscriptionsController>(SubscriptionsController);
service = module.get<SubscriptionsService>(SubscriptionsService);
});
afterEach(() => {
jest.clearAllMocks();
});
describe('findAll', () => {
it('должен вернуть все подписки', async () => {
const mockSubs = [
{ id: '1', name: 'Sub 1', uuid: 'uuid-1' },
{ id: '2', name: 'Sub 2', uuid: 'uuid-2' },
];
mockSubscriptionsService.findAll.mockResolvedValue(mockSubs);
const result = await controller.findAll();
expect(result).toEqual(mockSubs);
expect(service.findAll).toHaveBeenCalledTimes(1);
});
});
describe('create', () => {
const createDto: CreateSubscriptionDto = {
name: 'Test Subscription',
inboundsConfig: [
{ type: 'vless-tcp-reality', port: 443, sni: 'example.com' },
],
isAutoRotationEnabled: true,
};
it('должен создать подписку', async () => {
const mockSubscription = { id: 'new-id', ...createDto };
mockSubscriptionsService.create.mockResolvedValue(mockSubscription);
const result = await controller.create(createDto);
expect(result).toEqual(mockSubscription);
expect(service.create).toHaveBeenCalledWith(createDto);
});
});
describe('bulkUpdateAutoRotation', () => {
it('должен вернуть ошибку, если subscriptionIds не массив', async () => {
const result = await controller.bulkUpdateAutoRotation({
subscriptionIds: 'not-array' as any,
enabled: true,
});
expect(result).toEqual({
success: false,
message: 'subscriptionIds должен быть массивом',
});
});
it('должен вернуть ошибку, если больше 100 ID', async () => {
const result = await controller.bulkUpdateAutoRotation({
subscriptionIds: Array(101).fill('id'),
enabled: true,
});
expect(result).toEqual({
success: false,
message: 'Максимум 100 ID за раз',
});
});
it('должен обновить подписки и вернуть отчёт', async () => {
const ids = ['id1', 'id2', 'id3'];
mockSubscriptionsService.update
.mockResolvedValueOnce({ id: 'id1' })
.mockResolvedValueOnce(null)
.mockResolvedValueOnce({ id: 'id3' });
const result = await controller.bulkUpdateAutoRotation({
subscriptionIds: ids,
enabled: true,
});
expect(result).toEqual({
success: true,
message: 'Обновлено 2 подписок',
updatedCount: 2,
notFound: ['id2'],
});
});
it('должен вернуть пустой notFound, если все обновлены успешно', async () => {
const ids = ['id1', 'id2'];
mockSubscriptionsService.update.mockResolvedValue({ id: 'updated' });
const result = await controller.bulkUpdateAutoRotation({
subscriptionIds: ids,
enabled: false,
});
expect(result).toEqual({
success: true,
message: 'Обновлено 2 подписок',
updatedCount: 2,
notFound: undefined,
});
});
it('должен обработать пустой массив', async () => {
const result = await controller.bulkUpdateAutoRotation({
subscriptionIds: [],
enabled: true,
});
expect(result).toEqual({
success: true,
message: 'Обновлено 0 подписок',
updatedCount: 0,
notFound: undefined,
});
});
});
describe('update', () => {
const updateDto: UpdateSubscriptionDto = {
name: 'Updated Name',
isAutoRotationEnabled: false,
};
it('должен обновить подписку', async () => {
const mockSub = { id: 'id1', ...updateDto };
mockSubscriptionsService.update.mockResolvedValue(mockSub);
const result = await controller.update('id1', updateDto);
expect(result).toEqual(mockSub);
expect(service.update).toHaveBeenCalledWith('id1', updateDto);
});
it('должен бросить NotFoundException, если подписка не найдена', async () => {
mockSubscriptionsService.update.mockResolvedValue(null);
await expect(
controller.update('non-existent', updateDto),
).rejects.toThrow(NotFoundException);
await expect(
controller.update('non-existent', updateDto),
).rejects.toThrow('Подписка non-existent не найдена');
});
});
describe('remove', () => {
it('должен удалить подписку', async () => {
mockSubscriptionsService.remove.mockResolvedValue(undefined);
await controller.remove('id1');
expect(service.remove).toHaveBeenCalledWith('id1');
});
});
});
@@ -0,0 +1,362 @@
/* eslint-disable @typescript-eslint/unbound-method */
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { SubscriptionsService } from 'src/subscriptions/subscriptions.service';
import { Subscription } from 'src/subscriptions/entities/subscription.entity';
import { XuiService } from 'src/xui/xui.service';
import { CreateSubscriptionDto } from 'src/subscriptions/dto/create-subscription.dto';
describe('SubscriptionsService', () => {
let service: SubscriptionsService;
let subRepo: Repository<Subscription>;
let xuiService: XuiService;
const mockSubRepo = {
find: jest.fn(),
findOne: jest.fn(),
create: jest.fn(),
save: jest.fn(),
delete: jest.fn(),
remove: jest.fn(),
};
const mockXuiService = {
deleteInbound: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
SubscriptionsService,
{
provide: getRepositoryToken(Subscription),
useValue: mockSubRepo,
},
{
provide: XuiService,
useValue: mockXuiService,
},
],
}).compile();
service = module.get<SubscriptionsService>(SubscriptionsService);
subRepo = module.get<Repository<Subscription>>(
getRepositoryToken(Subscription),
);
xuiService = module.get<XuiService>(XuiService);
});
afterEach(() => {
jest.clearAllMocks();
});
describe('findAll', () => {
it('должен вернуть все подписки с relations и сортировкой', async () => {
const mockSubs: Subscription[] = [
{
id: '1',
name: 'Sub 1',
uuid: 'uuid-1',
isEnabled: true,
isAutoRotationEnabled: true,
inboundsConfig: [],
inbounds: [],
createdAt: new Date(),
updatedAt: new Date(),
},
{
id: '2',
name: 'Sub 2',
uuid: 'uuid-2',
isEnabled: true,
isAutoRotationEnabled: false,
inboundsConfig: [],
inbounds: [],
createdAt: new Date(),
updatedAt: new Date(),
},
];
mockSubRepo.find.mockResolvedValue(mockSubs);
const result = await service.findAll();
expect(result).toEqual(mockSubs);
expect(subRepo.find).toHaveBeenCalledWith({
relations: ['inbounds'],
order: { createdAt: 'DESC' },
});
});
it('должен вернуть пустой массив, если подписок нет', async () => {
mockSubRepo.find.mockResolvedValue([]);
const result = await service.findAll();
expect(result).toEqual([]);
});
});
describe('create', () => {
const createDto: CreateSubscriptionDto = {
name: 'Test Subscription',
inboundsConfig: [
{ type: 'vless-tcp-reality', port: 443, sni: 'example.com' },
],
isAutoRotationEnabled: true,
};
it('должен создать подписку с UUID и настройками по умолчанию', async () => {
const mockSubscription = {
...createDto,
id: 'test-id',
uuid: 'generated-uuid',
createdAt: new Date(),
updatedAt: new Date(),
};
mockSubRepo.create.mockReturnValue(mockSubscription);
mockSubRepo.save.mockResolvedValue(mockSubscription);
const result = await service.create(createDto);
expect(subRepo.create).toHaveBeenCalledWith({
name: 'Test Subscription',
uuid: expect.any(String),
inboundsConfig: createDto.inboundsConfig,
isAutoRotationEnabled: true,
});
expect(subRepo.save).toHaveBeenCalledWith(mockSubscription);
expect(result).toEqual(mockSubscription);
});
it('должен использовать inboundsConfig по умолчанию [], если не передан', async () => {
const dtoWithoutConfig: CreateSubscriptionDto = {
name: 'Test',
isAutoRotationEnabled: false,
};
mockSubRepo.create.mockReturnValue({ ...dtoWithoutConfig, uuid: 'uuid' });
mockSubRepo.save.mockResolvedValue({ ...dtoWithoutConfig, uuid: 'uuid' });
await service.create(dtoWithoutConfig);
expect(subRepo.create).toHaveBeenCalledWith(
expect.objectContaining({
inboundsConfig: [],
}),
);
});
it('должен использовать isAutoRotationEnabled=true по умолчанию', async () => {
const dtoWithoutRotation: CreateSubscriptionDto = {
name: 'Test',
inboundsConfig: [],
};
mockSubRepo.create.mockReturnValue({
...dtoWithoutRotation,
uuid: 'uuid',
});
mockSubRepo.save.mockResolvedValue({
...dtoWithoutRotation,
uuid: 'uuid',
});
await service.create(dtoWithoutRotation);
expect(subRepo.create).toHaveBeenCalledWith(
expect.objectContaining({
isAutoRotationEnabled: true,
}),
);
});
});
describe('update', () => {
const existingSub: Subscription = {
id: 'test-id',
name: 'Old Name',
uuid: 'uuid',
isEnabled: true,
isAutoRotationEnabled: true,
inboundsConfig: [],
inbounds: [],
createdAt: new Date(),
updatedAt: new Date(),
};
it('должен обновить имя подписки', async () => {
mockSubRepo.findOne.mockResolvedValue(existingSub);
mockSubRepo.save.mockResolvedValue({ ...existingSub, name: 'New Name' });
const result = await service.update('test-id', { name: 'New Name' });
expect(subRepo.findOne).toHaveBeenCalledWith({
where: { id: 'test-id' },
relations: ['inbounds'],
});
expect(subRepo.save).toHaveBeenCalledWith(
expect.objectContaining({ name: 'New Name' }),
);
expect(result).toEqual({ ...existingSub, name: 'New Name' });
});
it('должен обновить isAutoRotationEnabled', async () => {
mockSubRepo.findOne.mockResolvedValue(existingSub);
mockSubRepo.save.mockResolvedValue({
...existingSub,
isAutoRotationEnabled: false,
});
const result = await service.update('test-id', {
isAutoRotationEnabled: false,
});
expect(subRepo.save).toHaveBeenCalledWith(
expect.objectContaining({ isAutoRotationEnabled: false }),
);
expect(result).toEqual({ ...existingSub, isAutoRotationEnabled: false });
});
it('должен обновить inboundsConfig', async () => {
const newConfig = [{ type: 'vmess-tcp', port: 8080 }];
mockSubRepo.findOne.mockResolvedValue(existingSub);
mockSubRepo.save.mockResolvedValue({
...existingSub,
inboundsConfig: newConfig,
});
await service.update('test-id', { inboundsConfig: newConfig });
expect(subRepo.save).toHaveBeenCalledWith(
expect.objectContaining({ inboundsConfig: newConfig }),
);
});
it('НЕ должен обновлять имя на пустую строку (защита от очистки)', async () => {
mockSubRepo.findOne.mockResolvedValue(existingSub);
mockSubRepo.save.mockResolvedValue(existingSub);
await service.update('test-id', { name: '' });
expect(subRepo.save).not.toHaveBeenCalledWith(
expect.objectContaining({ name: '' }),
);
expect(subRepo.save).toHaveBeenCalledWith(existingSub);
});
it('НЕ должен обновлять имя с пробелами (защита от очистки)', async () => {
mockSubRepo.findOne.mockResolvedValue(existingSub);
mockSubRepo.save.mockResolvedValue(existingSub);
await service.update('test-id', { name: ' ' });
expect(subRepo.save).toHaveBeenCalledWith(existingSub);
});
it('должен вернуть null, если подписка не найдена', async () => {
mockSubRepo.findOne.mockResolvedValue(null);
const result = await service.update('non-existent-id', { name: 'New' });
expect(result).toBeNull();
expect(subRepo.save).not.toHaveBeenCalled();
});
it('должен обновить только isAutoRotationEnabled, не трогая имя', async () => {
const freshSub: Subscription = {
...existingSub,
name: 'Old Name',
inboundsConfig: [],
};
mockSubRepo.findOne.mockResolvedValue(freshSub);
mockSubRepo.save.mockImplementation((sub) => Promise.resolve(sub));
await service.update('test-id', { isAutoRotationEnabled: false });
expect(subRepo.save).toHaveBeenCalledWith(
expect.objectContaining({
name: 'Old Name',
isAutoRotationEnabled: false,
}),
);
});
});
describe('remove', () => {
const subWithInbounds: Subscription = {
id: 'test-id',
name: 'Test',
uuid: 'uuid',
isEnabled: true,
isAutoRotationEnabled: true,
inboundsConfig: [],
inbounds: [
{
id: '1',
xuiId: 101,
port: 443,
protocol: 'vless',
remark: 'test',
link: 'link',
subscription: null as any,
createdAt: new Date(),
updatedAt: new Date(),
},
{
id: '2',
xuiId: 102,
port: 8443,
protocol: 'vmess',
remark: 'test2',
link: 'link2',
subscription: null as any,
createdAt: new Date(),
updatedAt: new Date(),
},
],
createdAt: new Date(),
updatedAt: new Date(),
};
it('должен удалить инбаунды из 3x-ui перед удалением подписки', async () => {
mockSubRepo.findOne.mockResolvedValue(subWithInbounds);
mockSubRepo.remove.mockResolvedValue(undefined);
await service.remove('test-id');
expect(xuiService.deleteInbound).toHaveBeenCalledWith(101);
expect(xuiService.deleteInbound).toHaveBeenCalledWith(102);
expect(subRepo.remove).toHaveBeenCalledWith(subWithInbounds);
});
it('должен удалить подписку без инбаундов', async () => {
const subWithoutInbounds: Subscription = {
...subWithInbounds,
inbounds: [],
};
mockSubRepo.findOne.mockResolvedValue(subWithoutInbounds);
mockSubRepo.remove.mockResolvedValue(undefined);
await service.remove('test-id');
expect(xuiService.deleteInbound).not.toHaveBeenCalled();
expect(subRepo.remove).toHaveBeenCalledWith(subWithoutInbounds);
});
it('должен вернуть undefined, если подписка не найдена', async () => {
mockSubRepo.findOne.mockResolvedValue(null);
const result = await service.remove('non-existent-id');
expect(result).toBeUndefined();
expect(subRepo.remove).not.toHaveBeenCalled();
expect(xuiService.deleteInbound).not.toHaveBeenCalled();
});
});
});
+218
View File
@@ -0,0 +1,218 @@
import { Test, TestingModule } from '@nestjs/testing';
import { SshService } from 'src/tunnels/ssh.service';
// Мокируем ssh2 перед импортом сервиса
const mockStreamOn = jest.fn();
const mockStreamStderrOn = jest.fn();
const mockStream = {
on: mockStreamOn,
stderr: { on: mockStreamStderrOn },
};
const mockConnOn = jest.fn();
const mockConnExec = jest.fn();
const mockConnConnect = jest.fn();
const mockConnEnd = jest.fn();
const mockConn = {
on: mockConnOn,
exec: mockConnExec,
connect: mockConnConnect,
end: mockConnEnd,
};
jest.mock('ssh2', () => ({
Client: jest.fn(() => mockConn),
}));
describe('SshService', () => {
let service: SshService;
let readyCallback: (() => void) | null = null;
let errorCallback: ((err: Error) => void) | null = null;
let streamCloseCallback: ((code: number, signal: unknown) => void) | null =
null;
let streamDataCallback: ((data: Buffer) => void) | null = null;
let streamStderrDataCallback: ((data: Buffer) => void) | null = null;
beforeEach(async () => {
// Сброс всех моков
jest.clearAllMocks();
readyCallback = null;
errorCallback = null;
streamCloseCallback = null;
streamDataCallback = null;
streamStderrDataCallback = null;
// Настройка mockConn.on для сохранения callback'ов
mockConnOn.mockImplementation((event: string, cb: () => void) => {
if (event === 'ready') readyCallback = cb;
if (event === 'error') errorCallback = cb;
return mockConn;
});
// Настройка mockConn.exec
mockConnExec.mockImplementation(
(_command: string, cb: (err: Error | null, stream: unknown) => void) => {
cb(null, mockStream);
return mockStream;
},
);
// Настройка stream.on
mockStreamOn.mockImplementation(
(event: string, cb: (...args: unknown[]) => void) => {
if (event === 'close')
streamCloseCallback = cb as (code: number, signal: unknown) => void;
if (event === 'data') streamDataCallback = cb as (data: Buffer) => void;
return mockStream;
},
);
// Настройка stream.stderr.on
mockStreamStderrOn.mockImplementation(
(event: string, cb: (data: Buffer) => void) => {
if (event === 'data') streamStderrDataCallback = cb;
return { on: mockStreamStderrOn };
},
);
const module: TestingModule = await Test.createTestingModule({
providers: [SshService],
}).compile();
service = module.get<SshService>(SshService);
});
describe('executeCommand', () => {
const config = {
host: '192.168.1.100',
port: 22,
username: 'root',
password: 'password123',
};
const command = 'echo "test"';
it('должен выполнить команду успешно', async () => {
const connectPromise = service.executeCommand(config, command);
// Симулируем успешное подключение
readyCallback();
// Симулируем получение данных
streamDataCallback(Buffer.from('test output\n'));
// Симулируем завершение команды с кодом 0
streamCloseCallback(0, null);
const result = await connectPromise;
expect(result).toBe('test output\n');
expect(mockConnConnect).toHaveBeenCalledWith(
expect.objectContaining({
host: '192.168.1.100',
port: 22,
username: 'root',
password: 'password123',
readyTimeout: 20000,
}),
);
});
it('должен выполнить команду с privateKey', async () => {
const configWithKey = {
...config,
privateKey: '-----BEGIN OPENSSH PRIVATE KEY-----',
};
const connectPromise = service.executeCommand(configWithKey, command);
readyCallback();
streamDataCallback(Buffer.from('success'));
streamCloseCallback(0, null);
await connectPromise;
expect(mockConnConnect).toHaveBeenCalledWith(
expect.objectContaining({
privateKey: '-----BEGIN OPENSSH PRIVATE KEY-----',
}),
);
});
it('должен обработать ошибку подключения', async () => {
const error = new Error('Connection refused');
const connectPromise = service.executeCommand(config, command);
// Симулируем ошибку подключения
errorCallback(error);
await expect(connectPromise).rejects.toThrow('Connection refused');
});
it('должен обработать ошибку выполнения команды', async () => {
const execError = new Error('Command not found');
mockConnExec.mockImplementationOnce(
(_command: string, cb: (err: Error | null) => void) => {
cb(execError, null);
},
);
const connectPromise = service.executeCommand(config, command);
readyCallback();
await expect(connectPromise).rejects.toThrow('Command not found');
});
it('должен обработать ненулевой код выхода', async () => {
const connectPromise = service.executeCommand(config, command);
readyCallback();
streamDataCallback(Buffer.from('error output'));
streamCloseCallback(1, null);
await expect(connectPromise).rejects.toThrow('Exit code 1');
});
it('должен собрать вывод из stderr', async () => {
const connectPromise = service.executeCommand(config, command);
readyCallback();
streamDataCallback(Buffer.from('stdout'));
streamStderrDataCallback(Buffer.from('stderr'));
streamCloseCallback(0, null);
const result = await connectPromise;
expect(result).toContain('stdout');
expect(result).toContain('stderr');
});
it('должен обработать пустой вывод', async () => {
const connectPromise = service.executeCommand(config, command);
readyCallback();
streamCloseCallback(0, null);
const result = await connectPromise;
expect(result).toBe('');
});
it('должен собрать вывод из нескольких чанков', async () => {
const connectPromise = service.executeCommand(config, command);
readyCallback();
streamDataCallback(Buffer.from('chunk1'));
streamDataCallback(Buffer.from('chunk2'));
streamDataCallback(Buffer.from('chunk3'));
streamCloseCallback(0, null);
const result = await connectPromise;
expect(result).toBe('chunk1chunk2chunk3');
});
});
});
@@ -0,0 +1,88 @@
/* eslint-disable @typescript-eslint/unbound-method */
import { Test, TestingModule } from '@nestjs/testing';
import { TunnelsController } from 'src/tunnels/tunnels.controller';
import { TunnelsService } from 'src/tunnels/tunnels.service';
describe('TunnelsController', () => {
let controller: TunnelsController;
let tunnelsService: TunnelsService;
const mockTunnelsService = {
create: jest.fn(),
findAll: jest.fn(),
remove: jest.fn(),
installScript: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [TunnelsController],
providers: [
{
provide: TunnelsService,
useValue: mockTunnelsService,
},
],
}).compile();
controller = module.get<TunnelsController>(TunnelsController);
tunnelsService = module.get<TunnelsService>(TunnelsService);
});
afterEach(() => {
jest.clearAllMocks();
});
describe('create', () => {
it('должен создать туннель', async () => {
const dto = { ip: '192.168.1.1', sshPort: 22 };
const mockTunnel = { id: 1, ...dto };
mockTunnelsService.create.mockResolvedValue(mockTunnel);
const result = await controller.create(dto);
expect(result).toEqual(mockTunnel);
expect(tunnelsService.create).toHaveBeenCalledWith(dto);
});
});
describe('findAll', () => {
it('должен вернуть все туннели', async () => {
const mockTunnels = [
{ id: 1, ip: '192.168.1.1' },
{ id: 2, ip: '192.168.1.2' },
];
mockTunnelsService.findAll.mockResolvedValue(mockTunnels);
const result = await controller.findAll();
expect(result).toEqual(mockTunnels);
expect(tunnelsService.findAll).toHaveBeenCalledTimes(1);
});
});
describe('install', () => {
it('должен установить скрипт', async () => {
const mockResult = { success: true, output: 'done' };
mockTunnelsService.installScript.mockResolvedValue(mockResult);
const result = await controller.install('1');
expect(result).toEqual(mockResult);
expect(tunnelsService.installScript).toHaveBeenCalledWith(1);
});
});
describe('remove', () => {
it('должен удалить туннель', async () => {
mockTunnelsService.remove.mockResolvedValue(undefined);
await controller.remove('1');
expect(tunnelsService.remove).toHaveBeenCalledWith(1);
});
});
});
+205
View File
@@ -0,0 +1,205 @@
/* eslint-disable @typescript-eslint/unbound-method */
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { HttpException } from '@nestjs/common';
import { TunnelsService } from 'src/tunnels/tunnels.service';
import { Tunnel } from 'src/tunnels/entities/tunnel.entity';
import { SshService } from 'src/tunnels/ssh.service';
import { Setting } from 'src/settings/entities/setting.entity';
describe('TunnelsService', () => {
let service: TunnelsService;
let tunnelRepo: Repository<Tunnel>;
let sshService: SshService;
const mockTunnelRepo = {
find: jest.fn(),
findOne: jest.fn(),
create: jest.fn(),
save: jest.fn(),
delete: jest.fn(),
createQueryBuilder: jest.fn(() => ({
addSelect: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
getOne: jest.fn(),
})),
};
const mockSettingRepo = {
find: jest.fn(),
findOne: jest.fn(),
create: jest.fn(),
save: jest.fn(),
};
const mockSshService = {
executeCommand: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
TunnelsService,
{
provide: getRepositoryToken(Tunnel),
useValue: mockTunnelRepo,
},
{
provide: getRepositoryToken(Setting),
useValue: mockSettingRepo,
},
{
provide: SshService,
useValue: mockSshService,
},
],
}).compile();
service = module.get<TunnelsService>(TunnelsService);
tunnelRepo = module.get<Repository<Tunnel>>(getRepositoryToken(Tunnel));
settingRepo = module.get<Repository<Setting>>(getRepositoryToken(Setting));
sshService = module.get<SshService>(SshService);
});
afterEach(() => {
jest.clearAllMocks();
});
describe('create', () => {
it('должен создать туннель', async () => {
const dto = { ip: '192.168.1.1', sshPort: 22, username: 'root' };
const mockTunnel = { id: 1, ...dto };
mockTunnelRepo.create.mockReturnValue(mockTunnel);
mockTunnelRepo.save.mockResolvedValue(mockTunnel);
const result = await service.create(dto);
expect(result).toEqual(mockTunnel);
expect(tunnelRepo.create).toHaveBeenCalledWith(dto);
});
});
describe('findAll', () => {
it('должен вернуть все туннели', async () => {
const mockTunnels = [
{ id: 1, ip: '192.168.1.1' },
{ id: 2, ip: '192.168.1.2' },
];
mockTunnelRepo.find.mockResolvedValue(mockTunnels);
const result = await service.findAll();
expect(result).toEqual(mockTunnels);
expect(tunnelRepo.find).toHaveBeenCalledTimes(1);
});
});
describe('remove', () => {
it('должен удалить туннель по ID', async () => {
mockTunnelRepo.delete.mockResolvedValue({ affected: 1 });
await service.remove(1);
expect(tunnelRepo.delete).toHaveBeenCalledWith(1);
});
});
describe('installScript', () => {
const mockTunnel = {
id: 1,
ip: '192.168.1.100',
sshPort: 22,
username: 'root',
password: 'password123',
privateKey: null,
isInstalled: false,
};
it('должен установить скрипт перенаправления', async () => {
mockTunnelRepo.createQueryBuilder.mockReturnValue({
addSelect: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
getOne: jest.fn().mockResolvedValue(mockTunnel),
});
mockSettingRepo.findOne.mockResolvedValue({
key: 'xui_ip',
value: '10.0.0.1',
});
mockSshService.executeCommand.mockResolvedValue(
'Script executed successfully',
);
mockTunnelRepo.save.mockResolvedValue({
...mockTunnel,
isInstalled: true,
});
const result = await service.installScript(1);
expect(result).toEqual({
success: true,
output: 'Script executed successfully',
});
expect(sshService.executeCommand).toHaveBeenCalled();
expect(tunnelRepo.save).toHaveBeenCalledWith(
expect.objectContaining({ isInstalled: true }),
);
});
it('должен бросить HttpException, если туннель не найден', async () => {
mockTunnelRepo.createQueryBuilder.mockReturnValue({
addSelect: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
getOne: jest.fn().mockResolvedValue(null),
});
await expect(service.installScript(999)).rejects.toThrow(HttpException);
await expect(service.installScript(999)).rejects.toThrow(
'Tunnel not found',
);
});
it('должен бросить HttpException, если xui_host не настроен', async () => {
mockTunnelRepo.createQueryBuilder.mockReturnValue({
addSelect: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
getOne: jest.fn().mockResolvedValue(mockTunnel),
});
mockSettingRepo.findOne.mockResolvedValue(null);
await expect(service.installScript(1)).rejects.toThrow(HttpException);
await expect(service.installScript(1)).rejects.toThrow(
'В настройках (Settings) не сохранен Host/IP основного сервера',
);
});
it('должен бросить HttpException при ошибке SSH', async () => {
mockTunnelRepo.createQueryBuilder.mockReturnValue({
addSelect: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
getOne: jest.fn().mockResolvedValue(mockTunnel),
});
mockSettingRepo.findOne.mockResolvedValue({
key: 'xui_ip',
value: '10.0.0.1',
});
mockSshService.executeCommand.mockRejectedValue(
new Error('SSH connection failed'),
);
await expect(service.installScript(1)).rejects.toThrow(HttpException);
await expect(service.installScript(1)).rejects.toThrow(
'Ошибка установки: SSH connection failed',
);
});
});
});
+198
View File
@@ -0,0 +1,198 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { XuiService } from 'src/xui/xui.service';
import { Setting } from 'src/settings/entities/setting.entity';
import { SessionService } from 'src/session/session.service';
import axios from 'axios';
jest.mock('axios');
describe('XuiService', () => {
let service: XuiService;
const mockSettingsRepo = {
find: jest.fn(),
findOne: jest.fn(),
create: jest.fn(),
save: jest.fn(),
};
const mockSessionService = {
getCookie: jest.fn(),
setFromHeaders: jest.fn(),
};
const mockAxiosInstance = {
get: jest.fn(),
post: jest.fn(),
defaults: { baseURL: '' },
interceptors: {
request: { use: jest.fn() },
response: { use: jest.fn() },
},
};
beforeEach(async () => {
(axios.create as jest.Mock).mockReturnValue(mockAxiosInstance);
const module: TestingModule = await Test.createTestingModule({
providers: [
XuiService,
{
provide: getRepositoryToken(Setting),
useValue: mockSettingsRepo,
},
{
provide: SessionService,
useValue: mockSessionService,
},
],
}).compile();
service = module.get<XuiService>(XuiService);
settingsRepo = module.get<Repository<Setting>>(getRepositoryToken(Setting));
sessionService = module.get<SessionService>(SessionService);
});
afterEach(() => {
jest.clearAllMocks();
});
describe('login', () => {
it('должен вернуть false, если настройки не заполнены', async () => {
mockSettingsRepo.find.mockResolvedValue([]);
const result = await service.login();
expect(result).toBe(false);
});
it('должен вернуть true при успешном логине', async () => {
mockSettingsRepo.find.mockResolvedValue([
{ key: 'xui_url', value: 'http://localhost:3000' },
{ key: 'xui_login', value: 'admin' },
{ key: 'xui_password', value: 'password' },
]);
mockAxiosInstance.post.mockResolvedValue({
headers: {
'set-cookie': ['session=abc123'],
},
});
const result = await service.login();
expect(result).toBe(true);
expect(mockAxiosInstance.post).toHaveBeenCalledWith('/login', {
username: 'admin',
password: 'password',
});
});
it('должен вернуть false при ошибке логина', async () => {
mockSettingsRepo.find.mockResolvedValue([
{ key: 'xui_url', value: 'http://localhost:3000' },
{ key: 'xui_login', value: 'admin' },
{ key: 'xui_password', value: 'password' },
]);
mockAxiosInstance.post.mockRejectedValue(new Error('Network error'));
const result = await service.login();
expect(result).toBe(false);
});
it('должен вернуть false, если нет cookie в ответе', async () => {
mockSettingsRepo.find.mockResolvedValue([
{ key: 'xui_url', value: 'http://localhost:3000' },
{ key: 'xui_login', value: 'admin' },
{ key: 'xui_password', value: 'password' },
]);
mockAxiosInstance.post.mockResolvedValue({
headers: {},
});
const result = await service.login();
expect(result).toBe(false);
});
});
describe('checkConnection', () => {
it('должен вернуть false при ошибке подключения', async () => {
mockAxiosInstance.post.mockRejectedValue(new Error('Connection failed'));
const result = await service.checkConnection(
'http://localhost:3000',
'admin',
'password',
);
expect(result).toBe(false);
});
});
describe('addInbound', () => {
it('должен вернуть null при ошибке', async () => {
mockAxiosInstance.post.mockRejectedValue(new Error('API error'));
const result = await service.addInbound(
{} as unknown as { port: number },
);
expect(result).toBeNull();
});
});
describe('deleteInbound', () => {
it('должен удалить инбаунд', async () => {
mockAxiosInstance.post.mockResolvedValue({ data: { success: true } });
await service.deleteInbound(101);
expect(mockAxiosInstance.post).toHaveBeenCalledWith(
'/panel/api/inbounds/del/101',
);
});
it('должен обработать ошибку удаления', async () => {
mockAxiosInstance.post.mockRejectedValue(new Error('Not found'));
await service.deleteInbound(999);
expect(mockAxiosInstance.post).toHaveBeenCalled();
});
});
describe('getNewX25519Cert', () => {
it('должен получить Reality ключи', async () => {
mockAxiosInstance.get.mockResolvedValue({
data: {
success: true,
obj: {
publicKey: 'pub-key',
privateKey: 'priv-key',
},
},
});
const result = await service.getNewX25519Cert();
expect(result).toEqual({
publicKey: 'pub-key',
privateKey: 'priv-key',
});
});
it('должен вернуть null при ошибке', async () => {
mockAxiosInstance.get.mockRejectedValue(new Error('API error'));
const result = await service.getNewX25519Cert();
expect(result).toBeNull();
});
});
});
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./dist/test",
"types": ["jest", "node"]
},
"include": ["test/**/*.ts", "src/**/*.ts"]
}