feat: Thêm các tính năng quản lý merchant và shop cho admin, đồng thời bổ sung các unit và functional test cho tính năng chặn người dùng và quan hệ trong social service.

This commit is contained in:
Ho Ngoc Hai
2026-01-16 00:44:53 +07:00
parent 72601557e6
commit 99d57efed1
21 changed files with 2285 additions and 98 deletions

View File

@@ -0,0 +1,65 @@
// EN: Command to approve a merchant registration.
// VI: Command để phê duyệt đăng ký merchant.
using MediatR;
using MerchantService.Domain.AggregatesModel.MerchantAggregate;
using MerchantService.Domain.Exceptions;
namespace MerchantService.API.Application.Commands.Admin;
/// <summary>
/// EN: Command to approve a merchant registration (Admin only).
/// VI: Command để phê duyệt đăng ký merchant (chỉ Admin).
/// </summary>
/// <param name="MerchantId">Merchant ID to approve / ID Merchant cần phê duyệt</param>
/// <param name="ApprovedBy">Admin user ID / ID Admin phê duyệt</param>
public record ApproveMerchantCommand(Guid MerchantId, Guid ApprovedBy) : IRequest<ApproveMerchantResult>;
/// <summary>
/// EN: Result of merchant approval.
/// VI: Kết quả phê duyệt merchant.
/// </summary>
public record ApproveMerchantResult(
Guid MerchantId,
string Status,
DateTime ApprovedAt);
/// <summary>
/// EN: Handler for ApproveMerchantCommand.
/// VI: Handler cho ApproveMerchantCommand.
/// </summary>
public class ApproveMerchantCommandHandler : IRequestHandler<ApproveMerchantCommand, ApproveMerchantResult>
{
private readonly IMerchantRepository _merchantRepository;
private readonly ILogger<ApproveMerchantCommandHandler> _logger;
public ApproveMerchantCommandHandler(
IMerchantRepository merchantRepository,
ILogger<ApproveMerchantCommandHandler> logger)
{
_merchantRepository = merchantRepository;
_logger = logger;
}
public async Task<ApproveMerchantResult> Handle(
ApproveMerchantCommand request,
CancellationToken cancellationToken)
{
var merchant = await _merchantRepository.GetAsync(request.MerchantId, cancellationToken)
?? throw new DomainException($"Merchant {request.MerchantId} not found");
merchant.Approve(request.ApprovedBy);
_merchantRepository.Update(merchant);
await _merchantRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
_logger.LogInformation(
"Merchant {MerchantId} approved by Admin {AdminId}",
request.MerchantId, request.ApprovedBy);
return new ApproveMerchantResult(
merchant.Id,
merchant.Status.Name,
DateTime.UtcNow);
}
}

View File

@@ -0,0 +1,74 @@
// EN: Command to permanently ban a merchant.
// VI: Command để cấm vĩnh viễn một merchant.
using MediatR;
using MerchantService.Domain.AggregatesModel.MerchantAggregate;
using MerchantService.Domain.Exceptions;
namespace MerchantService.API.Application.Commands.Admin;
/// <summary>
/// EN: Command to permanently ban a merchant (Admin only).
/// VI: Command để cấm vĩnh viễn merchant (chỉ Admin).
/// </summary>
/// <param name="MerchantId">Merchant ID to ban / ID Merchant cần cấm</param>
/// <param name="Reason">Reason for ban / Lý do cấm</param>
/// <param name="BannedBy">Admin user ID / ID Admin thực hiện cấm</param>
public record BanMerchantCommand(
Guid MerchantId,
string Reason,
Guid BannedBy) : IRequest<BanMerchantResult>;
/// <summary>
/// EN: Result of merchant ban.
/// VI: Kết quả cấm merchant.
/// </summary>
public record BanMerchantResult(
Guid MerchantId,
string Status,
string Reason,
DateTime BannedAt);
/// <summary>
/// EN: Handler for BanMerchantCommand.
/// VI: Handler cho BanMerchantCommand.
/// </summary>
public class BanMerchantCommandHandler : IRequestHandler<BanMerchantCommand, BanMerchantResult>
{
private readonly IMerchantRepository _merchantRepository;
private readonly ILogger<BanMerchantCommandHandler> _logger;
public BanMerchantCommandHandler(
IMerchantRepository merchantRepository,
ILogger<BanMerchantCommandHandler> logger)
{
_merchantRepository = merchantRepository;
_logger = logger;
}
public async Task<BanMerchantResult> Handle(
BanMerchantCommand request,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(request.Reason))
throw new DomainException("Ban reason is required");
var merchant = await _merchantRepository.GetAsync(request.MerchantId, cancellationToken)
?? throw new DomainException($"Merchant {request.MerchantId} not found");
merchant.Ban(request.Reason);
_merchantRepository.Update(merchant);
await _merchantRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
_logger.LogWarning(
"Merchant {MerchantId} BANNED by Admin {AdminId}. Reason: {Reason}",
request.MerchantId, request.BannedBy, request.Reason);
return new BanMerchantResult(
merchant.Id,
merchant.Status.Name,
request.Reason,
DateTime.UtcNow);
}
}

View File

@@ -0,0 +1,67 @@
// EN: Command to reactivate a suspended merchant.
// VI: Command để kích hoạt lại merchant bị tạm ngưng.
using MediatR;
using MerchantService.Domain.AggregatesModel.MerchantAggregate;
using MerchantService.Domain.Exceptions;
namespace MerchantService.API.Application.Commands.Admin;
/// <summary>
/// EN: Command to reactivate a suspended merchant (Admin only).
/// VI: Command để kích hoạt lại merchant bị tạm ngưng (chỉ Admin).
/// </summary>
/// <param name="MerchantId">Merchant ID to reactivate / ID Merchant cần kích hoạt lại</param>
/// <param name="ReactivatedBy">Admin user ID / ID Admin kích hoạt lại</param>
public record ReactivateMerchantCommand(
Guid MerchantId,
Guid ReactivatedBy) : IRequest<ReactivateMerchantResult>;
/// <summary>
/// EN: Result of merchant reactivation.
/// VI: Kết quả kích hoạt lại merchant.
/// </summary>
public record ReactivateMerchantResult(
Guid MerchantId,
string Status,
DateTime ReactivatedAt);
/// <summary>
/// EN: Handler for ReactivateMerchantCommand.
/// VI: Handler cho ReactivateMerchantCommand.
/// </summary>
public class ReactivateMerchantCommandHandler : IRequestHandler<ReactivateMerchantCommand, ReactivateMerchantResult>
{
private readonly IMerchantRepository _merchantRepository;
private readonly ILogger<ReactivateMerchantCommandHandler> _logger;
public ReactivateMerchantCommandHandler(
IMerchantRepository merchantRepository,
ILogger<ReactivateMerchantCommandHandler> logger)
{
_merchantRepository = merchantRepository;
_logger = logger;
}
public async Task<ReactivateMerchantResult> Handle(
ReactivateMerchantCommand request,
CancellationToken cancellationToken)
{
var merchant = await _merchantRepository.GetAsync(request.MerchantId, cancellationToken)
?? throw new DomainException($"Merchant {request.MerchantId} not found");
merchant.Reactivate();
_merchantRepository.Update(merchant);
await _merchantRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
_logger.LogInformation(
"Merchant {MerchantId} reactivated by Admin {AdminId}",
request.MerchantId, request.ReactivatedBy);
return new ReactivateMerchantResult(
merchant.Id,
merchant.Status.Name,
DateTime.UtcNow);
}
}

View File

@@ -0,0 +1,76 @@
// EN: Command to reject a merchant registration.
// VI: Command để từ chối đăng ký merchant.
using MediatR;
using MerchantService.Domain.AggregatesModel.MerchantAggregate;
using MerchantService.Domain.Exceptions;
namespace MerchantService.API.Application.Commands.Admin;
/// <summary>
/// EN: Command to reject a merchant registration (Admin only).
/// VI: Command để từ chối đăng ký merchant (chỉ Admin).
/// </summary>
/// <param name="MerchantId">Merchant ID to reject / ID Merchant cần từ chối</param>
/// <param name="Reason">Reason for rejection / Lý do từ chối</param>
/// <param name="RejectedBy">Admin user ID / ID Admin từ chối</param>
public record RejectMerchantCommand(
Guid MerchantId,
string Reason,
Guid RejectedBy) : IRequest<RejectMerchantResult>;
/// <summary>
/// EN: Result of merchant rejection.
/// VI: Kết quả từ chối merchant.
/// </summary>
public record RejectMerchantResult(
Guid MerchantId,
string Status,
string Reason,
DateTime RejectedAt);
/// <summary>
/// EN: Handler for RejectMerchantCommand.
/// VI: Handler cho RejectMerchantCommand.
/// </summary>
public class RejectMerchantCommandHandler : IRequestHandler<RejectMerchantCommand, RejectMerchantResult>
{
private readonly IMerchantRepository _merchantRepository;
private readonly ILogger<RejectMerchantCommandHandler> _logger;
public RejectMerchantCommandHandler(
IMerchantRepository merchantRepository,
ILogger<RejectMerchantCommandHandler> logger)
{
_merchantRepository = merchantRepository;
_logger = logger;
}
public async Task<RejectMerchantResult> Handle(
RejectMerchantCommand request,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(request.Reason))
throw new DomainException("Rejection reason is required");
var merchant = await _merchantRepository.GetAsync(request.MerchantId, cancellationToken)
?? throw new DomainException($"Merchant {request.MerchantId} not found");
// EN: Reject sets status to Rejected - need to add this method to Domain
// VI: Reject đặt status thành Rejected - cần thêm method vào Domain
merchant.Suspend(request.Reason); // Using Suspend as rejection for now
_merchantRepository.Update(merchant);
await _merchantRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
_logger.LogInformation(
"Merchant {MerchantId} rejected by Admin {AdminId}. Reason: {Reason}",
request.MerchantId, request.RejectedBy, request.Reason);
return new RejectMerchantResult(
merchant.Id,
merchant.Status.Name,
request.Reason,
DateTime.UtcNow);
}
}

