Initial commit: Telegram Management System
Some checks failed
Deploy / deploy (push) Has been cancelled
Some checks failed
Deploy / deploy (push) Has been cancelled
Full-stack web application for Telegram management - Frontend: Vue 3 + Vben Admin - Backend: NestJS - Features: User management, group broadcast, statistics 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
254
marketing-agent/tests/integration/api/auth.test.js
Normal file
254
marketing-agent/tests/integration/api/auth.test.js
Normal file
@@ -0,0 +1,254 @@
|
||||
import request from 'supertest';
|
||||
import app from '../../../services/api-gateway/src/app.js';
|
||||
import { connectDatabase, closeDatabase, clearDatabase } from '../../helpers/database.js';
|
||||
import { createUser } from '../../helpers/factories.js';
|
||||
import bcrypt from 'bcryptjs';
|
||||
|
||||
describe('Auth API Integration Tests', () => {
|
||||
beforeAll(async () => {
|
||||
await connectDatabase();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await clearDatabase();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await closeDatabase();
|
||||
});
|
||||
|
||||
describe('POST /api/v1/auth/login', () => {
|
||||
it('should login with valid credentials', async () => {
|
||||
// Create a user in the database
|
||||
const password = 'Test123!@#';
|
||||
const hashedPassword = await bcrypt.hash(password, 10);
|
||||
const userData = createUser({ password: hashedPassword });
|
||||
|
||||
// Save user to database (you'll need to import and use your User model)
|
||||
// const user = await User.create(userData);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/v1/auth/login')
|
||||
.send({
|
||||
username: userData.username,
|
||||
password: password
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.success).toBe(true);
|
||||
expect(response.body.data).toHaveProperty('accessToken');
|
||||
expect(response.body.data).toHaveProperty('refreshToken');
|
||||
expect(response.body.data.user).toHaveProperty('id');
|
||||
expect(response.body.data.user.username).toBe(userData.username);
|
||||
});
|
||||
|
||||
it('should fail with invalid credentials', async () => {
|
||||
const response = await request(app)
|
||||
.post('/api/v1/auth/login')
|
||||
.send({
|
||||
username: 'nonexistent',
|
||||
password: 'wrongpassword'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.success).toBe(false);
|
||||
expect(response.body.error).toBe('Invalid credentials');
|
||||
});
|
||||
|
||||
it('should fail with missing credentials', async () => {
|
||||
const response = await request(app)
|
||||
.post('/api/v1/auth/login')
|
||||
.send({
|
||||
username: 'testuser'
|
||||
// missing password
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.success).toBe(false);
|
||||
expect(response.body.error).toContain('required');
|
||||
});
|
||||
|
||||
it('should handle rate limiting', async () => {
|
||||
// Make multiple requests to trigger rate limit
|
||||
const requests = Array(11).fill(null).map(() =>
|
||||
request(app)
|
||||
.post('/api/v1/auth/login')
|
||||
.send({
|
||||
username: 'testuser',
|
||||
password: 'password'
|
||||
})
|
||||
);
|
||||
|
||||
const responses = await Promise.all(requests);
|
||||
const rateLimited = responses.some(res => res.status === 429);
|
||||
|
||||
expect(rateLimited).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/auth/register', () => {
|
||||
it('should register new user', async () => {
|
||||
const userData = {
|
||||
username: 'newuser',
|
||||
email: 'newuser@example.com',
|
||||
password: 'SecurePass123!',
|
||||
fullName: 'New User'
|
||||
};
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/v1/auth/register')
|
||||
.send(userData);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body.success).toBe(true);
|
||||
expect(response.body.message).toBe('Registration successful');
|
||||
});
|
||||
|
||||
it('should fail with duplicate username', async () => {
|
||||
const userData = {
|
||||
username: 'existinguser',
|
||||
email: 'test@example.com',
|
||||
password: 'SecurePass123!'
|
||||
};
|
||||
|
||||
// First registration
|
||||
await request(app)
|
||||
.post('/api/v1/auth/register')
|
||||
.send(userData);
|
||||
|
||||
// Duplicate registration
|
||||
const response = await request(app)
|
||||
.post('/api/v1/auth/register')
|
||||
.send(userData);
|
||||
|
||||
expect(response.status).toBe(409);
|
||||
expect(response.body.success).toBe(false);
|
||||
expect(response.body.error).toContain('already exists');
|
||||
});
|
||||
|
||||
it('should validate email format', async () => {
|
||||
const response = await request(app)
|
||||
.post('/api/v1/auth/register')
|
||||
.send({
|
||||
username: 'testuser',
|
||||
email: 'invalid-email',
|
||||
password: 'SecurePass123!'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.success).toBe(false);
|
||||
expect(response.body.error).toContain('email');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/auth/refresh', () => {
|
||||
let accessToken, refreshToken;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Login to get tokens
|
||||
const loginResponse = await request(app)
|
||||
.post('/api/v1/auth/login')
|
||||
.send({
|
||||
username: 'admin',
|
||||
password: 'password123'
|
||||
});
|
||||
|
||||
accessToken = loginResponse.body.data.accessToken;
|
||||
refreshToken = loginResponse.body.data.refreshToken;
|
||||
});
|
||||
|
||||
it('should refresh access token', async () => {
|
||||
const response = await request(app)
|
||||
.post('/api/v1/auth/refresh')
|
||||
.send({
|
||||
refreshToken: refreshToken
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.success).toBe(true);
|
||||
expect(response.body.data).toHaveProperty('accessToken');
|
||||
expect(response.body.data.accessToken).not.toBe(accessToken);
|
||||
});
|
||||
|
||||
it('should fail with invalid refresh token', async () => {
|
||||
const response = await request(app)
|
||||
.post('/api/v1/auth/refresh')
|
||||
.send({
|
||||
refreshToken: 'invalid-refresh-token'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.success).toBe(false);
|
||||
expect(response.body.error).toBe('Invalid refresh token');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v1/auth/me', () => {
|
||||
let accessToken;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Login to get token
|
||||
const loginResponse = await request(app)
|
||||
.post('/api/v1/auth/login')
|
||||
.send({
|
||||
username: 'admin',
|
||||
password: 'password123'
|
||||
});
|
||||
|
||||
accessToken = loginResponse.body.data.accessToken;
|
||||
});
|
||||
|
||||
it('should get current user info', async () => {
|
||||
const response = await request(app)
|
||||
.get('/api/v1/auth/me')
|
||||
.set('Authorization', `Bearer ${accessToken}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.success).toBe(true);
|
||||
expect(response.body.data).toHaveProperty('id');
|
||||
expect(response.body.data).toHaveProperty('role');
|
||||
});
|
||||
|
||||
it('should fail without token', async () => {
|
||||
const response = await request(app)
|
||||
.get('/api/v1/auth/me');
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.success).toBe(false);
|
||||
expect(response.body.error).toBe('No token provided');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/auth/logout', () => {
|
||||
let accessToken;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Login to get token
|
||||
const loginResponse = await request(app)
|
||||
.post('/api/v1/auth/login')
|
||||
.send({
|
||||
username: 'admin',
|
||||
password: 'password123'
|
||||
});
|
||||
|
||||
accessToken = loginResponse.body.data.accessToken;
|
||||
});
|
||||
|
||||
it('should logout successfully', async () => {
|
||||
const response = await request(app)
|
||||
.post('/api/v1/auth/logout')
|
||||
.set('Authorization', `Bearer ${accessToken}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.success).toBe(true);
|
||||
expect(response.body.message).toBe('Logged out successfully');
|
||||
|
||||
// Verify token is blacklisted
|
||||
const meResponse = await request(app)
|
||||
.get('/api/v1/auth/me')
|
||||
.set('Authorization', `Bearer ${accessToken}`);
|
||||
|
||||
expect(meResponse.status).toBe(401);
|
||||
});
|
||||
});
|
||||
});
|
||||
370
marketing-agent/tests/integration/api/campaigns.test.js
Normal file
370
marketing-agent/tests/integration/api/campaigns.test.js
Normal file
@@ -0,0 +1,370 @@
|
||||
import request from 'supertest';
|
||||
import app from '../../../services/api-gateway/src/app.js';
|
||||
import { connectDatabase, closeDatabase, clearDatabase } from '../../helpers/database.js';
|
||||
import { createCampaign, createTelegramUser } from '../../helpers/factories.js';
|
||||
|
||||
describe('Campaigns API Integration Tests', () => {
|
||||
let authToken;
|
||||
|
||||
beforeAll(async () => {
|
||||
await connectDatabase();
|
||||
|
||||
// Login to get auth token
|
||||
const loginResponse = await request(app)
|
||||
.post('/api/v1/auth/login')
|
||||
.send({
|
||||
username: 'admin',
|
||||
password: 'password123'
|
||||
});
|
||||
|
||||
authToken = loginResponse.body.data.accessToken;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await clearDatabase();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await closeDatabase();
|
||||
});
|
||||
|
||||
describe('GET /api/v1/orchestrator/campaigns', () => {
|
||||
it('should list campaigns', async () => {
|
||||
const response = await request(app)
|
||||
.get('/api/v1/orchestrator/campaigns')
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.success).toBe(true);
|
||||
expect(response.body.data).toHaveProperty('campaigns');
|
||||
expect(Array.isArray(response.body.data.campaigns)).toBe(true);
|
||||
expect(response.body.data).toHaveProperty('pagination');
|
||||
});
|
||||
|
||||
it('should filter campaigns by status', async () => {
|
||||
const response = await request(app)
|
||||
.get('/api/v1/orchestrator/campaigns?status=active')
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.success).toBe(true);
|
||||
response.body.data.campaigns.forEach(campaign => {
|
||||
expect(campaign.status).toBe('active');
|
||||
});
|
||||
});
|
||||
|
||||
it('should paginate campaigns', async () => {
|
||||
const response = await request(app)
|
||||
.get('/api/v1/orchestrator/campaigns?page=1&limit=5')
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.data.campaigns.length).toBeLessThanOrEqual(5);
|
||||
expect(response.body.data.pagination.page).toBe(1);
|
||||
expect(response.body.data.pagination.limit).toBe(5);
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const response = await request(app)
|
||||
.get('/api/v1/orchestrator/campaigns');
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/orchestrator/campaigns', () => {
|
||||
it('should create a new campaign', async () => {
|
||||
const campaignData = createCampaign();
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/v1/orchestrator/campaigns')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send(campaignData);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body.success).toBe(true);
|
||||
expect(response.body.data.campaign).toHaveProperty('id');
|
||||
expect(response.body.data.campaign.name).toBe(campaignData.name);
|
||||
expect(response.body.data.campaign.type).toBe(campaignData.type);
|
||||
});
|
||||
|
||||
it('should validate required fields', async () => {
|
||||
const response = await request(app)
|
||||
.post('/api/v1/orchestrator/campaigns')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({
|
||||
// Missing required fields
|
||||
description: 'Test campaign'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.success).toBe(false);
|
||||
expect(response.body.error).toContain('required');
|
||||
});
|
||||
|
||||
it('should validate campaign type', async () => {
|
||||
const campaignData = createCampaign({ type: 'invalid_type' });
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/v1/orchestrator/campaigns')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send(campaignData);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.success).toBe(false);
|
||||
expect(response.body.error).toContain('type');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v1/orchestrator/campaigns/:id', () => {
|
||||
let campaignId;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create a campaign
|
||||
const campaignData = createCampaign();
|
||||
const createResponse = await request(app)
|
||||
.post('/api/v1/orchestrator/campaigns')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send(campaignData);
|
||||
|
||||
campaignId = createResponse.body.data.campaign.id;
|
||||
});
|
||||
|
||||
it('should get campaign by ID', async () => {
|
||||
const response = await request(app)
|
||||
.get(`/api/v1/orchestrator/campaigns/${campaignId}`)
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.success).toBe(true);
|
||||
expect(response.body.data.campaign.id).toBe(campaignId);
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent campaign', async () => {
|
||||
const response = await request(app)
|
||||
.get('/api/v1/orchestrator/campaigns/nonexistent123')
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.success).toBe(false);
|
||||
expect(response.body.error).toContain('not found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /api/v1/orchestrator/campaigns/:id', () => {
|
||||
let campaignId;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create a campaign
|
||||
const campaignData = createCampaign();
|
||||
const createResponse = await request(app)
|
||||
.post('/api/v1/orchestrator/campaigns')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send(campaignData);
|
||||
|
||||
campaignId = createResponse.body.data.campaign.id;
|
||||
});
|
||||
|
||||
it('should update campaign', async () => {
|
||||
const updates = {
|
||||
name: 'Updated Campaign Name',
|
||||
description: 'Updated description'
|
||||
};
|
||||
|
||||
const response = await request(app)
|
||||
.put(`/api/v1/orchestrator/campaigns/${campaignId}`)
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send(updates);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.success).toBe(true);
|
||||
expect(response.body.data.campaign.name).toBe(updates.name);
|
||||
expect(response.body.data.campaign.description).toBe(updates.description);
|
||||
});
|
||||
|
||||
it('should not update campaign type', async () => {
|
||||
const response = await request(app)
|
||||
.put(`/api/v1/orchestrator/campaigns/${campaignId}`)
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({ type: 'different_type' });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.success).toBe(false);
|
||||
expect(response.body.error).toContain('type');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/orchestrator/campaigns/:id/execute', () => {
|
||||
let campaignId;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create users for targeting
|
||||
const users = await Promise.all([
|
||||
createTelegramUser(),
|
||||
createTelegramUser(),
|
||||
createTelegramUser()
|
||||
]);
|
||||
|
||||
// Create a campaign with users
|
||||
const campaignData = createCampaign({
|
||||
status: 'active',
|
||||
targeting: {
|
||||
includedUsers: users.map(u => u.telegramId)
|
||||
}
|
||||
});
|
||||
|
||||
const createResponse = await request(app)
|
||||
.post('/api/v1/orchestrator/campaigns')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send(campaignData);
|
||||
|
||||
campaignId = createResponse.body.data.campaign.id;
|
||||
});
|
||||
|
||||
it('should execute campaign', async () => {
|
||||
const response = await request(app)
|
||||
.post(`/api/v1/orchestrator/campaigns/${campaignId}/execute`)
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({ test: false });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.success).toBe(true);
|
||||
expect(response.body.data).toHaveProperty('executionId');
|
||||
expect(response.body.data.status).toBe('running');
|
||||
});
|
||||
|
||||
it('should execute campaign in test mode', async () => {
|
||||
const response = await request(app)
|
||||
.post(`/api/v1/orchestrator/campaigns/${campaignId}/execute`)
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({
|
||||
test: true,
|
||||
testUsers: ['testUser123']
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.success).toBe(true);
|
||||
expect(response.body.data).toHaveProperty('executionId');
|
||||
});
|
||||
|
||||
it('should not execute draft campaign', async () => {
|
||||
// Create draft campaign
|
||||
const draftCampaign = createCampaign({ status: 'draft' });
|
||||
const createResponse = await request(app)
|
||||
.post('/api/v1/orchestrator/campaigns')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send(draftCampaign);
|
||||
|
||||
const draftId = createResponse.body.data.campaign.id;
|
||||
|
||||
const response = await request(app)
|
||||
.post(`/api/v1/orchestrator/campaigns/${draftId}/execute`)
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({ test: false });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.success).toBe(false);
|
||||
expect(response.body.error).toContain('not active');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v1/orchestrator/campaigns/:id/statistics', () => {
|
||||
let campaignId;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create and execute a campaign
|
||||
const campaignData = createCampaign({
|
||||
status: 'active',
|
||||
statistics: {
|
||||
messagesSent: 100,
|
||||
delivered: 95,
|
||||
read: 80,
|
||||
clicked: 20,
|
||||
conversions: 10
|
||||
}
|
||||
});
|
||||
|
||||
const createResponse = await request(app)
|
||||
.post('/api/v1/orchestrator/campaigns')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send(campaignData);
|
||||
|
||||
campaignId = createResponse.body.data.campaign.id;
|
||||
});
|
||||
|
||||
it('should get campaign statistics', async () => {
|
||||
const response = await request(app)
|
||||
.get(`/api/v1/orchestrator/campaigns/${campaignId}/statistics`)
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.success).toBe(true);
|
||||
expect(response.body.data.statistics).toHaveProperty('overview');
|
||||
expect(response.body.data.statistics.overview).toHaveProperty('deliveryRate');
|
||||
expect(response.body.data.statistics.overview).toHaveProperty('readRate');
|
||||
expect(response.body.data.statistics.overview).toHaveProperty('conversionRate');
|
||||
});
|
||||
|
||||
it('should filter statistics by date range', async () => {
|
||||
const response = await request(app)
|
||||
.get(`/api/v1/orchestrator/campaigns/${campaignId}/statistics?dateRange=last7days`)
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.success).toBe(true);
|
||||
expect(response.body.data.statistics).toHaveProperty('timeline');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /api/v1/orchestrator/campaigns/:id', () => {
|
||||
let campaignId;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create a campaign
|
||||
const campaignData = createCampaign({ status: 'draft' });
|
||||
const createResponse = await request(app)
|
||||
.post('/api/v1/orchestrator/campaigns')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send(campaignData);
|
||||
|
||||
campaignId = createResponse.body.data.campaign.id;
|
||||
});
|
||||
|
||||
it('should delete campaign', async () => {
|
||||
const response = await request(app)
|
||||
.delete(`/api/v1/orchestrator/campaigns/${campaignId}`)
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.success).toBe(true);
|
||||
expect(response.body.message).toContain('deleted');
|
||||
|
||||
// Verify deletion
|
||||
const getResponse = await request(app)
|
||||
.get(`/api/v1/orchestrator/campaigns/${campaignId}`)
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
expect(getResponse.status).toBe(404);
|
||||
});
|
||||
|
||||
it('should not delete active campaign', async () => {
|
||||
// Create active campaign
|
||||
const activeCampaign = createCampaign({ status: 'active' });
|
||||
const createResponse = await request(app)
|
||||
.post('/api/v1/orchestrator/campaigns')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send(activeCampaign);
|
||||
|
||||
const activeId = createResponse.body.data.campaign.id;
|
||||
|
||||
const response = await request(app)
|
||||
.delete(`/api/v1/orchestrator/campaigns/${activeId}`)
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.success).toBe(false);
|
||||
expect(response.body.error).toContain('Cannot delete active campaign');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user