back tests 80+
This commit is contained in:
@@ -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
|
||||
Generated
+13
@@ -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
@@ -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;
|
||||
@@ -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('/')
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist/test",
|
||||
"types": ["jest", "node"]
|
||||
},
|
||||
"include": ["test/**/*.ts", "src/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user