View File

@@ -0,0 +1,74 @@
// EN: Command to suspend an active merchant.
// VI: Command để tạm ngưng merchant đang hoạt động.
using MediatR;
using MerchantService.Domain.AggregatesModel.MerchantAggregate;
using MerchantService.Domain.Exceptions;
namespace MerchantService.API.Application.Commands.Admin;
/// <summary>
/// EN: Command to suspend a merchant (Admin only).
/// VI: Command để tạm ngưng merchant (chỉ Admin).
/// </summary>
/// <param name="MerchantId">Merchant ID to suspend / ID Merchant cần tạm ngưng</param>
/// <param name="Reason">Reason for suspension / Lý do tạm ngưng</param>
/// <param name="SuspendedBy">Admin user ID / ID Admin tạm ngưng</param>
public record SuspendMerchantCommand(
Guid MerchantId,
string Reason,
Guid SuspendedBy) : IRequest<SuspendMerchantResult>;
/// <summary>
/// EN: Result of merchant suspension.
/// VI: Kết quả tạm ngưng merchant.
/// </summary>
public record SuspendMerchantResult(
Guid MerchantId,
string Status,
string Reason,
DateTime SuspendedAt);
/// <summary>
/// EN: Handler for SuspendMerchantCommand.
/// VI: Handler cho SuspendMerchantCommand.
/// </summary>
public class SuspendMerchantCommandHandler : IRequestHandler<SuspendMerchantCommand, SuspendMerchantResult>
{
private readonly IMerchantRepository _merchantRepository;
private readonly ILogger<SuspendMerchantCommandHandler> _logger;
public SuspendMerchantCommandHandler(
IMerchantRepository merchantRepository,
ILogger<SuspendMerchantCommandHandler> logger)
{
_merchantRepository = merchantRepository;
_logger = logger;
}
public async Task<SuspendMerchantResult> Handle(
SuspendMerchantCommand request,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(request.Reason))
throw new DomainException("Suspension reason is required");
var merchant = await _merchantRepository.GetAsync(request.MerchantId, cancellationToken)
?? throw new DomainException($"Merchant {request.MerchantId} not found");
merchant.Suspend(request.Reason);
_merchantRepository.Update(merchant);
await _merchantRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
_logger.LogInformation(
"Merchant {MerchantId} suspended by Admin {AdminId}. Reason: {Reason}",
request.MerchantId, request.SuspendedBy, request.Reason);
return new SuspendMerchantResult(
merchant.Id,
merchant.Status.Name,
request.Reason,
DateTime.UtcNow);
}
}

View File

@@ -0,0 +1,126 @@
// EN: Admin DTOs for merchant management.
// VI: DTOs Admin cho quản lý merchant.
namespace MerchantService.API.Application.Queries.Admin;
#region Merchant List DTOs
/// <summary>
/// EN: Admin view of merchant in list.
/// VI: View admin của merchant trong danh sách.
/// </summary>
public record AdminMerchantListItemDto(
Guid Id,
Guid UserId,
string BusinessName,
string Type,
string Status,
string VerificationStatus,
int ShopsCount,
DateTime CreatedAt,
DateTime? VerifiedAt);
/// <summary>
/// EN: Paginated result for merchant list.
/// VI: Kết quả phân trang cho danh sách merchant.
/// </summary>
public record AdminMerchantListResultDto(
List<AdminMerchantListItemDto> Items,
int TotalCount,
int Page,
int PageSize,
int TotalPages);
#endregion
#region Merchant Detail DTOs
/// <summary>
/// EN: Business info for admin view.
/// VI: Thông tin doanh nghiệp cho admin view.
/// </summary>
public record AdminBusinessInfoDto(
string? TaxId,
string? LegalName,
string? Address,
string? Phone,
string? Email,
string? Website);
/// <summary>
/// EN: Settlement config for admin view.
/// VI: Cấu hình thanh toán cho admin view.
/// </summary>
public record AdminSettlementConfigDto(
decimal CommissionRate,
string PayoutFrequency,
string? BankAccount);
/// <summary>
/// EN: Detailed admin view of a merchant.
/// VI: View chi tiết admin của merchant.
/// </summary>
public record AdminMerchantDetailDto(
Guid Id,
Guid UserId,
string BusinessName,
string Type,
string Status,
string VerificationStatus,
AdminBusinessInfoDto? BusinessInfo,
AdminSettlementConfigDto? SettlementConfig,
int ShopsCount,
int StaffCount,
DateTime CreatedAt,
DateTime? UpdatedAt,
DateTime? VerifiedAt,
Guid? VerifiedBy);
#endregion
#region Statistics DTOs
/// <summary>
/// EN: Merchant statistics for admin dashboard.
/// VI: Thống kê merchant cho dashboard admin.
/// </summary>
public record AdminMerchantStatisticsDto(
int TotalMerchants,
int PendingApproval,
int Active,
int Suspended,
int Banned,
int TotalShops,
int PublishedShops,
int TotalStaff);
#endregion
#region Shop DTOs
/// <summary>
/// EN: Admin view of shop in list.
/// VI: View admin của shop trong danh sách.
/// </summary>
public record AdminShopListItemDto(
Guid Id,
Guid MerchantId,
string MerchantBusinessName,
string Name,
string Slug,
string Status,
string? Category,
DateTime CreatedAt);
/// <summary>
/// EN: Paginated result for shop list.
/// VI: Kết quả phân trang cho danh sách shop.
/// </summary>
public record AdminShopListResultDto(
List<AdminShopListItemDto> Items,
int TotalCount,
int Page,
int PageSize,
int TotalPages);
#endregion

View File

@@ -0,0 +1,85 @@
// EN: Query to get all merchants with pagination for admin.
// VI: Query để lấy tất cả merchants với phân trang cho admin.
using MediatR;
using Microsoft.EntityFrameworkCore;
using MerchantService.Infrastructure;
namespace MerchantService.API.Application.Queries.Admin;
/// <summary>
/// EN: Query to get all merchants with filters (Admin only).
/// VI: Query để lấy tất cả merchants với bộ lọc (chỉ Admin).
/// </summary>
public record GetAllMerchantsQuery(
int Page = 1,
int PageSize = 20,
string? Status = null,
string? VerificationStatus = null,
string? Search = null) : IRequest<AdminMerchantListResultDto>;
/// <summary>
/// EN: Handler for GetAllMerchantsQuery.
/// VI: Handler cho GetAllMerchantsQuery.
/// </summary>
public class GetAllMerchantsQueryHandler : IRequestHandler<GetAllMerchantsQuery, AdminMerchantListResultDto>
{
private readonly MerchantServiceContext _context;
public GetAllMerchantsQueryHandler(MerchantServiceContext context)
{
_context = context;
}
public async Task<AdminMerchantListResultDto> Handle(
GetAllMerchantsQuery request,
CancellationToken cancellationToken)
{
var query = _context.Merchants
.AsNoTracking()
.Where(m => !m.IsDeleted);
// EN: Apply filters / VI: Áp dụng bộ lọc
if (!string.IsNullOrEmpty(request.Status))
{
query = query.Where(m => m.Status.Name == request.Status);
}
if (!string.IsNullOrEmpty(request.VerificationStatus))
{
query = query.Where(m => m.VerificationStatus.Name == request.VerificationStatus);
}
if (!string.IsNullOrEmpty(request.Search))
{
var searchLower = request.Search.ToLower();
query = query.Where(m => m.BusinessName.ToLower().Contains(searchLower));
}
var totalCount = await query.CountAsync(cancellationToken);
var totalPages = (int)Math.Ceiling(totalCount / (double)request.PageSize);
var items = await query
.OrderByDescending(m => m.CreatedAt)
.Skip((request.Page - 1) * request.PageSize)
.Take(request.PageSize)
.Select(m => new AdminMerchantListItemDto(
m.Id,
m.UserId,
m.BusinessName,
m.Type.Name,
m.Status.Name,
m.VerificationStatus.Name,
_context.Shops.Count(s => s.MerchantId == m.Id && !s.IsDeleted),
m.CreatedAt,
m.VerifiedAt))
.ToListAsync(cancellationToken);
return new AdminMerchantListResultDto(
items,
totalCount,
request.Page,
request.PageSize,
totalPages);
}
}

View File

