feat:实现管理员系统核心功能
- 添加管理员数据库管理控制器和服务 - 实现管理员操作日志记录系统 - 添加数据库异常处理过滤器 - 完善管理员权限验证和响应格式 - 添加全面的属性测试覆盖
This commit is contained in:
597
src/business/admin/database_management.service.unit.spec.ts
Normal file
597
src/business/admin/database_management.service.unit.spec.ts
Normal file
@@ -0,0 +1,597 @@
|
||||
/**
|
||||
* DatabaseManagementService 单元测试
|
||||
*
|
||||
* 测试目标:
|
||||
* - 验证服务类各个方法的具体实现
|
||||
* - 测试边界条件和异常情况
|
||||
* - 确保代码覆盖率达标
|
||||
*
|
||||
* 最近修改:
|
||||
* - 2026-01-08: 注释规范优化 - 修正@author字段,更新版本号和修改记录 (修改者: moyin)
|
||||
* - 2026-01-08: 功能新增 - 创建DatabaseManagementService单元测试 (修改者: assistant)
|
||||
*
|
||||
* @author moyin
|
||||
* @version 1.0.1
|
||||
* @since 2026-01-08
|
||||
* @lastModified 2026-01-08
|
||||
*/
|
||||
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { DatabaseManagementService } from '../../services/database_management.service';
|
||||
import { AdminOperationLogService } from '../../services/admin_operation_log.service';
|
||||
import { UserStatus } from '../../../../core/db/users/user_status.enum';
|
||||
|
||||
describe('DatabaseManagementService Unit Tests', () => {
|
||||
let service: DatabaseManagementService;
|
||||
let mockUsersService: any;
|
||||
let mockUserProfilesService: any;
|
||||
let mockZulipAccountsService: any;
|
||||
let mockLogService: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
mockUsersService = {
|
||||
findAll: jest.fn(),
|
||||
findOne: jest.fn(),
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
remove: jest.fn(),
|
||||
search: jest.fn(),
|
||||
count: jest.fn()
|
||||
};
|
||||
|
||||
mockUserProfilesService = {
|
||||
findAll: jest.fn(),
|
||||
findOne: jest.fn(),
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
remove: jest.fn(),
|
||||
findByMap: jest.fn(),
|
||||
count: jest.fn()
|
||||
};
|
||||
|
||||
mockZulipAccountsService = {
|
||||
findMany: jest.fn(),
|
||||
findById: jest.fn(),
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
getStatusStatistics: jest.fn()
|
||||
};
|
||||
|
||||
mockLogService = {
|
||||
createLog: jest.fn().mockResolvedValue({}),
|
||||
queryLogs: jest.fn().mockResolvedValue({ logs: [], total: 0 }),
|
||||
getLogById: jest.fn().mockResolvedValue(null),
|
||||
getStatistics: jest.fn().mockResolvedValue({}),
|
||||
cleanupExpiredLogs: jest.fn().mockResolvedValue(0),
|
||||
getAdminOperationHistory: jest.fn().mockResolvedValue([]),
|
||||
getSensitiveOperations: jest.fn().mockResolvedValue({ logs: [], total: 0 })
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
envFilePath: ['.env.test', '.env']
|
||||
})
|
||||
],
|
||||
providers: [
|
||||
DatabaseManagementService,
|
||||
{
|
||||
provide: AdminOperationLogService,
|
||||
useValue: mockLogService
|
||||
},
|
||||
{
|
||||
provide: 'UsersService',
|
||||
useValue: mockUsersService
|
||||
},
|
||||
{
|
||||
provide: 'IUserProfilesService',
|
||||
useValue: mockUserProfilesService
|
||||
},
|
||||
{
|
||||
provide: 'ZulipAccountsService',
|
||||
useValue: mockZulipAccountsService
|
||||
}
|
||||
]
|
||||
}).compile();
|
||||
|
||||
service = module.get<DatabaseManagementService>(DatabaseManagementService);
|
||||
});
|
||||
|
||||
describe('User Management', () => {
|
||||
describe('getUserList', () => {
|
||||
it('should return paginated user list with correct format', async () => {
|
||||
const mockUsers = [
|
||||
{ id: BigInt(1), username: 'user1', email: 'user1@test.com' },
|
||||
{ id: BigInt(2), username: 'user2', email: 'user2@test.com' }
|
||||
];
|
||||
const totalCount = 10;
|
||||
|
||||
mockUsersService.findAll.mockResolvedValue(mockUsers);
|
||||
mockUsersService.count.mockResolvedValue(totalCount);
|
||||
|
||||
const result = await service.getUserList(5, 0);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data.items).toEqual(mockUsers.map(u => ({ ...u, id: u.id.toString() })));
|
||||
expect(result.data.total).toBe(totalCount);
|
||||
expect(result.data.limit).toBe(5);
|
||||
expect(result.data.offset).toBe(0);
|
||||
expect(result.data.has_more).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle empty result set', async () => {
|
||||
mockUsersService.findAll.mockResolvedValue([]);
|
||||
mockUsersService.count.mockResolvedValue(0);
|
||||
|
||||
const result = await service.getUserList(10, 0);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data.items).toEqual([]);
|
||||
expect(result.data.total).toBe(0);
|
||||
expect(result.data.has_more).toBe(false);
|
||||
});
|
||||
|
||||
it('should apply limit and offset correctly', async () => {
|
||||
const mockUsers = [{ id: BigInt(1), username: 'user1' }];
|
||||
mockUsersService.findAll.mockResolvedValue(mockUsers);
|
||||
mockUsersService.count.mockResolvedValue(1);
|
||||
|
||||
await service.getUserList(20, 10);
|
||||
|
||||
expect(mockUsersService.findAll).toHaveBeenCalledWith(undefined, 20, 10);
|
||||
});
|
||||
|
||||
it('should enforce maximum limit', async () => {
|
||||
mockUsersService.findAll.mockResolvedValue([]);
|
||||
mockUsersService.count.mockResolvedValue(0);
|
||||
|
||||
await service.getUserList(200, 0); // 超过最大限制
|
||||
|
||||
expect(mockUsersService.findAll).toHaveBeenCalledWith(undefined, 100, 0);
|
||||
});
|
||||
|
||||
it('should handle negative offset', async () => {
|
||||
mockUsersService.findAll.mockResolvedValue([]);
|
||||
mockUsersService.count.mockResolvedValue(0);
|
||||
|
||||
await service.getUserList(10, -5);
|
||||
|
||||
expect(mockUsersService.findAll).toHaveBeenCalledWith(undefined, 10, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUserById', () => {
|
||||
it('should return user when found', async () => {
|
||||
const mockUser = { id: BigInt(1), username: 'testuser', email: 'test@example.com' };
|
||||
mockUsersService.findOne.mockResolvedValue(mockUser);
|
||||
|
||||
const result = await service.getUserById('1');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data).toEqual({ ...mockUser, id: '1' });
|
||||
expect(mockUsersService.findOne).toHaveBeenCalledWith(BigInt(1));
|
||||
});
|
||||
|
||||
it('should return error when user not found', async () => {
|
||||
mockUsersService.findOne.mockResolvedValue(null);
|
||||
|
||||
const result = await service.getUserById('999');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error_code).toBe('USER_NOT_FOUND');
|
||||
expect(result.message).toContain('User with ID 999 not found');
|
||||
});
|
||||
|
||||
it('should handle invalid ID format', async () => {
|
||||
const result = await service.getUserById('invalid');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error_code).toBe('INVALID_USER_ID');
|
||||
});
|
||||
|
||||
it('should handle service errors', async () => {
|
||||
mockUsersService.findOne.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
const result = await service.getUserById('1');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error_code).toBe('DATABASE_ERROR');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createUser', () => {
|
||||
it('should create user successfully', async () => {
|
||||
const userData = {
|
||||
username: 'newuser',
|
||||
email: 'new@example.com',
|
||||
status: UserStatus.ACTIVE
|
||||
};
|
||||
const createdUser = { ...userData, id: BigInt(1) };
|
||||
|
||||
mockUsersService.create.mockResolvedValue(createdUser);
|
||||
|
||||
const result = await service.createUser(userData);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data).toEqual({ ...createdUser, id: '1' });
|
||||
expect(mockUsersService.create).toHaveBeenCalledWith(userData);
|
||||
});
|
||||
|
||||
it('should handle duplicate username error', async () => {
|
||||
const userData = { username: 'existing', email: 'test@example.com', status: UserStatus.ACTIVE };
|
||||
mockUsersService.create.mockRejectedValue(new Error('Duplicate key violation'));
|
||||
|
||||
const result = await service.createUser(userData);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error_code).toBe('DUPLICATE_USERNAME');
|
||||
});
|
||||
|
||||
it('should validate required fields', async () => {
|
||||
const invalidData = { username: '', email: 'test@example.com', status: UserStatus.ACTIVE };
|
||||
|
||||
const result = await service.createUser(invalidData);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error_code).toBe('VALIDATION_ERROR');
|
||||
});
|
||||
|
||||
it('should validate email format', async () => {
|
||||
const invalidData = { username: 'test', email: 'invalid-email', status: UserStatus.ACTIVE };
|
||||
|
||||
const result = await service.createUser(invalidData);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error_code).toBe('VALIDATION_ERROR');
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateUser', () => {
|
||||
it('should update user successfully', async () => {
|
||||
const updateData = { nickname: 'Updated Name' };
|
||||
const existingUser = { id: BigInt(1), username: 'test', email: 'test@example.com' };
|
||||
const updatedUser = { ...existingUser, ...updateData };
|
||||
|
||||
mockUsersService.findOne.mockResolvedValue(existingUser);
|
||||
mockUsersService.update.mockResolvedValue(updatedUser);
|
||||
|
||||
const result = await service.updateUser('1', updateData);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data).toEqual({ ...updatedUser, id: '1' });
|
||||
expect(mockUsersService.update).toHaveBeenCalledWith(BigInt(1), updateData);
|
||||
});
|
||||
|
||||
it('should return error when user not found', async () => {
|
||||
mockUsersService.findOne.mockResolvedValue(null);
|
||||
|
||||
const result = await service.updateUser('999', { nickname: 'New Name' });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error_code).toBe('USER_NOT_FOUND');
|
||||
});
|
||||
|
||||
it('should handle empty update data', async () => {
|
||||
const result = await service.updateUser('1', {});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error_code).toBe('VALIDATION_ERROR');
|
||||
expect(result.message).toContain('No valid fields to update');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteUser', () => {
|
||||
it('should delete user successfully', async () => {
|
||||
const existingUser = { id: BigInt(1), username: 'test' };
|
||||
mockUsersService.findOne.mockResolvedValue(existingUser);
|
||||
mockUsersService.remove.mockResolvedValue(undefined);
|
||||
|
||||
const result = await service.deleteUser('1');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data.deleted).toBe(true);
|
||||
expect(result.data.id).toBe('1');
|
||||
expect(mockUsersService.remove).toHaveBeenCalledWith(BigInt(1));
|
||||
});
|
||||
|
||||
it('should return error when user not found', async () => {
|
||||
mockUsersService.findOne.mockResolvedValue(null);
|
||||
|
||||
const result = await service.deleteUser('999');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error_code).toBe('USER_NOT_FOUND');
|
||||
});
|
||||
});
|
||||
|
||||
describe('searchUsers', () => {
|
||||
it('should search users successfully', async () => {
|
||||
const mockUsers = [
|
||||
{ id: BigInt(1), username: 'testuser', email: 'test@example.com' }
|
||||
];
|
||||
mockUsersService.search.mockResolvedValue(mockUsers);
|
||||
|
||||
const result = await service.searchUsers('test', 10);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data.items).toEqual(mockUsers.map(u => ({ ...u, id: u.id.toString() })));
|
||||
expect(mockUsersService.search).toHaveBeenCalledWith('test', 10);
|
||||
});
|
||||
|
||||
it('should handle empty search term', async () => {
|
||||
const result = await service.searchUsers('', 10);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error_code).toBe('VALIDATION_ERROR');
|
||||
expect(result.message).toContain('Search term cannot be empty');
|
||||
});
|
||||
|
||||
it('should apply search limit', async () => {
|
||||
mockUsersService.search.mockResolvedValue([]);
|
||||
|
||||
await service.searchUsers('test', 200); // 超过限制
|
||||
|
||||
expect(mockUsersService.search).toHaveBeenCalledWith('test', 100);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('User Profile Management', () => {
|
||||
describe('getUserProfileList', () => {
|
||||
it('should return paginated profile list', async () => {
|
||||
const mockProfiles = [
|
||||
{ id: BigInt(1), user_id: '1', bio: 'Test bio' }
|
||||
];
|
||||
mockUserProfilesService.findAll.mockResolvedValue(mockProfiles);
|
||||
mockUserProfilesService.count.mockResolvedValue(1);
|
||||
|
||||
const result = await service.getUserProfileList(10, 0);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data.items).toEqual(mockProfiles.map(p => ({ ...p, id: p.id.toString() })));
|
||||
});
|
||||
});
|
||||
|
||||
describe('createUserProfile', () => {
|
||||
it('should create profile successfully', async () => {
|
||||
const profileData = {
|
||||
user_id: '1',
|
||||
bio: 'Test bio',
|
||||
current_map: 'plaza',
|
||||
pos_x: 100,
|
||||
pos_y: 200
|
||||
};
|
||||
const createdProfile = { ...profileData, id: BigInt(1) };
|
||||
|
||||
mockUserProfilesService.create.mockResolvedValue(createdProfile);
|
||||
|
||||
const result = await service.createUserProfile(profileData);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data).toEqual({ ...createdProfile, id: '1' });
|
||||
});
|
||||
|
||||
it('should validate position coordinates', async () => {
|
||||
const invalidData = {
|
||||
user_id: '1',
|
||||
bio: 'Test',
|
||||
pos_x: 'invalid' as any,
|
||||
pos_y: 100
|
||||
};
|
||||
|
||||
const result = await service.createUserProfile(invalidData);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error_code).toBe('VALIDATION_ERROR');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUserProfilesByMap', () => {
|
||||
it('should return profiles by map', async () => {
|
||||
const mockProfiles = [
|
||||
{ id: BigInt(1), user_id: '1', current_map: 'plaza' }
|
||||
];
|
||||
mockUserProfilesService.findByMap.mockResolvedValue(mockProfiles);
|
||||
mockUserProfilesService.count.mockResolvedValue(1);
|
||||
|
||||
const result = await service.getUserProfilesByMap('plaza', 10, 0);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data.items).toEqual(mockProfiles.map(p => ({ ...p, id: p.id.toString() })));
|
||||
expect(mockUserProfilesService.findByMap).toHaveBeenCalledWith('plaza', undefined, 10, 0);
|
||||
});
|
||||
|
||||
it('should validate map name', async () => {
|
||||
const result = await service.getUserProfilesByMap('', 10, 0);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error_code).toBe('VALIDATION_ERROR');
|
||||
expect(result.message).toContain('Map name cannot be empty');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Zulip Account Management', () => {
|
||||
describe('getZulipAccountList', () => {
|
||||
it('should return paginated account list', async () => {
|
||||
const mockAccounts = [
|
||||
{ id: '1', gameUserId: '1', zulipEmail: 'test@zulip.com' }
|
||||
];
|
||||
mockZulipAccountsService.findMany.mockResolvedValue({
|
||||
accounts: mockAccounts,
|
||||
total: 1
|
||||
});
|
||||
|
||||
const result = await service.getZulipAccountList(10, 0);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data.items).toEqual(mockAccounts);
|
||||
expect(result.data.total).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createZulipAccount', () => {
|
||||
it('should create account successfully', async () => {
|
||||
const accountData = {
|
||||
gameUserId: '1',
|
||||
zulipUserId: 123,
|
||||
zulipEmail: 'test@zulip.com',
|
||||
zulipFullName: 'Test User',
|
||||
zulipApiKeyEncrypted: 'encrypted_key',
|
||||
status: 'active' as const
|
||||
};
|
||||
const createdAccount = { ...accountData, id: '1' };
|
||||
|
||||
mockZulipAccountsService.create.mockResolvedValue(createdAccount);
|
||||
|
||||
const result = await service.createZulipAccount(accountData);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data).toEqual(createdAccount);
|
||||
});
|
||||
|
||||
it('should validate required fields', async () => {
|
||||
const invalidData = {
|
||||
gameUserId: '',
|
||||
zulipUserId: 123,
|
||||
zulipEmail: 'test@zulip.com',
|
||||
zulipFullName: 'Test',
|
||||
zulipApiKeyEncrypted: 'key',
|
||||
status: 'active' as const
|
||||
};
|
||||
|
||||
const result = await service.createZulipAccount(invalidData);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error_code).toBe('VALIDATION_ERROR');
|
||||
});
|
||||
});
|
||||
|
||||
describe('batchUpdateZulipAccountStatus', () => {
|
||||
it('should update multiple accounts successfully', async () => {
|
||||
const batchData = {
|
||||
ids: ['1', '2'],
|
||||
status: 'active' as const,
|
||||
reason: 'Test update'
|
||||
};
|
||||
|
||||
mockZulipAccountsService.update
|
||||
.mockResolvedValueOnce({ id: '1', status: 'active' })
|
||||
.mockResolvedValueOnce({ id: '2', status: 'active' });
|
||||
|
||||
const result = await service.batchUpdateZulipAccountStatus(batchData);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data.total).toBe(2);
|
||||
expect(result.data.success).toBe(2);
|
||||
expect(result.data.failed).toBe(0);
|
||||
expect(result.data.results).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should handle partial failures', async () => {
|
||||
const batchData = {
|
||||
ids: ['1', '2'],
|
||||
status: 'active' as const,
|
||||
reason: 'Test update'
|
||||
};
|
||||
|
||||
mockZulipAccountsService.update
|
||||
.mockResolvedValueOnce({ id: '1', status: 'active' })
|
||||
.mockRejectedValueOnce(new Error('Update failed'));
|
||||
|
||||
const result = await service.batchUpdateZulipAccountStatus(batchData);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data.total).toBe(2);
|
||||
expect(result.data.success).toBe(1);
|
||||
expect(result.data.failed).toBe(1);
|
||||
expect(result.data.errors).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should validate batch data', async () => {
|
||||
const invalidData = {
|
||||
ids: [],
|
||||
status: 'active' as const,
|
||||
reason: 'Test'
|
||||
};
|
||||
|
||||
const result = await service.batchUpdateZulipAccountStatus(invalidData);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error_code).toBe('VALIDATION_ERROR');
|
||||
expect(result.message).toContain('No account IDs provided');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getZulipAccountStatistics', () => {
|
||||
it('should return statistics successfully', async () => {
|
||||
const mockStats = {
|
||||
active: 10,
|
||||
inactive: 5,
|
||||
suspended: 2,
|
||||
error: 1,
|
||||
total: 18
|
||||
};
|
||||
mockZulipAccountsService.getStatusStatistics.mockResolvedValue(mockStats);
|
||||
|
||||
const result = await service.getZulipAccountStatistics();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data).toEqual(mockStats);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Health Check', () => {
|
||||
describe('healthCheck', () => {
|
||||
it('should return healthy status', async () => {
|
||||
const result = await service.healthCheck();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data.status).toBe('healthy');
|
||||
expect(result.data.timestamp).toBeDefined();
|
||||
expect(result.data.services).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should handle service injection errors', () => {
|
||||
expect(service).toBeDefined();
|
||||
expect(service['usersService']).toBeDefined();
|
||||
expect(service['userProfilesService']).toBeDefined();
|
||||
expect(service['zulipAccountsService']).toBeDefined();
|
||||
});
|
||||
|
||||
it('should format BigInt IDs correctly', async () => {
|
||||
const mockUser = { id: BigInt(123456789012345), username: 'test' };
|
||||
mockUsersService.findOne.mockResolvedValue(mockUser);
|
||||
|
||||
const result = await service.getUserById('123456789012345');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data.id).toBe('123456789012345');
|
||||
});
|
||||
|
||||
it('should handle concurrent operations', async () => {
|
||||
const mockUser = { id: BigInt(1), username: 'test' };
|
||||
mockUsersService.findOne.mockResolvedValue(mockUser);
|
||||
|
||||
const promises = [
|
||||
service.getUserById('1'),
|
||||
service.getUserById('1'),
|
||||
service.getUserById('1')
|
||||
];
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
|
||||
results.forEach(result => {
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data.id).toBe('1');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user