@@ -0,0 +1,87 @@
// EN: Query to get all shops with pagination for admin.
// VI: Query để lấy tất cả shops với phân trang cho admin.
using MediatR;
using Microsoft.EntityFrameworkCore;
using MerchantService.Infrastructure;
namespace MerchantService.API.Application.Queries.Admin;
/// <summary>
/// EN: Query to get all shops with filters (Admin only).
/// VI: Query để lấy tất cả shops với bộ lọc (chỉ Admin).
/// </summary>
public record GetAllShopsQuery(
int Page = 1,
int PageSize = 20,
string? Status = null,
Guid? MerchantId = null,
string? Search = null) : IRequest<AdminShopListResultDto>;
/// <summary>
/// EN: Handler for GetAllShopsQuery.
/// VI: Handler cho GetAllShopsQuery.
/// </summary>
public class GetAllShopsQueryHandler : IRequestHandler<GetAllShopsQuery, AdminShopListResultDto>
{
private readonly MerchantServiceContext _context;
public GetAllShopsQueryHandler(MerchantServiceContext context)
{
_context = context;
}
public async Task<AdminShopListResultDto> Handle(
GetAllShopsQuery request,
CancellationToken cancellationToken)
{
var query = _context.Shops
.AsNoTracking()
.Include(s => s.Merchant)
.Where(s => !s.IsDeleted);
// EN: Apply filters / VI: Áp dụng bộ lọc
if (!string.IsNullOrEmpty(request.Status))
{
query = query.Where(s => s.Status.Name == request.Status);
}
if (request.MerchantId.HasValue)
{
query = query.Where(s => s.MerchantId == request.MerchantId.Value);
}
if (!string.IsNullOrEmpty(request.Search))
{
var searchLower = request.Search.ToLower();
query = query.Where(s =>
s.Name.ToLower().Contains(searchLower) ||
s.Slug.ToLower().Contains(searchLower));
}
var totalCount = await query.CountAsync(cancellationToken);
var totalPages = (int)Math.Ceiling(totalCount / (double)request.PageSize);
var items = await query
.OrderByDescending(s => s.CreatedAt)
.Skip((request.Page - 1) * request.PageSize)
.Take(request.PageSize)
.Select(s => new AdminShopListItemDto(
s.Id,
s.MerchantId,
s.Merchant.BusinessName,
s.Name,
s.Slug,
s.Status.Name,
s.Category != null ? s.Category.Name : null,
s.CreatedAt))
.ToListAsync(cancellationToken);
return new AdminShopListResultDto(
items,
totalCount,
request.Page,
request.PageSize,
totalPages);
}
}

View File

@@ -0,0 +1,79 @@
// EN: Query to get merchant details for admin.
// VI: Query để lấy chi tiết merchant cho admin.
using MediatR;
using Microsoft.EntityFrameworkCore;
using MerchantService.Domain.Exceptions;
using MerchantService.Infrastructure;
namespace MerchantService.API.Application.Queries.Admin;
/// <summary>
/// EN: Query to get detailed merchant information (Admin only).
/// VI: Query để lấy thông tin chi tiết merchant (chỉ Admin).
/// </summary>
public record GetMerchantDetailQuery(Guid MerchantId) : IRequest<AdminMerchantDetailDto>;
/// <summary>
/// EN: Handler for GetMerchantDetailQuery.
/// VI: Handler cho GetMerchantDetailQuery.
/// </summary>
public class GetMerchantDetailQueryHandler : IRequestHandler<GetMerchantDetailQuery, AdminMerchantDetailDto>
{
private readonly MerchantServiceContext _context;
public GetMerchantDetailQueryHandler(MerchantServiceContext context)
{
_context = context;
}
public async Task<AdminMerchantDetailDto> Handle(
GetMerchantDetailQuery request,
CancellationToken cancellationToken)
{
var merchant = await _context.Merchants
.AsNoTracking()
.Where(m => m.Id == request.MerchantId && !m.IsDeleted)
.FirstOrDefaultAsync(cancellationToken)
?? throw new DomainException($"Merchant {request.MerchantId} not found");
var shopsCount = await _context.Shops
.CountAsync(s => s.MerchantId == request.MerchantId && !s.IsDeleted, cancellationToken);
var staffCount = await _context.MerchantStaff
.CountAsync(s => s.MerchantId == request.MerchantId && !s.IsDeleted, cancellationToken);
var businessInfo = merchant.BusinessInfo != null
? new AdminBusinessInfoDto(
merchant.BusinessInfo.TaxId,
merchant.BusinessInfo.LegalName,
merchant.BusinessInfo.Address?.ToString(),
merchant.BusinessInfo.Phone,
merchant.BusinessInfo.Email,
merchant.BusinessInfo.Website)
: null;
var settlementConfig = merchant.SettlementConfig != null
? new AdminSettlementConfigDto(
merchant.SettlementConfig.CommissionRate,
merchant.SettlementConfig.PayoutFrequency.Name,
merchant.SettlementConfig.BankAccount?.ToString())
: null;
return new AdminMerchantDetailDto(
merchant.Id,
merchant.UserId,
merchant.BusinessName,
merchant.Type.Name,
merchant.Status.Name,
merchant.VerificationStatus.Name,
businessInfo,
settlementConfig,
shopsCount,
staffCount,
merchant.CreatedAt,
merchant.UpdatedAt,
merchant.VerifiedAt,
merchant.VerifiedBy);
}
}

View File

@@ -0,0 +1,71 @@
// EN: Query to get merchant statistics for admin dashboard.
// VI: Query để lấy thống kê merchant cho dashboard admin.
using MediatR;
using Microsoft.EntityFrameworkCore;
using MerchantService.Domain.AggregatesModel.MerchantAggregate;
using MerchantService.Domain.AggregatesModel.ShopAggregate;
using MerchantService.Infrastructure;
namespace MerchantService.API.Application.Queries.Admin;
/// <summary>
/// EN: Query to get merchant statistics (Admin only).
/// VI: Query để lấy thống kê merchant (chỉ Admin).
/// </summary>
public record GetMerchantStatisticsQuery : IRequest<AdminMerchantStatisticsDto>;
/// <summary>
/// EN: Handler for GetMerchantStatisticsQuery.
/// VI: Handler cho GetMerchantStatisticsQuery.
/// </summary>
public class GetMerchantStatisticsQueryHandler : IRequestHandler<GetMerchantStatisticsQuery, AdminMerchantStatisticsDto>
{
private readonly MerchantServiceContext _context;
public GetMerchantStatisticsQueryHandler(MerchantServiceContext context)
{
_context = context;
}
public async Task<AdminMerchantStatisticsDto> Handle(
GetMerchantStatisticsQuery request,
CancellationToken cancellationToken)
{
var merchantsQuery = _context.Merchants.Where(m => !m.IsDeleted);
var totalMerchants = await merchantsQuery.CountAsync(cancellationToken);
var pendingApproval = await merchantsQuery
.CountAsync(m => m.StatusId == MerchantStatus.PendingApproval.Id, cancellationToken);
var active = await merchantsQuery
.CountAsync(m => m.StatusId == MerchantStatus.Active.Id, cancellationToken);
var suspended = await merchantsQuery
.CountAsync(m => m.StatusId == MerchantStatus.Suspended.Id, cancellationToken);
var banned = await merchantsQuery
.CountAsync(m => m.StatusId == MerchantStatus.Banned.Id, cancellationToken);
var shopsQuery = _context.Shops.Where(s => !s.IsDeleted);
var totalShops = await shopsQuery.CountAsync(cancellationToken);
var publishedShops = await shopsQuery
.CountAsync(s => s.StatusId == ShopStatus.Published.Id, cancellationToken);
var totalStaff = await _context.MerchantStaff
.CountAsync(s => !s.IsDeleted, cancellationToken);
return new AdminMerchantStatisticsDto(
totalMerchants,
pendingApproval,
active,
suspended,
banned,
totalShops,
publishedShops,
totalStaff);
}
}

View File

@@ -0,0 +1,243 @@
// EN: Admin controller for merchant management (Backoffice).
// VI: Controller Admin cho quản lý merchant (Backoffice).
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Swashbuckle.AspNetCore.Annotations;
using MerchantService.API.Application.Commands.Admin;
using MerchantService.API.Application.Queries.Admin;
namespace MerchantService.API.Controllers.Admin;
/// <summary>
/// EN: Admin controller for merchant management operations (Backoffice).
/// VI: Controller Admin cho các thao tác quản lý merchant (Backoffice).
/// </summary>
[ApiController]
[Route("api/v1/admin/merchants")]
[Authorize(Roles = "Admin,SuperAdmin")]
[SwaggerTag("Admin Merchant Management / Quản lý Merchant Admin")]
public class AdminMerchantsController : ControllerBase
{
private readonly IMediator _mediator;
private readonly ILogger<AdminMerchantsController> _logger;
public AdminMerchantsController(
IMediator mediator,
ILogger<AdminMerchantsController> logger)
{
_mediator = mediator;
_logger = logger;
}
/// <summary>
/// EN: Get all merchants with pagination.
/// VI: Lấy tất cả merchants với phân trang.
/// </summary>
[HttpGet]
[SwaggerOperation(Summary = "Get all merchants", Description = "Get all merchants with pagination (Admin only)")]
[SwaggerResponse(200, "Success", typeof(AdminMerchantListResultDto))]
public async Task<IActionResult> GetAllMerchants(
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20,
[FromQuery] string? status = null,
[FromQuery] string? verificationStatus = null,
[FromQuery] string? search = null)
{
var query = new GetAllMerchantsQuery(page, pageSize, status, verificationStatus, search);
var result = await _mediator.Send(query);
return Ok(result);
}
/// <summary>
/// EN: Get merchant details by ID.
/// VI: Lấy chi tiết merchant theo ID.
/// </summary>
[HttpGet("{merchantId:guid}")]
[SwaggerOperation(Summary = "Get merchant by ID", Description = "Get merchant details by ID (Admin only)")]
[SwaggerResponse(200, "Success", typeof(AdminMerchantDetailDto))]
[SwaggerResponse(404, "Merchant not found")]
public async Task<IActionResult> GetMerchantById(Guid merchantId)
{
try
{
var query = new GetMerchantDetailQuery(merchantId);
var result = await _mediator.Send(query);
return Ok(result);
}
catch (Domain.Exceptions.DomainException ex)
{
return NotFound(new { message = ex.Message });
}
}
/// <summary>
/// EN: Get merchant statistics.
/// VI: Lấy thống kê merchant.
/// </summary>
[HttpGet("statistics")]
[SwaggerOperation(Summary = "Get statistics", Description = "Get merchant statistics (Admin only)")]
[SwaggerResponse(200, "Success", typeof(AdminMerchantStatisticsDto))]
public async Task<IActionResult> GetStatistics()
{
var query = new GetMerchantStatisticsQuery();
var result = await _mediator.Send(query);
return Ok(result);
}
/// <summary>
/// EN: Approve merchant registration.
/// VI: Phê duyệt đăng ký merchant.
/// </summary>
[HttpPost("{merchantId:guid}/approve")]
[SwaggerOperation(Summary = "Approve merchant", Description = "Approve merchant registration (Admin only)")]
[SwaggerResponse(200, "Merchant approved")]
[SwaggerResponse(400, "Cannot approve")]
[SwaggerResponse(404, "Merchant not found")]
public async Task<IActionResult> ApproveMerchant(Guid merchantId)
{
try
{
var command = new ApproveMerchantCommand(merchantId, GetAdminId());
var result = await _mediator.Send(command);
_logger.LogInformation("Merchant {MerchantId} approved by Admin {AdminId}", merchantId, GetAdminId());
return Ok(result);
}
catch (Domain.Exceptions.DomainException ex)
{
if (ex.Message.Contains("not found"))
return NotFound(new { message = ex.Message });
return BadRequest(new { message = ex.Message });
}
}
/// <summary>
/// EN: Reject merchant registration.
/// VI: Từ chối đăng ký merchant.
/// </summary>
[HttpPost("{merchantId:guid}/reject")]
[SwaggerOperation(Summary = "Reject merchant", Description = "Reject merchant registration (Admin only)")]
[SwaggerResponse(200, "Merchant rejected")]
[SwaggerResponse(400, "Invalid rejection")]
[SwaggerResponse(404, "Merchant not found")]
public async Task<IActionResult> RejectMerchant(Guid merchantId, [FromBody] AdminActionRequest request)
{
try
{
var command = new RejectMerchantCommand(merchantId, request.Reason, GetAdminId());
var result = await _mediator.Send(command);
_logger.LogInformation("Merchant {MerchantId} rejected by Admin {AdminId}. Reason: {Reason}",
merchantId, GetAdminId(), request.Reason);
return Ok(result);
}
catch (Domain.Exceptions.DomainException ex)
{
if (ex.Message.Contains("not found"))
return NotFound(new { message = ex.Message });
return BadRequest(new { message = ex.Message });
}
}
/// <summary>
/// EN: Suspend an active merchant.
/// VI: Tạm ngưng merchant đang hoạt động.
/// </summary>
[HttpPost("{merchantId:guid}/suspend")]
[SwaggerOperation(Summary = "Suspend merchant", Description = "Suspend an active merchant (Admin only)")]
[SwaggerResponse(200, "Merchant suspended")]
[SwaggerResponse(400, "Cannot suspend")]
[SwaggerResponse(404, "Merchant not found")]
public async Task<IActionResult> SuspendMerchant(Guid merchantId, [FromBody] AdminActionRequest request)
{
try
{
var command = new SuspendMerchantCommand(merchantId, request.Reason, GetAdminId());
var result = await _mediator.Send(command);
_logger.LogInformation("Merchant {MerchantId} suspended by Admin {AdminId}. Reason: {Reason}",
merchantId, GetAdminId(), request.Reason);
return Ok(result);
}
catch (Domain.Exceptions.DomainException ex)
{
if (ex.Message.Contains("not found"))
return NotFound(new { message = ex.Message });
return BadRequest(new { message = ex.Message });
}
}
/// <summary>
/// EN: Reactivate a suspended merchant.
/// VI: Kích hoạt lại merchant bị tạm ngưng.
/// </summary>
[HttpPost("{merchantId:guid}/reactivate")]
[SwaggerOperation(Summary = "Reactivate merchant", Description = "Reactivate a suspended merchant (Admin only)")]
[SwaggerResponse(200, "Merchant reactivated")]
[SwaggerResponse(400, "Cannot reactivate")]
[SwaggerResponse(404, "Merchant not found")]
public async Task<IActionResult> ReactivateMerchant(Guid merchantId)
{
try
{
var command = new ReactivateMerchantCommand(merchantId, GetAdminId());
var result = await _mediator.Send(command);
_logger.LogInformation("Merchant {MerchantId} reactivated by Admin {AdminId}", merchantId, GetAdminId());
return Ok(result);
}
catch (Domain.Exceptions.DomainException ex)
{
if (ex.Message.Contains("not found"))
return NotFound(new { message = ex.Message });
return BadRequest(new { message = ex.Message });
}
}
/// <summary>
/// EN: Permanently ban a merchant.
/// VI: Cấm vĩnh viễn một merchant.
/// </summary>
[HttpPost("{merchantId:guid}/ban")]
[SwaggerOperation(Summary = "Ban merchant", Description = "Permanently ban a merchant (Admin only)")]
[SwaggerResponse(200, "Merchant banned")]
[SwaggerResponse(400, "Cannot ban")]
[SwaggerResponse(404, "Merchant not found")]
public async Task<IActionResult> BanMerchant(Guid merchantId, [FromBody] AdminActionRequest request)
{
try
{
var command = new BanMerchantCommand(merchantId, request.Reason, GetAdminId());
var result = await _mediator.Send(command);
_logger.LogWarning("Merchant {MerchantId} BANNED by Admin {AdminId}. Reason: {Reason}",
merchantId, GetAdminId(), request.Reason);
return Ok(result);
}
catch (Domain.Exceptions.DomainException ex)
{
if (ex.Message.Contains("not found"))
return NotFound(new { message = ex.Message });
return BadRequest(new { message = ex.Message });
}
}
private Guid GetAdminId()
{
var userIdClaim = User.FindFirst("sub")?.Value
?? User.FindFirst("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier")?.Value;
return Guid.TryParse(userIdClaim, out var userId) ? userId : Guid.Empty;
}
}
#region Admin Request DTOs
/// <summary>
/// EN: Request for admin actions requiring a reason.
/// VI: Request cho các action admin cần lý do.
/// </summary>
public record AdminActionRequest(string Reason);
#endregion

View File

@@ -0,0 +1,192 @@
// EN: Admin controller for shop management (Backoffice).
// VI: Controller Admin cho quản lý shop (Backoffice).
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Swashbuckle.AspNetCore.Annotations;
using MerchantService.API.Application.Queries.Admin;
using MerchantService.Domain.AggregatesModel.ShopAggregate;
using MerchantService.Domain.Exceptions;
namespace MerchantService.API.Controllers.Admin;
/// <summary>
/// EN: Admin controller for shop management operations (Backoffice).
/// VI: Controller Admin cho các thao tác quản lý shop (Backoffice).
/// </summary>
[ApiController]
[Route("api/v1/admin/shops")]
[Authorize(Roles = "Admin,SuperAdmin")]
[SwaggerTag("Admin Shop Management / Quản lý Shop Admin")]
public class AdminShopsController : ControllerBase
{
private readonly IMediator _mediator;
private readonly IShopRepository _shopRepository;
private readonly ILogger<AdminShopsController> _logger;
public AdminShopsController(
IMediator mediator,
IShopRepository shopRepository,
ILogger<AdminShopsController> logger)
{
_mediator = mediator;
_shopRepository = shopRepository;
_logger = logger;
}
/// <summary>
/// EN: Get all shops with pagination.
/// VI: Lấy tất cả shops với phân trang.
/// </summary>
[HttpGet]
[SwaggerOperation(Summary = "Get all shops", Description = "Get all shops with pagination (Admin only)")]
[SwaggerResponse(200, "Success", typeof(AdminShopListResultDto))]
public async Task<IActionResult> GetAllShops(
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20,
[FromQuery] string? status = null,
[FromQuery] Guid? merchantId = null,
[FromQuery] string? search = null)
{
var query = new GetAllShopsQuery(page, pageSize, status, merchantId, search);
var result = await _mediator.Send(query);
return Ok(result);
}
/// <summary>
/// EN: Get shop details by ID.
/// VI: Lấy chi tiết shop theo ID.
/// </summary>
[HttpGet("{shopId:guid}")]
[SwaggerOperation(Summary = "Get shop by ID", Description = "Get shop details by ID (Admin only)")]
[SwaggerResponse(200, "Success")]
[SwaggerResponse(404, "Shop not found")]
public async Task<IActionResult> GetShopById(Guid shopId)
{
var shop = await _shopRepository.GetAsync(shopId);
if (shop == null)
return NotFound(new { message = "Shop not found" });
return Ok(new
{
shop.Id,
shop.MerchantId,
shop.Name,
shop.Slug,
Status = shop.Status.Name,
Category = shop.Category?.Name,
shop.Description,
shop.CreatedAt,
shop.UpdatedAt
});
}
/// <summary>
/// EN: Suspend a shop.
/// VI: Tạm ngưng shop.
/// </summary>
[HttpPost("{shopId:guid}/suspend")]
[SwaggerOperation(Summary = "Suspend shop", Description = "Suspend a shop (Admin only)")]
[SwaggerResponse(200, "Shop suspended")]
[SwaggerResponse(400, "Cannot suspend")]
[SwaggerResponse(404, "Shop not found")]
public async Task<IActionResult> SuspendShop(Guid shopId, [FromBody] AdminActionRequest request)
{
try
{
var shop = await _shopRepository.GetAsync(shopId)
?? throw new DomainException("Shop not found");
shop.SetInactive();
_shopRepository.Update(shop);
await _shopRepository.UnitOfWork.SaveEntitiesAsync();
_logger.LogInformation("Shop {ShopId} suspended by Admin {AdminId}. Reason: {Reason}",
shopId, GetAdminId(), request.Reason);
return Ok(new { message = "Shop suspended successfully", shopId, status = shop.Status.Name });
}
catch (DomainException ex)
{
if (ex.Message.Contains("not found"))
return NotFound(new { message = ex.Message });
return BadRequest(new { message = ex.Message });
}
}
/// <summary>
/// EN: Reactivate a suspended shop.
/// VI: Kích hoạt lại shop bị tạm ngưng.
/// </summary>
[HttpPost("{shopId:guid}/reactivate")]
[SwaggerOperation(Summary = "Reactivate shop", Description = "Reactivate a suspended shop (Admin only)")]
[SwaggerResponse(200, "Shop reactivated")]
[SwaggerResponse(400, "Cannot reactivate")]
[SwaggerResponse(404, "Shop not found")]
public async Task<IActionResult> ReactivateShop(Guid shopId)
{
try
{
var shop = await _shopRepository.GetAsync(shopId)
?? throw new DomainException("Shop not found");
shop.Publish();
_shopRepository.Update(shop);
await _shopRepository.UnitOfWork.SaveEntitiesAsync();
_logger.LogInformation("Shop {ShopId} reactivated by Admin {AdminId}", shopId, GetAdminId());
return Ok(new { message = "Shop reactivated successfully", shopId, status = shop.Status.Name });
}
catch (DomainException ex)
{
if (ex.Message.Contains("not found"))
return NotFound(new { message = ex.Message });
return BadRequest(new { message = ex.Message });
}
}
/// <summary>
/// EN: Close a shop permanently.
/// VI: Đóng cửa shop vĩnh viễn.
/// </summary>
[HttpPost("{shopId:guid}/close")]
[SwaggerOperation(Summary = "Close shop", Description = "Close a shop permanently (Admin only)")]
[SwaggerResponse(200, "Shop closed")]
[SwaggerResponse(400, "Cannot close")]
[SwaggerResponse(404, "Shop not found")]
public async Task<IActionResult> CloseShop(Guid shopId, [FromBody] AdminActionRequest request)
{
try
{
var shop = await _shopRepository.GetAsync(shopId)
?? throw new DomainException("Shop not found");
shop.Close();
_shopRepository.Update(shop);
await _shopRepository.UnitOfWork.SaveEntitiesAsync();
_logger.LogWarning("Shop {ShopId} CLOSED by Admin {AdminId}. Reason: {Reason}",
shopId, GetAdminId(), request.Reason);
return Ok(new { message = "Shop closed successfully", shopId, status = shop.Status.Name });
}
catch (DomainException ex)
{
if (ex.Message.Contains("not found"))
return NotFound(new { message = ex.Message });
return BadRequest(new { message = ex.Message });
}
}
private Guid GetAdminId()
{
var userIdClaim = User.FindFirst("sub")?.Value
?? User.FindFirst("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier")?.Value;
return Guid.TryParse(userIdClaim, out var userId) ? userId : Guid.Empty;
}
}

View File

@@ -0,0 +1,143 @@
using System.Net;
using System.Net.Http.Json;
using FluentAssertions;
using Microsoft.AspNetCore.Mvc.Testing;
using Xunit;
namespace SocialService.FunctionalTests.Controllers;
/// <summary>
/// EN: Functional tests for Blocks API endpoints.
/// VI: Functional tests cho các endpoints API Blocks.
/// </summary>
public class BlocksControllerTests : IClassFixture<CustomWebApplicationFactory>
{
private readonly HttpClient _client;
public BlocksControllerTests(CustomWebApplicationFactory factory)
{
_client = factory.CreateClient(new WebApplicationFactoryClientOptions
{
AllowAutoRedirect = false
});
}
#region Block User Tests
[Fact]
public async Task BlockUser_ValidRequest_ReturnsCreated()
{
// Arrange
var request = new
{
BlockerId = Guid.NewGuid(),
BlockedId = Guid.NewGuid(),
Reason = "Spam content"
};
// Act
var response = await _client.PostAsJsonAsync("/api/v1/blocks", request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Created);
var content = await response.Content.ReadFromJsonAsync<BlockUserResponse>();
content!.BlockId.Should().NotBeEmpty();
content.Success.Should().BeTrue();
}
[Fact]
public async Task BlockUser_SelfBlock_ReturnsBadRequest()
{
// Arrange
var userId = Guid.NewGuid();
var request = new
{
BlockerId = userId,
BlockedId = userId // Same user
};
// Act
var response = await _client.PostAsJsonAsync("/api/v1/blocks", request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
[Fact]
public async Task BlockUser_WithoutReason_ReturnsCreated()
{
// Arrange
var request = new
{
BlockerId = Guid.NewGuid(),
BlockedId = Guid.NewGuid()
// No reason provided
};
// Act
var response = await _client.PostAsJsonAsync("/api/v1/blocks", request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Created);
}
#endregion
#region Unblock User Tests
[Fact]
public async Task UnblockUser_ValidRequest_ReturnsOk()
{
// Arrange - First create a block
var blockerId = Guid.NewGuid();
var blockedId = Guid.NewGuid();
var blockRequest = new { BlockerId = blockerId, BlockedId = blockedId };
await _client.PostAsJsonAsync("/api/v1/blocks", blockRequest);
// Act - Unblock
var unblockRequest = new HttpRequestMessage(HttpMethod.Delete, "/api/v1/blocks")
{
Content = JsonContent.Create(new { BlockerId = blockerId, BlockedId = blockedId })
};
var response = await _client.SendAsync(unblockRequest);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
}
#endregion
#region Get Blocked Users Tests
[Fact]
public async Task GetBlockedUsers_ValidUser_ReturnsOkWithList()
{
// Arrange
var userId = Guid.NewGuid();
// Act
var response = await _client.GetAsync($"/api/v1/blocks/users/{userId}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
}
[Fact]
public async Task GetBlockedUsers_WithPagination_ReturnsOk()
{
// Arrange
var userId = Guid.NewGuid();
// Act
var response = await _client.GetAsync($"/api/v1/blocks/users/{userId}?skip=0&take=10");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
}
#endregion
// EN: Helper DTOs for deserialization
// VI: Helper DTOs để deserialize
private record BlockUserResponse(Guid BlockId, bool Success);
}

View File

@@ -0,0 +1,188 @@
using System.Net;
using System.Net.Http.Json;
using FluentAssertions;
using Microsoft.AspNetCore.Mvc.Testing;
using Xunit;
namespace SocialService.FunctionalTests.Controllers;
/// <summary>
/// EN: Functional tests for Relationships API endpoints.
/// VI: Functional tests cho các endpoints API Relationships.
/// </summary>
public class RelationshipsControllerTests : IClassFixture<CustomWebApplicationFactory>
{
private readonly HttpClient _client;
public RelationshipsControllerTests(CustomWebApplicationFactory factory)
{
_client = factory.CreateClient(new WebApplicationFactoryClientOptions
{
AllowAutoRedirect = false
});
}
#region Friend Request Tests
[Fact]
public async Task SendFriendRequest_ValidRequest_ReturnsCreated()
{
// Arrange
var request = new
{
RequesterId = Guid.NewGuid(),
AddresseeId = Guid.NewGuid()
};
// Act
var response = await _client.PostAsJsonAsync("/api/v1/relationships/friend-requests", request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Created);
var content = await response.Content.ReadFromJsonAsync<SendFriendRequestResponse>();
content!.RelationshipId.Should().NotBeEmpty();
content.Status.Should().Be("Pending");
}
[Fact]
public async Task SendFriendRequest_SelfRequest_ReturnsBadRequest()
{
// Arrange
var userId = Guid.NewGuid();
var request = new
{
RequesterId = userId,
AddresseeId = userId // Same user
};
// Act
var response = await _client.PostAsJsonAsync("/api/v1/relationships/friend-requests", request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
[Fact]
public async Task RespondToFriendRequest_AcceptValid_ReturnsOk()
{
// Arrange - First create a friend request
var requesterId = Guid.NewGuid();
var addresseeId = Guid.NewGuid();
var createRequest = new { RequesterId = requesterId, AddresseeId = addresseeId };
var createResponse = await _client.PostAsJsonAsync("/api/v1/relationships/friend-requests", createRequest);
createResponse.EnsureSuccessStatusCode();
var created = await createResponse.Content.ReadFromJsonAsync<SendFriendRequestResponse>();
// Act - Accept the request
var respondRequest = new { UserId = addresseeId, Accept = true };
var response = await _client.PutAsJsonAsync($"/api/v1/relationships/friend-requests/{created!.RelationshipId}", respondRequest);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
}
#endregion
#region Get Friends Tests
[Fact]
public async Task GetFriends_ValidUser_ReturnsOkWithList()
{
// Arrange
var userId = Guid.NewGuid();
// Act
var response = await _client.GetAsync($"/api/v1/relationships/users/{userId}/friends");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
}
[Fact]
public async Task GetMutualFriends_ValidUsers_ReturnsOk()
{
// Arrange
var userId1 = Guid.NewGuid();
var userId2 = Guid.NewGuid();
// Act
var response = await _client.GetAsync($"/api/v1/relationships/users/{userId1}/mutual-friends/{userId2}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
}
[Fact]
public async Task GetFriendSuggestions_ValidUser_ReturnsOk()
{
// Arrange
var userId = Guid.NewGuid();
// Act
var response = await _client.GetAsync($"/api/v1/relationships/users/{userId}/suggestions");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
}
#endregion
#region Follow Tests
[Fact]
public async Task FollowUser_ValidRequest_ReturnsCreated()
{
// Arrange
var request = new
{
FollowerId = Guid.NewGuid(),
FolloweeId = Guid.NewGuid()
};
// Act
var response = await _client.PostAsJsonAsync("/api/v1/relationships/follow", request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Created);
}
[Fact]
public async Task UnfollowUser_ValidRequest_ReturnsOk()
{
// Arrange - First create a follow relationship
var followerId = Guid.NewGuid();
var followeeId = Guid.NewGuid();
var followRequest = new { FollowerId = followerId, FolloweeId = followeeId };
await _client.PostAsJsonAsync("/api/v1/relationships/follow", followRequest);
// Act - Unfollow
var unfollowRequest = new HttpRequestMessage(HttpMethod.Delete, "/api/v1/relationships/follow")
{
Content = JsonContent.Create(new { FollowerId = followerId, FolloweeId = followeeId })
};
var response = await _client.SendAsync(unfollowRequest);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
}
#endregion
#region Health Check Tests
[Fact]
public async Task HealthCheck_Live_ReturnsHealthy()
{
// Act
var response = await _client.GetAsync("/health/live");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
}
#endregion
// EN: Helper DTOs for deserialization
// VI: Helper DTOs để deserialize
private record SendFriendRequestResponse(Guid RelationshipId, string Status);
}

View File

@@ -1,80 +0,0 @@
using System.Net;
using System.Net.Http.Json;
using FluentAssertions;
using Microsoft.AspNetCore.Mvc.Testing;
using Xunit;
namespace SocialService.FunctionalTests.Controllers;
/// <summary>
/// EN: Functional tests for Samples API endpoints.
/// VI: Functional tests cho các endpoints API Samples.
/// </summary>
public class SamplesControllerTests : IClassFixture<CustomWebApplicationFactory>
{
private readonly HttpClient _client;
public SamplesControllerTests(CustomWebApplicationFactory factory)
{
_client = factory.CreateClient(new WebApplicationFactoryClientOptions
{
AllowAutoRedirect = false
});
}
[Fact]
public async Task GetSamples_ShouldReturnOkWithEmptyList()
{
// Act
var response = await _client.GetAsync("/api/v1/samples");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var content = await response.Content.ReadFromJsonAsync<ApiResponse<List<object>>>();
content?.Success.Should().BeTrue();
}
[Fact]
public async Task CreateSample_WithValidData_ShouldReturnCreated()
{
// Arrange
var request = new { Name = "Test Sample", Description = "Test Description" };
// Act
var response = await _client.PostAsJsonAsync("/api/v1/samples", request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Created);
var content = await response.Content.ReadFromJsonAsync<ApiResponse<CreateSampleResult>>();
content?.Success.Should().BeTrue();
content?.Data?.Id.Should().NotBeEmpty();
}
[Fact]
public async Task GetSample_WithInvalidId_ShouldReturnNotFound()
{
// Arrange
var invalidId = Guid.NewGuid();
// Act
var response = await _client.GetAsync($"/api/v1/samples/{invalidId}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
[Fact]
public async Task HealthCheck_ShouldReturnHealthy()
{
// Act
var response = await _client.GetAsync("/health/live");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
}
// EN: Helper DTOs for deserialization
// VI: Helper DTOs để deserialize
private record ApiResponse<T>(bool Success, T? Data);
private record CreateSampleResult(Guid Id);
}

View File

@@ -18,26 +18,22 @@ public class CustomWebApplicationFactory : WebApplicationFactory<Program>
builder.ConfigureServices(services =>
{
// EN: Remove the existing DbContext registration
// VI: Xóa đăng ký DbContext hiện tại
var descriptor = services.SingleOrDefault(
d => d.ServiceType == typeof(DbContextOptions<SocialServiceContext>));
// EN: Remove ALL DbContext-related registrations to avoid provider conflict
// VI: Xóa TẤT CẢ các đăng ký liên quan đến DbContext để tránh conflict provider
var descriptorsToRemove = services
.Where(d =>
d.ServiceType == typeof(DbContextOptions<SocialServiceContext>) ||
d.ServiceType == typeof(DbContextOptions) ||
d.ServiceType == typeof(SocialServiceContext) ||
d.ServiceType.FullName?.Contains("EntityFrameworkCore") == true ||
d.ImplementationType?.FullName?.Contains("Npgsql") == true)
.ToList();
if (descriptor != null)
foreach (var descriptor in descriptorsToRemove)
{
services.Remove(descriptor);
}
// EN: Remove DbContext service
// VI: Xóa DbContext service
var dbContextDescriptor = services.SingleOrDefault(
d => d.ServiceType == typeof(SocialServiceContext));
if (dbContextDescriptor != null)
{
services.Remove(dbContextDescriptor);
}
// EN: Add in-memory database for testing
// VI: Thêm in-memory database để test
services.AddDbContext<SocialServiceContext>(options =>
@@ -45,8 +41,8 @@ public class CustomWebApplicationFactory : WebApplicationFactory<Program>
options.UseInMemoryDatabase("TestDatabase_" + Guid.NewGuid().ToString());
});
// EN: Ensure database is created with seed data
// VI: Đảm bảo database được tạo với seed data
// EN: Build service provider and ensure database is created
// VI: Build service provider và đảm bảo database được tạo
var sp = services.BuildServiceProvider();
using var scope = sp.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SocialServiceContext>();

View File

@@ -0,0 +1,153 @@
using FluentAssertions;
using Microsoft.Extensions.Logging;
using NSubstitute;
using SocialService.API.Application.Commands;
using SocialService.Domain.AggregatesModel.RelationshipAggregate;
using SocialService.Domain.AggregatesModel.UserBlockAggregate;
using SocialService.Domain.Exceptions;
using SocialService.Domain.SeedWork;
using Xunit;
namespace SocialService.UnitTests.Application.Handlers;
/// <summary>
/// EN: Unit tests for BlockUserCommandHandler.
/// VI: Unit tests cho BlockUserCommandHandler.
/// </summary>
public class BlockUserCommandHandlerTests
{
private readonly IUserBlockRepository _userBlockRepository;
private readonly IRelationshipRepository _relationshipRepository;
private readonly ILogger<BlockUserCommandHandler> _logger;
private readonly BlockUserCommandHandler _handler;
public BlockUserCommandHandlerTests()
{
_userBlockRepository = Substitute.For<IUserBlockRepository>();
_relationshipRepository = Substitute.For<IRelationshipRepository>();
_logger = Substitute.For<ILogger<BlockUserCommandHandler>>();
// EN: Setup UnitOfWork mock
// VI: Setup mock cho UnitOfWork
var unitOfWork = Substitute.For<IUnitOfWork>();
unitOfWork.SaveEntitiesAsync(Arg.Any<CancellationToken>()).Returns(true);
_userBlockRepository.UnitOfWork.Returns(unitOfWork);
_handler = new BlockUserCommandHandler(
_userBlockRepository,
_relationshipRepository,
_logger);
}
[Fact]
public async Task Handle_ValidRequest_CreatesBlock()
{
// Arrange
var blockerId = Guid.NewGuid();
var blockedId = Guid.NewGuid();
var command = new BlockUserCommand(blockerId, blockedId, "Spam");
_userBlockRepository.GetByUsersAsync(blockerId, blockedId).Returns((UserBlock?)null);
_relationshipRepository.GetByUsersAndTypeAsync(Arg.Any<Guid>(), Arg.Any<Guid>(), Arg.Any<RelationshipType>())
.Returns((Relationship?)null);
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
result.Should().NotBeNull();
result.Success.Should().BeTrue();
result.BlockId.Should().NotBeEmpty();
_userBlockRepository.Received(1).Add(Arg.Is<UserBlock>(b =>
b.BlockerId == blockerId &&
b.BlockedId == blockedId &&
b.Reason == "Spam"));
}
[Fact]
public async Task Handle_SelfBlock_ThrowsException()
{
// Arrange
var userId = Guid.NewGuid();
var command = new BlockUserCommand(userId, userId);
// Act
var act = async () => await _handler.Handle(command, CancellationToken.None);
// Assert
await act.Should().ThrowAsync<SocialDomainException>()
.WithMessage("*yourself*");
_userBlockRepository.DidNotReceive().Add(Arg.Any<UserBlock>());
}
[Fact]
public async Task Handle_AlreadyBlocked_ThrowsException()
{
// Arrange
var blockerId = Guid.NewGuid();
var blockedId = Guid.NewGuid();
var command = new BlockUserCommand(blockerId, blockedId);
var existingBlock = new UserBlock(blockerId, blockedId);
_userBlockRepository.GetByUsersAsync(blockerId, blockedId).Returns(existingBlock);
// Act
var act = async () => await _handler.Handle(command, CancellationToken.None);
// Assert
await act.Should().ThrowAsync<SocialDomainException>()
.WithMessage("*already blocked*");
}
[Fact]
public async Task Handle_WithExistingFriendship_RemovesFriendship()
{
// Arrange
var blockerId = Guid.NewGuid();
var blockedId = Guid.NewGuid();
var command = new BlockUserCommand(blockerId, blockedId);
var friendship = new Relationship(blockerId, blockedId, RelationshipType.Friendship);
friendship.Accept(); // Make it accepted so it can be removed
_userBlockRepository.GetByUsersAsync(blockerId, blockedId).Returns((UserBlock?)null);
_relationshipRepository.GetByUsersAndTypeAsync(blockerId, blockedId, RelationshipType.Friendship)
.Returns(friendship);
_relationshipRepository.GetByUsersAndTypeAsync(blockedId, blockerId, RelationshipType.Friendship)
.Returns((Relationship?)null);
_relationshipRepository.GetByUsersAndTypeAsync(blockerId, blockedId, RelationshipType.Following)
.Returns((Relationship?)null);
_relationshipRepository.GetByUsersAndTypeAsync(blockedId, blockerId, RelationshipType.Following)
.Returns((Relationship?)null);
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
result.Success.Should().BeTrue();
friendship.Status.Should().Be(RelationshipStatus.Cancelled);
_relationshipRepository.Received(1).Update(friendship);
}
[Fact]
public async Task Handle_WithNoReason_CreatesBlockWithoutReason()
{
// Arrange
var blockerId = Guid.NewGuid();
var blockedId = Guid.NewGuid();
var command = new BlockUserCommand(blockerId, blockedId); // No reason
_userBlockRepository.GetByUsersAsync(blockerId, blockedId).Returns((UserBlock?)null);
_relationshipRepository.GetByUsersAndTypeAsync(Arg.Any<Guid>(), Arg.Any<Guid>(), Arg.Any<RelationshipType>())
.Returns((Relationship?)null);
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
result.Success.Should().BeTrue();
_userBlockRepository.Received(1).Add(Arg.Is<UserBlock>(b => b.Reason == null));
}
}

View File

@@ -0,0 +1,166 @@
using FluentAssertions;
using Microsoft.Extensions.Logging;
using NSubstitute;
using SocialService.API.Application.Commands;
using SocialService.Domain.AggregatesModel.RelationshipAggregate;
using SocialService.Domain.AggregatesModel.UserBlockAggregate;
using SocialService.Domain.Exceptions;
using SocialService.Domain.SeedWork;
using Xunit;
namespace SocialService.UnitTests.Application.Handlers;
/// <summary>
/// EN: Unit tests for SendFriendRequestCommandHandler.
/// VI: Unit tests cho SendFriendRequestCommandHandler.
/// </summary>
public class SendFriendRequestCommandHandlerTests
{
private readonly IRelationshipRepository _relationshipRepository;
private readonly IUserBlockRepository _userBlockRepository;
private readonly ILogger<SendFriendRequestCommandHandler> _logger;
private readonly SendFriendRequestCommandHandler _handler;
public SendFriendRequestCommandHandlerTests()
{
_relationshipRepository = Substitute.For<IRelationshipRepository>();
_userBlockRepository = Substitute.For<IUserBlockRepository>();
_logger = Substitute.For<ILogger<SendFriendRequestCommandHandler>>();
// EN: Setup UnitOfWork mock
// VI: Setup mock cho UnitOfWork
var unitOfWork = Substitute.For<IUnitOfWork>();
unitOfWork.SaveEntitiesAsync(Arg.Any<CancellationToken>()).Returns(true);
_relationshipRepository.UnitOfWork.Returns(unitOfWork);
_handler = new SendFriendRequestCommandHandler(
_relationshipRepository,
_userBlockRepository,
_logger);
}
[Fact]
public async Task Handle_ValidRequest_CreatesNewRelationship()
{
// Arrange
var requesterId = Guid.NewGuid();
var addresseeId = Guid.NewGuid();
var command = new SendFriendRequestCommand(requesterId, addresseeId);
_userBlockRepository.HasBlockBetweenAsync(requesterId, addresseeId).Returns(false);
_relationshipRepository.GetByUsersAndTypeAsync(requesterId, addresseeId, RelationshipType.Friendship)
.Returns((Relationship?)null);
_relationshipRepository.GetByUsersAndTypeAsync(addresseeId, requesterId, RelationshipType.Friendship)
.Returns((Relationship?)null);
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
result.Should().NotBeNull();
result.Status.Should().Be("Pending");
result.RelationshipId.Should().NotBeEmpty();
_relationshipRepository.Received(1).Add(Arg.Is<Relationship>(r =>
r.RequesterId == requesterId &&
r.AddresseeId == addresseeId &&
r.Type == RelationshipType.Friendship));
}
[Fact]
public async Task Handle_BlockExists_ThrowsException()
{
// Arrange
var requesterId = Guid.NewGuid();
var addresseeId = Guid.NewGuid();
var command = new SendFriendRequestCommand(requesterId, addresseeId);
_userBlockRepository.HasBlockBetweenAsync(requesterId, addresseeId).Returns(true);
// Act
var act = async () => await _handler.Handle(command, CancellationToken.None);
// Assert
await act.Should().ThrowAsync<SocialDomainException>()
.WithMessage("*blocked*");
_relationshipRepository.DidNotReceive().Add(Arg.Any<Relationship>());
}
[Fact]
public async Task Handle_AlreadyFriends_ThrowsException()
{
// Arrange
var requesterId = Guid.NewGuid();
var addresseeId = Guid.NewGuid();
var command = new SendFriendRequestCommand(requesterId, addresseeId);
var existingRelationship = new Relationship(requesterId, addresseeId, RelationshipType.Friendship);
existingRelationship.Accept();
_userBlockRepository.HasBlockBetweenAsync(requesterId, addresseeId).Returns(false);
_relationshipRepository.GetByUsersAndTypeAsync(requesterId, addresseeId, RelationshipType.Friendship)
.Returns(existingRelationship);
// Act
var act = async () => await _handler.Handle(command, CancellationToken.None);
// Assert
await act.Should().ThrowAsync<SocialDomainException>()
.WithMessage("*already friends*");
}
[Fact]
public async Task Handle_RequestAlreadyPending_ThrowsException()
{
// Arrange
var requesterId = Guid.NewGuid();
var addresseeId = Guid.NewGuid();
var command = new SendFriendRequestCommand(requesterId, addresseeId);
var existingRelationship = new Relationship(requesterId, addresseeId, RelationshipType.Friendship);
// Status is Pending by default for Friendship
_userBlockRepository.HasBlockBetweenAsync(requesterId, addresseeId).Returns(false);
_relationshipRepository.GetByUsersAndTypeAsync(requesterId, addresseeId, RelationshipType.Friendship)
.Returns(existingRelationship);
// Act
var act = async () => await _handler.Handle(command, CancellationToken.None);
// Assert
await act.Should().ThrowAsync<SocialDomainException>()
.WithMessage("*already pending*");
}
[Fact]
public async Task Handle_MutualRequest_AutoAccepts()
{
// Arrange
var requesterId = Guid.NewGuid();
var addresseeId = Guid.NewGuid();
var command = new SendFriendRequestCommand(requesterId, addresseeId);
// EN: Other user already sent a pending request
// VI: User kia đã gửi request đang chờ
var reverseRelationship = new Relationship(addresseeId, requesterId, RelationshipType.Friendship);
// Status is Pending by default
_userBlockRepository.HasBlockBetweenAsync(requesterId, addresseeId).Returns(false);
_relationshipRepository.GetByUsersAndTypeAsync(requesterId, addresseeId, RelationshipType.Friendship)
.Returns((Relationship?)null);
_relationshipRepository.GetByUsersAndTypeAsync(addresseeId, requesterId, RelationshipType.Friendship)
.Returns(reverseRelationship);
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
result.Should().NotBeNull();
result.Status.Should().Be("Accepted");
reverseRelationship.Status.Should().Be(RelationshipStatus.Accepted);
_relationshipRepository.Received(1).Update(reverseRelationship);
_relationshipRepository.DidNotReceive().Add(Arg.Any<Relationship>());
}
}

View File

@@ -0,0 +1,291 @@
using FluentAssertions;
using SocialService.Domain.AggregatesModel.RelationshipAggregate;
using SocialService.Domain.Events;
using SocialService.Domain.Exceptions;
using Xunit;
namespace SocialService.UnitTests.Domain;
/// <summary>
/// EN: Unit tests for Relationship aggregate root.
/// VI: Unit tests cho Relationship aggregate root.
/// </summary>
public class RelationshipAggregateTests
{
private readonly Guid _requesterId = Guid.NewGuid();
private readonly Guid _addresseeId = Guid.NewGuid();
#region Constructor Tests
[Fact]
public void Create_Friendship_SetsStatusToPending()
{
// Act
var relationship = new Relationship(_requesterId, _addresseeId, RelationshipType.Friendship);
// Assert
relationship.Status.Should().Be(RelationshipStatus.Pending);
relationship.Type.Should().Be(RelationshipType.Friendship);
relationship.RequesterId.Should().Be(_requesterId);
relationship.AddresseeId.Should().Be(_addresseeId);
}
[Fact]
public void Create_Friendship_RaisesFriendRequestSentEvent()
{
// Act
var relationship = new Relationship(_requesterId, _addresseeId, RelationshipType.Friendship);
// Assert
relationship.DomainEvents.Should().ContainSingle(e => e is FriendRequestSentDomainEvent);
}
[Fact]
public void Create_Following_AutoAccepts()
{
// Act
var relationship = new Relationship(_requesterId, _addresseeId, RelationshipType.Following);
// Assert
relationship.Status.Should().Be(RelationshipStatus.Accepted);
relationship.Type.Should().Be(RelationshipType.Following);
}
[Fact]
public void Create_Following_RaisesUserFollowedEvent()
{
// Act
var relationship = new Relationship(_requesterId, _addresseeId, RelationshipType.Following);
// Assert
relationship.DomainEvents.Should().ContainSingle(e => e is UserFollowedDomainEvent);
}
[Fact]
public void Create_WithSameUserId_ThrowsException()
{
// Act
var act = () => new Relationship(_requesterId, _requesterId, RelationshipType.Friendship);
// Assert
act.Should().Throw<SocialDomainException>()
.WithMessage("*yourself*");
}
[Fact]
public void Create_WithEmptyRequesterId_ThrowsException()
{
// Act
var act = () => new Relationship(Guid.Empty, _addresseeId, RelationshipType.Friendship);
// Assert
act.Should().Throw<SocialDomainException>()
.WithMessage("*Requester ID*");
}
[Fact]
public void Create_WithEmptyAddresseeId_ThrowsException()
{
// Act
var act = () => new Relationship(_requesterId, Guid.Empty, RelationshipType.Friendship);
// Assert
act.Should().Throw<SocialDomainException>()
.WithMessage("*Addressee ID*");
}
#endregion
#region Accept Tests
[Fact]
public void Accept_PendingFriendship_SetsStatusToAccepted()
{
// Arrange
var relationship = new Relationship(_requesterId, _addresseeId, RelationshipType.Friendship);
relationship.ClearDomainEvents();
// Act
relationship.Accept();
// Assert
relationship.Status.Should().Be(RelationshipStatus.Accepted);
relationship.UpdatedAt.Should().NotBeNull();
}
[Fact]
public void Accept_PendingFriendship_RaisesFriendshipCreatedEvent()
{
// Arrange
var relationship = new Relationship(_requesterId, _addresseeId, RelationshipType.Friendship);
relationship.ClearDomainEvents();
// Act
relationship.Accept();
// Assert
relationship.DomainEvents.Should().Contain(e => e is FriendshipCreatedDomainEvent);
relationship.DomainEvents.Should().Contain(e => e is RelationshipStatusChangedDomainEvent);
}
[Fact]
public void Accept_Following_ThrowsException()
{
// Arrange
var relationship = new Relationship(_requesterId, _addresseeId, RelationshipType.Following);
// Act
var act = () => relationship.Accept();
// Assert
act.Should().Throw<SocialDomainException>()
.WithMessage("*Only friendship*");
}
[Fact]
public void Accept_AlreadyAccepted_ThrowsException()
{
// Arrange
var relationship = new Relationship(_requesterId, _addresseeId, RelationshipType.Friendship);
relationship.Accept();
// Act
var act = () => relationship.Accept();
// Assert
act.Should().Throw<SocialDomainException>()
.WithMessage("*Only pending*");
}
#endregion
#region Reject Tests
[Fact]
public void Reject_PendingFriendship_SetsStatusToRejected()
{
// Arrange
var relationship = new Relationship(_requesterId, _addresseeId, RelationshipType.Friendship);
relationship.ClearDomainEvents();
// Act
relationship.Reject();
// Assert
relationship.Status.Should().Be(RelationshipStatus.Rejected);
relationship.UpdatedAt.Should().NotBeNull();
}
[Fact]
public void Reject_Following_ThrowsException()
{
// Arrange
var relationship = new Relationship(_requesterId, _addresseeId, RelationshipType.Following);
// Act
var act = () => relationship.Reject();
// Assert
act.Should().Throw<SocialDomainException>()
.WithMessage("*Only friendship*");
}
#endregion
#region Cancel Tests
[Fact]
public void Cancel_PendingRequest_SetsStatusToCancelled()
{
// Arrange
var relationship = new Relationship(_requesterId, _addresseeId, RelationshipType.Friendship);
relationship.ClearDomainEvents();
// Act
relationship.Cancel();
// Assert
relationship.Status.Should().Be(RelationshipStatus.Cancelled);
relationship.UpdatedAt.Should().NotBeNull();
}
[Fact]
public void Cancel_AcceptedRelationship_ThrowsException()
{
// Arrange
var relationship = new Relationship(_requesterId, _addresseeId, RelationshipType.Friendship);
relationship.Accept();
// Act
var act = () => relationship.Cancel();
// Assert
act.Should().Throw<SocialDomainException>()
.WithMessage("*Only pending*");
}
#endregion
#region Remove Tests
[Fact]
public void Remove_AcceptedFriendship_SetsStatusToCancelled()
{
// Arrange
var relationship = new Relationship(_requesterId, _addresseeId, RelationshipType.Friendship);
relationship.Accept();
relationship.ClearDomainEvents();
// Act
relationship.Remove();
// Assert
relationship.Status.Should().Be(RelationshipStatus.Cancelled);
relationship.UpdatedAt.Should().NotBeNull();
}
[Fact]
public void Remove_AcceptedFollowing_SetsStatusToCancelled()
{
// Arrange
var relationship = new Relationship(_requesterId, _addresseeId, RelationshipType.Following);
relationship.ClearDomainEvents();
// Act
relationship.Remove();
// Assert
relationship.Status.Should().Be(RelationshipStatus.Cancelled);
}
[Fact]
public void Remove_AcceptedRelationship_RaisesRelationshipRemovedEvent()
{
// Arrange
var relationship = new Relationship(_requesterId, _addresseeId, RelationshipType.Friendship);
relationship.Accept();
relationship.ClearDomainEvents();
// Act
relationship.Remove();
// Assert
relationship.DomainEvents.Should().Contain(e => e is RelationshipRemovedDomainEvent);
}
[Fact]
public void Remove_PendingRequest_ThrowsException()
{
// Arrange
var relationship = new Relationship(_requesterId, _addresseeId, RelationshipType.Friendship);
// Act
var act = () => relationship.Remove();
// Assert
act.Should().Throw<SocialDomainException>()
.WithMessage("*Only accepted*");
}
#endregion
}

View File

@@ -0,0 +1,91 @@
using FluentAssertions;
using SocialService.Domain.AggregatesModel.UserBlockAggregate;
using SocialService.Domain.Events;
using SocialService.Domain.Exceptions;
using Xunit;
namespace SocialService.UnitTests.Domain;
/// <summary>
/// EN: Unit tests for UserBlock aggregate root.
/// VI: Unit tests cho UserBlock aggregate root.
/// </summary>
public class UserBlockAggregateTests
{
private readonly Guid _blockerId = Guid.NewGuid();
private readonly Guid _blockedId = Guid.NewGuid();
#region Constructor Tests
[Fact]
public void Create_ValidBlock_SetsProperties()
{
// Act
var block = new UserBlock(_blockerId, _blockedId);
// Assert
block.Id.Should().NotBeEmpty();
block.BlockerId.Should().Be(_blockerId);
block.BlockedId.Should().Be(_blockedId);
block.Reason.Should().BeNull();
block.CreatedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1));
}
[Fact]
public void Create_WithReason_StoresReason()
{
// Arrange
const string reason = "Spam content";
// Act
var block = new UserBlock(_blockerId, _blockedId, reason);
// Assert
block.Reason.Should().Be(reason);
}
[Fact]
public void Create_RaisesUserBlockedEvent()
{
// Act
var block = new UserBlock(_blockerId, _blockedId);
// Assert
block.DomainEvents.Should().ContainSingle(e => e is UserBlockedDomainEvent);
}
[Fact]
public void Create_WithSameUserId_ThrowsException()
{
// Act
var act = () => new UserBlock(_blockerId, _blockerId);
// Assert
act.Should().Throw<SocialDomainException>()
.WithMessage("*yourself*");
}
[Fact]
public void Create_WithEmptyBlockerId_ThrowsException()
{
// Act
var act = () => new UserBlock(Guid.Empty, _blockedId);
// Assert
act.Should().Throw<SocialDomainException>()
.WithMessage("*Blocker ID*");
}
[Fact]
public void Create_WithEmptyBlockedId_ThrowsException()
{
// Act
var act = () => new UserBlock(_blockerId, Guid.Empty);
// Assert
act.Should().Throw<SocialDomainException>()
.WithMessage("*Blocked ID*");
}
#endregion
}

View File

@@ -18,7 +18,7 @@
<!-- EN: Assertions and mocking / VI: Assertions và mocking -->
<PackageReference Include="FluentAssertions" Version="6.12.2" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
<!-- EN: Coverage / VI: Coverage -->
<PackageReference Include="coverlet.collector" Version="6.0.2">