From 99d57efed11cebd72be5b1348b831b7a4c6c22ed Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Fri, 16 Jan 2026 00:44:53 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20Th=C3=AAm=20c=C3=A1c=20t=C3=ADnh=20n?= =?UTF-8?q?=C4=83ng=20qu=E1=BA=A3n=20l=C3=BD=20merchant=20v=C3=A0=20shop?= =?UTF-8?q?=20cho=20admin,=20=C4=91=E1=BB=93ng=20th=E1=BB=9Di=20b=E1=BB=95?= =?UTF-8?q?=20sung=20c=C3=A1c=20unit=20v=C3=A0=20functional=20test=20cho?= =?UTF-8?q?=20t=C3=ADnh=20n=C4=83ng=20ch=E1=BA=B7n=20ng=C6=B0=E1=BB=9Di=20?= =?UTF-8?q?d=C3=B9ng=20v=C3=A0=20quan=20h=E1=BB=87=20trong=20social=20serv?= =?UTF-8?q?ice.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Commands/Admin/ApproveMerchantCommand.cs | 65 ++++ .../Commands/Admin/BanMerchantCommand.cs | 74 +++++ .../Admin/ReactivateMerchantCommand.cs | 67 ++++ .../Commands/Admin/RejectMerchantCommand.cs | 76 +++++ .../Commands/Admin/SuspendMerchantCommand.cs | 74 +++++ .../Application/Queries/Admin/AdminDtos.cs | 126 ++++++++ .../Queries/Admin/GetAllMerchantsQuery.cs | 85 +++++ .../Queries/Admin/GetAllShopsQuery.cs | 87 ++++++ .../Queries/Admin/GetMerchantDetailQuery.cs | 79 +++++ .../Admin/GetMerchantStatisticsQuery.cs | 71 +++++ .../Admin/AdminMerchantsController.cs | 243 +++++++++++++++ .../Controllers/Admin/AdminShopsController.cs | 192 ++++++++++++ .../Controllers/BlocksControllerTests.cs | 143 +++++++++ .../RelationshipsControllerTests.cs | 188 +++++++++++ .../Controllers/SamplesControllerTests.cs | 80 ----- .../CustomWebApplicationFactory.cs | 30 +- .../Handlers/BlockUserCommandHandlerTests.cs | 153 +++++++++ .../SendFriendRequestCommandHandlerTests.cs | 166 ++++++++++ .../Domain/RelationshipAggregateTests.cs | 291 ++++++++++++++++++ .../Domain/UserBlockAggregateTests.cs | 91 ++++++ .../SocialService.UnitTests.csproj | 2 +- 21 files changed, 2285 insertions(+), 98 deletions(-) create mode 100644 services/merchant-service-net/src/MerchantService.API/Application/Commands/Admin/ApproveMerchantCommand.cs create mode 100644 services/merchant-service-net/src/MerchantService.API/Application/Commands/Admin/BanMerchantCommand.cs create mode 100644 services/merchant-service-net/src/MerchantService.API/Application/Commands/Admin/ReactivateMerchantCommand.cs create mode 100644 services/merchant-service-net/src/MerchantService.API/Application/Commands/Admin/RejectMerchantCommand.cs create mode 100644 services/merchant-service-net/src/MerchantService.API/Application/Commands/Admin/SuspendMerchantCommand.cs create mode 100644 services/merchant-service-net/src/MerchantService.API/Application/Queries/Admin/AdminDtos.cs create mode 100644 services/merchant-service-net/src/MerchantService.API/Application/Queries/Admin/GetAllMerchantsQuery.cs create mode 100644 services/merchant-service-net/src/MerchantService.API/Application/Queries/Admin/GetAllShopsQuery.cs create mode 100644 services/merchant-service-net/src/MerchantService.API/Application/Queries/Admin/GetMerchantDetailQuery.cs create mode 100644 services/merchant-service-net/src/MerchantService.API/Application/Queries/Admin/GetMerchantStatisticsQuery.cs create mode 100644 services/merchant-service-net/src/MerchantService.API/Controllers/Admin/AdminMerchantsController.cs create mode 100644 services/merchant-service-net/src/MerchantService.API/Controllers/Admin/AdminShopsController.cs create mode 100644 services/social-service-net/tests/SocialService.FunctionalTests/Controllers/BlocksControllerTests.cs create mode 100644 services/social-service-net/tests/SocialService.FunctionalTests/Controllers/RelationshipsControllerTests.cs delete mode 100644 services/social-service-net/tests/SocialService.FunctionalTests/Controllers/SamplesControllerTests.cs create mode 100644 services/social-service-net/tests/SocialService.UnitTests/Application/Handlers/BlockUserCommandHandlerTests.cs create mode 100644 services/social-service-net/tests/SocialService.UnitTests/Application/Handlers/SendFriendRequestCommandHandlerTests.cs create mode 100644 services/social-service-net/tests/SocialService.UnitTests/Domain/RelationshipAggregateTests.cs create mode 100644 services/social-service-net/tests/SocialService.UnitTests/Domain/UserBlockAggregateTests.cs diff --git a/services/merchant-service-net/src/MerchantService.API/Application/Commands/Admin/ApproveMerchantCommand.cs b/services/merchant-service-net/src/MerchantService.API/Application/Commands/Admin/ApproveMerchantCommand.cs new file mode 100644 index 00000000..7023d32e --- /dev/null +++ b/services/merchant-service-net/src/MerchantService.API/Application/Commands/Admin/ApproveMerchantCommand.cs @@ -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; + +/// +/// EN: Command to approve a merchant registration (Admin only). +/// VI: Command để phê duyệt đăng ký merchant (chỉ Admin). +/// +/// Merchant ID to approve / ID Merchant cần phê duyệt +/// Admin user ID / ID Admin phê duyệt +public record ApproveMerchantCommand(Guid MerchantId, Guid ApprovedBy) : IRequest; + +/// +/// EN: Result of merchant approval. +/// VI: Kết quả phê duyệt merchant. +/// +public record ApproveMerchantResult( + Guid MerchantId, + string Status, + DateTime ApprovedAt); + +/// +/// EN: Handler for ApproveMerchantCommand. +/// VI: Handler cho ApproveMerchantCommand. +/// +public class ApproveMerchantCommandHandler : IRequestHandler +{ + private readonly IMerchantRepository _merchantRepository; + private readonly ILogger _logger; + + public ApproveMerchantCommandHandler( + IMerchantRepository merchantRepository, + ILogger logger) + { + _merchantRepository = merchantRepository; + _logger = logger; + } + + public async Task 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); + } +} diff --git a/services/merchant-service-net/src/MerchantService.API/Application/Commands/Admin/BanMerchantCommand.cs b/services/merchant-service-net/src/MerchantService.API/Application/Commands/Admin/BanMerchantCommand.cs new file mode 100644 index 00000000..5b1f0198 --- /dev/null +++ b/services/merchant-service-net/src/MerchantService.API/Application/Commands/Admin/BanMerchantCommand.cs @@ -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; + +/// +/// EN: Command to permanently ban a merchant (Admin only). +/// VI: Command để cấm vĩnh viễn merchant (chỉ Admin). +/// +/// Merchant ID to ban / ID Merchant cần cấm +/// Reason for ban / Lý do cấm +/// Admin user ID / ID Admin thực hiện cấm +public record BanMerchantCommand( + Guid MerchantId, + string Reason, + Guid BannedBy) : IRequest; + +/// +/// EN: Result of merchant ban. +/// VI: Kết quả cấm merchant. +/// +public record BanMerchantResult( + Guid MerchantId, + string Status, + string Reason, + DateTime BannedAt); + +/// +/// EN: Handler for BanMerchantCommand. +/// VI: Handler cho BanMerchantCommand. +/// +public class BanMerchantCommandHandler : IRequestHandler +{ + private readonly IMerchantRepository _merchantRepository; + private readonly ILogger _logger; + + public BanMerchantCommandHandler( + IMerchantRepository merchantRepository, + ILogger logger) + { + _merchantRepository = merchantRepository; + _logger = logger; + } + + public async Task 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); + } +} diff --git a/services/merchant-service-net/src/MerchantService.API/Application/Commands/Admin/ReactivateMerchantCommand.cs b/services/merchant-service-net/src/MerchantService.API/Application/Commands/Admin/ReactivateMerchantCommand.cs new file mode 100644 index 00000000..face907c --- /dev/null +++ b/services/merchant-service-net/src/MerchantService.API/Application/Commands/Admin/ReactivateMerchantCommand.cs @@ -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; + +/// +/// 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). +/// +/// Merchant ID to reactivate / ID Merchant cần kích hoạt lại +/// Admin user ID / ID Admin kích hoạt lại +public record ReactivateMerchantCommand( + Guid MerchantId, + Guid ReactivatedBy) : IRequest; + +/// +/// EN: Result of merchant reactivation. +/// VI: Kết quả kích hoạt lại merchant. +/// +public record ReactivateMerchantResult( + Guid MerchantId, + string Status, + DateTime ReactivatedAt); + +/// +/// EN: Handler for ReactivateMerchantCommand. +/// VI: Handler cho ReactivateMerchantCommand. +/// +public class ReactivateMerchantCommandHandler : IRequestHandler +{ + private readonly IMerchantRepository _merchantRepository; + private readonly ILogger _logger; + + public ReactivateMerchantCommandHandler( + IMerchantRepository merchantRepository, + ILogger logger) + { + _merchantRepository = merchantRepository; + _logger = logger; + } + + public async Task 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); + } +} diff --git a/services/merchant-service-net/src/MerchantService.API/Application/Commands/Admin/RejectMerchantCommand.cs b/services/merchant-service-net/src/MerchantService.API/Application/Commands/Admin/RejectMerchantCommand.cs new file mode 100644 index 00000000..ad0216d1 --- /dev/null +++ b/services/merchant-service-net/src/MerchantService.API/Application/Commands/Admin/RejectMerchantCommand.cs @@ -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; + +/// +/// EN: Command to reject a merchant registration (Admin only). +/// VI: Command để từ chối đăng ký merchant (chỉ Admin). +/// +/// Merchant ID to reject / ID Merchant cần từ chối +/// Reason for rejection / Lý do từ chối +/// Admin user ID / ID Admin từ chối +public record RejectMerchantCommand( + Guid MerchantId, + string Reason, + Guid RejectedBy) : IRequest; + +/// +/// EN: Result of merchant rejection. +/// VI: Kết quả từ chối merchant. +/// +public record RejectMerchantResult( + Guid MerchantId, + string Status, + string Reason, + DateTime RejectedAt); + +/// +/// EN: Handler for RejectMerchantCommand. +/// VI: Handler cho RejectMerchantCommand. +/// +public class RejectMerchantCommandHandler : IRequestHandler +{ + private readonly IMerchantRepository _merchantRepository; + private readonly ILogger _logger; + + public RejectMerchantCommandHandler( + IMerchantRepository merchantRepository, + ILogger logger) + { + _merchantRepository = merchantRepository; + _logger = logger; + } + + public async Task 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); + } +} diff --git a/services/merchant-service-net/src/MerchantService.API/Application/Commands/Admin/SuspendMerchantCommand.cs b/services/merchant-service-net/src/MerchantService.API/Application/Commands/Admin/SuspendMerchantCommand.cs new file mode 100644 index 00000000..726d262d --- /dev/null +++ b/services/merchant-service-net/src/MerchantService.API/Application/Commands/Admin/SuspendMerchantCommand.cs @@ -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; + +/// +/// EN: Command to suspend a merchant (Admin only). +/// VI: Command để tạm ngưng merchant (chỉ Admin). +/// +/// Merchant ID to suspend / ID Merchant cần tạm ngưng +/// Reason for suspension / Lý do tạm ngưng +/// Admin user ID / ID Admin tạm ngưng +public record SuspendMerchantCommand( + Guid MerchantId, + string Reason, + Guid SuspendedBy) : IRequest; + +/// +/// EN: Result of merchant suspension. +/// VI: Kết quả tạm ngưng merchant. +/// +public record SuspendMerchantResult( + Guid MerchantId, + string Status, + string Reason, + DateTime SuspendedAt); + +/// +/// EN: Handler for SuspendMerchantCommand. +/// VI: Handler cho SuspendMerchantCommand. +/// +public class SuspendMerchantCommandHandler : IRequestHandler +{ + private readonly IMerchantRepository _merchantRepository; + private readonly ILogger _logger; + + public SuspendMerchantCommandHandler( + IMerchantRepository merchantRepository, + ILogger logger) + { + _merchantRepository = merchantRepository; + _logger = logger; + } + + public async Task 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); + } +} diff --git a/services/merchant-service-net/src/MerchantService.API/Application/Queries/Admin/AdminDtos.cs b/services/merchant-service-net/src/MerchantService.API/Application/Queries/Admin/AdminDtos.cs new file mode 100644 index 00000000..13dc32db --- /dev/null +++ b/services/merchant-service-net/src/MerchantService.API/Application/Queries/Admin/AdminDtos.cs @@ -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 + +/// +/// EN: Admin view of merchant in list. +/// VI: View admin của merchant trong danh sách. +/// +public record AdminMerchantListItemDto( + Guid Id, + Guid UserId, + string BusinessName, + string Type, + string Status, + string VerificationStatus, + int ShopsCount, + DateTime CreatedAt, + DateTime? VerifiedAt); + +/// +/// EN: Paginated result for merchant list. +/// VI: Kết quả phân trang cho danh sách merchant. +/// +public record AdminMerchantListResultDto( + List Items, + int TotalCount, + int Page, + int PageSize, + int TotalPages); + +#endregion + +#region Merchant Detail DTOs + +/// +/// EN: Business info for admin view. +/// VI: Thông tin doanh nghiệp cho admin view. +/// +public record AdminBusinessInfoDto( + string? TaxId, + string? LegalName, + string? Address, + string? Phone, + string? Email, + string? Website); + +/// +/// EN: Settlement config for admin view. +/// VI: Cấu hình thanh toán cho admin view. +/// +public record AdminSettlementConfigDto( + decimal CommissionRate, + string PayoutFrequency, + string? BankAccount); + +/// +/// EN: Detailed admin view of a merchant. +/// VI: View chi tiết admin của merchant. +/// +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 + +/// +/// EN: Merchant statistics for admin dashboard. +/// VI: Thống kê merchant cho dashboard admin. +/// +public record AdminMerchantStatisticsDto( + int TotalMerchants, + int PendingApproval, + int Active, + int Suspended, + int Banned, + int TotalShops, + int PublishedShops, + int TotalStaff); + +#endregion + +#region Shop DTOs + +/// +/// EN: Admin view of shop in list. +/// VI: View admin của shop trong danh sách. +/// +public record AdminShopListItemDto( + Guid Id, + Guid MerchantId, + string MerchantBusinessName, + string Name, + string Slug, + string Status, + string? Category, + DateTime CreatedAt); + +/// +/// EN: Paginated result for shop list. +/// VI: Kết quả phân trang cho danh sách shop. +/// +public record AdminShopListResultDto( + List Items, + int TotalCount, + int Page, + int PageSize, + int TotalPages); + +#endregion diff --git a/services/merchant-service-net/src/MerchantService.API/Application/Queries/Admin/GetAllMerchantsQuery.cs b/services/merchant-service-net/src/MerchantService.API/Application/Queries/Admin/GetAllMerchantsQuery.cs new file mode 100644 index 00000000..3298b7a2 --- /dev/null +++ b/services/merchant-service-net/src/MerchantService.API/Application/Queries/Admin/GetAllMerchantsQuery.cs @@ -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; + +/// +/// 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). +/// +public record GetAllMerchantsQuery( + int Page = 1, + int PageSize = 20, + string? Status = null, + string? VerificationStatus = null, + string? Search = null) : IRequest; + +/// +/// EN: Handler for GetAllMerchantsQuery. +/// VI: Handler cho GetAllMerchantsQuery. +/// +public class GetAllMerchantsQueryHandler : IRequestHandler +{ + private readonly MerchantServiceContext _context; + + public GetAllMerchantsQueryHandler(MerchantServiceContext context) + { + _context = context; + } + + public async Task 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); + } +} diff --git a/services/merchant-service-net/src/MerchantService.API/Application/Queries/Admin/GetAllShopsQuery.cs b/services/merchant-service-net/src/MerchantService.API/Application/Queries/Admin/GetAllShopsQuery.cs new file mode 100644 index 00000000..0fc32017 --- /dev/null +++ b/services/merchant-service-net/src/MerchantService.API/Application/Queries/Admin/GetAllShopsQuery.cs @@ -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; + +/// +/// 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). +/// +public record GetAllShopsQuery( + int Page = 1, + int PageSize = 20, + string? Status = null, + Guid? MerchantId = null, + string? Search = null) : IRequest; + +/// +/// EN: Handler for GetAllShopsQuery. +/// VI: Handler cho GetAllShopsQuery. +/// +public class GetAllShopsQueryHandler : IRequestHandler +{ + private readonly MerchantServiceContext _context; + + public GetAllShopsQueryHandler(MerchantServiceContext context) + { + _context = context; + } + + public async Task 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); + } +} diff --git a/services/merchant-service-net/src/MerchantService.API/Application/Queries/Admin/GetMerchantDetailQuery.cs b/services/merchant-service-net/src/MerchantService.API/Application/Queries/Admin/GetMerchantDetailQuery.cs new file mode 100644 index 00000000..fa8a1820 --- /dev/null +++ b/services/merchant-service-net/src/MerchantService.API/Application/Queries/Admin/GetMerchantDetailQuery.cs @@ -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; + +/// +/// EN: Query to get detailed merchant information (Admin only). +/// VI: Query để lấy thông tin chi tiết merchant (chỉ Admin). +/// +public record GetMerchantDetailQuery(Guid MerchantId) : IRequest; + +/// +/// EN: Handler for GetMerchantDetailQuery. +/// VI: Handler cho GetMerchantDetailQuery. +/// +public class GetMerchantDetailQueryHandler : IRequestHandler +{ + private readonly MerchantServiceContext _context; + + public GetMerchantDetailQueryHandler(MerchantServiceContext context) + { + _context = context; + } + + public async Task 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); + } +} diff --git a/services/merchant-service-net/src/MerchantService.API/Application/Queries/Admin/GetMerchantStatisticsQuery.cs b/services/merchant-service-net/src/MerchantService.API/Application/Queries/Admin/GetMerchantStatisticsQuery.cs new file mode 100644 index 00000000..0040ade3 --- /dev/null +++ b/services/merchant-service-net/src/MerchantService.API/Application/Queries/Admin/GetMerchantStatisticsQuery.cs @@ -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; + +/// +/// EN: Query to get merchant statistics (Admin only). +/// VI: Query để lấy thống kê merchant (chỉ Admin). +/// +public record GetMerchantStatisticsQuery : IRequest; + +/// +/// EN: Handler for GetMerchantStatisticsQuery. +/// VI: Handler cho GetMerchantStatisticsQuery. +/// +public class GetMerchantStatisticsQueryHandler : IRequestHandler +{ + private readonly MerchantServiceContext _context; + + public GetMerchantStatisticsQueryHandler(MerchantServiceContext context) + { + _context = context; + } + + public async Task 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); + } +} diff --git a/services/merchant-service-net/src/MerchantService.API/Controllers/Admin/AdminMerchantsController.cs b/services/merchant-service-net/src/MerchantService.API/Controllers/Admin/AdminMerchantsController.cs new file mode 100644 index 00000000..5936898f --- /dev/null +++ b/services/merchant-service-net/src/MerchantService.API/Controllers/Admin/AdminMerchantsController.cs @@ -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; + +/// +/// EN: Admin controller for merchant management operations (Backoffice). +/// VI: Controller Admin cho các thao tác quản lý merchant (Backoffice). +/// +[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 _logger; + + public AdminMerchantsController( + IMediator mediator, + ILogger logger) + { + _mediator = mediator; + _logger = logger; + } + + /// + /// EN: Get all merchants with pagination. + /// VI: Lấy tất cả merchants với phân trang. + /// + [HttpGet] + [SwaggerOperation(Summary = "Get all merchants", Description = "Get all merchants with pagination (Admin only)")] + [SwaggerResponse(200, "Success", typeof(AdminMerchantListResultDto))] + public async Task 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); + } + + /// + /// EN: Get merchant details by ID. + /// VI: Lấy chi tiết merchant theo ID. + /// + [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 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 }); + } + } + + /// + /// EN: Get merchant statistics. + /// VI: Lấy thống kê merchant. + /// + [HttpGet("statistics")] + [SwaggerOperation(Summary = "Get statistics", Description = "Get merchant statistics (Admin only)")] + [SwaggerResponse(200, "Success", typeof(AdminMerchantStatisticsDto))] + public async Task GetStatistics() + { + var query = new GetMerchantStatisticsQuery(); + var result = await _mediator.Send(query); + return Ok(result); + } + + /// + /// EN: Approve merchant registration. + /// VI: Phê duyệt đăng ký merchant. + /// + [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 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 }); + } + } + + /// + /// EN: Reject merchant registration. + /// VI: Từ chối đăng ký merchant. + /// + [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 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 }); + } + } + + /// + /// EN: Suspend an active merchant. + /// VI: Tạm ngưng merchant đang hoạt động. + /// + [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 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 }); + } + } + + /// + /// EN: Reactivate a suspended merchant. + /// VI: Kích hoạt lại merchant bị tạm ngưng. + /// + [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 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 }); + } + } + + /// + /// EN: Permanently ban a merchant. + /// VI: Cấm vĩnh viễn một merchant. + /// + [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 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 + +/// +/// EN: Request for admin actions requiring a reason. +/// VI: Request cho các action admin cần lý do. +/// +public record AdminActionRequest(string Reason); + +#endregion diff --git a/services/merchant-service-net/src/MerchantService.API/Controllers/Admin/AdminShopsController.cs b/services/merchant-service-net/src/MerchantService.API/Controllers/Admin/AdminShopsController.cs new file mode 100644 index 00000000..40c54645 --- /dev/null +++ b/services/merchant-service-net/src/MerchantService.API/Controllers/Admin/AdminShopsController.cs @@ -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; + +/// +/// EN: Admin controller for shop management operations (Backoffice). +/// VI: Controller Admin cho các thao tác quản lý shop (Backoffice). +/// +[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 _logger; + + public AdminShopsController( + IMediator mediator, + IShopRepository shopRepository, + ILogger logger) + { + _mediator = mediator; + _shopRepository = shopRepository; + _logger = logger; + } + + /// + /// EN: Get all shops with pagination. + /// VI: Lấy tất cả shops với phân trang. + /// + [HttpGet] + [SwaggerOperation(Summary = "Get all shops", Description = "Get all shops with pagination (Admin only)")] + [SwaggerResponse(200, "Success", typeof(AdminShopListResultDto))] + public async Task 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); + } + + /// + /// EN: Get shop details by ID. + /// VI: Lấy chi tiết shop theo ID. + /// + [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 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 + }); + } + + /// + /// EN: Suspend a shop. + /// VI: Tạm ngưng shop. + /// + [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 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 }); + } + } + + /// + /// EN: Reactivate a suspended shop. + /// VI: Kích hoạt lại shop bị tạm ngưng. + /// + [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 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 }); + } + } + + /// + /// EN: Close a shop permanently. + /// VI: Đóng cửa shop vĩnh viễn. + /// + [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 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; + } +} diff --git a/services/social-service-net/tests/SocialService.FunctionalTests/Controllers/BlocksControllerTests.cs b/services/social-service-net/tests/SocialService.FunctionalTests/Controllers/BlocksControllerTests.cs new file mode 100644 index 00000000..ad18e21b --- /dev/null +++ b/services/social-service-net/tests/SocialService.FunctionalTests/Controllers/BlocksControllerTests.cs @@ -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; + +/// +/// EN: Functional tests for Blocks API endpoints. +/// VI: Functional tests cho các endpoints API Blocks. +/// +public class BlocksControllerTests : IClassFixture +{ + 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(); + 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); +} diff --git a/services/social-service-net/tests/SocialService.FunctionalTests/Controllers/RelationshipsControllerTests.cs b/services/social-service-net/tests/SocialService.FunctionalTests/Controllers/RelationshipsControllerTests.cs new file mode 100644 index 00000000..7a934a45 --- /dev/null +++ b/services/social-service-net/tests/SocialService.FunctionalTests/Controllers/RelationshipsControllerTests.cs @@ -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; + +/// +/// EN: Functional tests for Relationships API endpoints. +/// VI: Functional tests cho các endpoints API Relationships. +/// +public class RelationshipsControllerTests : IClassFixture +{ + 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(); + 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(); + + // 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); +} diff --git a/services/social-service-net/tests/SocialService.FunctionalTests/Controllers/SamplesControllerTests.cs b/services/social-service-net/tests/SocialService.FunctionalTests/Controllers/SamplesControllerTests.cs deleted file mode 100644 index b4ee08ac..00000000 --- a/services/social-service-net/tests/SocialService.FunctionalTests/Controllers/SamplesControllerTests.cs +++ /dev/null @@ -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; - -/// -/// EN: Functional tests for Samples API endpoints. -/// VI: Functional tests cho các endpoints API Samples. -/// -public class SamplesControllerTests : IClassFixture -{ - 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>>(); - 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>(); - 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(bool Success, T? Data); - private record CreateSampleResult(Guid Id); -} diff --git a/services/social-service-net/tests/SocialService.FunctionalTests/CustomWebApplicationFactory.cs b/services/social-service-net/tests/SocialService.FunctionalTests/CustomWebApplicationFactory.cs index d7074177..b0ec5a63 100644 --- a/services/social-service-net/tests/SocialService.FunctionalTests/CustomWebApplicationFactory.cs +++ b/services/social-service-net/tests/SocialService.FunctionalTests/CustomWebApplicationFactory.cs @@ -18,26 +18,22 @@ public class CustomWebApplicationFactory : WebApplicationFactory 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)); + // 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) || + 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(options => @@ -45,8 +41,8 @@ public class CustomWebApplicationFactory : WebApplicationFactory 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(); diff --git a/services/social-service-net/tests/SocialService.UnitTests/Application/Handlers/BlockUserCommandHandlerTests.cs b/services/social-service-net/tests/SocialService.UnitTests/Application/Handlers/BlockUserCommandHandlerTests.cs new file mode 100644 index 00000000..0cd79bb2 --- /dev/null +++ b/services/social-service-net/tests/SocialService.UnitTests/Application/Handlers/BlockUserCommandHandlerTests.cs @@ -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; + +/// +/// EN: Unit tests for BlockUserCommandHandler. +/// VI: Unit tests cho BlockUserCommandHandler. +/// +public class BlockUserCommandHandlerTests +{ + private readonly IUserBlockRepository _userBlockRepository; + private readonly IRelationshipRepository _relationshipRepository; + private readonly ILogger _logger; + private readonly BlockUserCommandHandler _handler; + + public BlockUserCommandHandlerTests() + { + _userBlockRepository = Substitute.For(); + _relationshipRepository = Substitute.For(); + _logger = Substitute.For>(); + + // EN: Setup UnitOfWork mock + // VI: Setup mock cho UnitOfWork + var unitOfWork = Substitute.For(); + unitOfWork.SaveEntitiesAsync(Arg.Any()).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(), Arg.Any(), Arg.Any()) + .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(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() + .WithMessage("*yourself*"); + + _userBlockRepository.DidNotReceive().Add(Arg.Any()); + } + + [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() + .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(), Arg.Any(), Arg.Any()) + .Returns((Relationship?)null); + + // Act + var result = await _handler.Handle(command, CancellationToken.None); + + // Assert + result.Success.Should().BeTrue(); + _userBlockRepository.Received(1).Add(Arg.Is(b => b.Reason == null)); + } +} diff --git a/services/social-service-net/tests/SocialService.UnitTests/Application/Handlers/SendFriendRequestCommandHandlerTests.cs b/services/social-service-net/tests/SocialService.UnitTests/Application/Handlers/SendFriendRequestCommandHandlerTests.cs new file mode 100644 index 00000000..ebe63b58 --- /dev/null +++ b/services/social-service-net/tests/SocialService.UnitTests/Application/Handlers/SendFriendRequestCommandHandlerTests.cs @@ -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; + +/// +/// EN: Unit tests for SendFriendRequestCommandHandler. +/// VI: Unit tests cho SendFriendRequestCommandHandler. +/// +public class SendFriendRequestCommandHandlerTests +{ + private readonly IRelationshipRepository _relationshipRepository; + private readonly IUserBlockRepository _userBlockRepository; + private readonly ILogger _logger; + private readonly SendFriendRequestCommandHandler _handler; + + public SendFriendRequestCommandHandlerTests() + { + _relationshipRepository = Substitute.For(); + _userBlockRepository = Substitute.For(); + _logger = Substitute.For>(); + + // EN: Setup UnitOfWork mock + // VI: Setup mock cho UnitOfWork + var unitOfWork = Substitute.For(); + unitOfWork.SaveEntitiesAsync(Arg.Any()).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(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() + .WithMessage("*blocked*"); + + _relationshipRepository.DidNotReceive().Add(Arg.Any()); + } + + [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() + .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() + .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()); + } +} diff --git a/services/social-service-net/tests/SocialService.UnitTests/Domain/RelationshipAggregateTests.cs b/services/social-service-net/tests/SocialService.UnitTests/Domain/RelationshipAggregateTests.cs new file mode 100644 index 00000000..1f4b32e4 --- /dev/null +++ b/services/social-service-net/tests/SocialService.UnitTests/Domain/RelationshipAggregateTests.cs @@ -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; + +/// +/// EN: Unit tests for Relationship aggregate root. +/// VI: Unit tests cho Relationship aggregate root. +/// +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() + .WithMessage("*yourself*"); + } + + [Fact] + public void Create_WithEmptyRequesterId_ThrowsException() + { + // Act + var act = () => new Relationship(Guid.Empty, _addresseeId, RelationshipType.Friendship); + + // Assert + act.Should().Throw() + .WithMessage("*Requester ID*"); + } + + [Fact] + public void Create_WithEmptyAddresseeId_ThrowsException() + { + // Act + var act = () => new Relationship(_requesterId, Guid.Empty, RelationshipType.Friendship); + + // Assert + act.Should().Throw() + .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() + .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() + .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() + .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() + .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() + .WithMessage("*Only accepted*"); + } + + #endregion +} diff --git a/services/social-service-net/tests/SocialService.UnitTests/Domain/UserBlockAggregateTests.cs b/services/social-service-net/tests/SocialService.UnitTests/Domain/UserBlockAggregateTests.cs new file mode 100644 index 00000000..284edc78 --- /dev/null +++ b/services/social-service-net/tests/SocialService.UnitTests/Domain/UserBlockAggregateTests.cs @@ -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; + +/// +/// EN: Unit tests for UserBlock aggregate root. +/// VI: Unit tests cho UserBlock aggregate root. +/// +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() + .WithMessage("*yourself*"); + } + + [Fact] + public void Create_WithEmptyBlockerId_ThrowsException() + { + // Act + var act = () => new UserBlock(Guid.Empty, _blockedId); + + // Assert + act.Should().Throw() + .WithMessage("*Blocker ID*"); + } + + [Fact] + public void Create_WithEmptyBlockedId_ThrowsException() + { + // Act + var act = () => new UserBlock(_blockerId, Guid.Empty); + + // Assert + act.Should().Throw() + .WithMessage("*Blocked ID*"); + } + + #endregion +} diff --git a/services/social-service-net/tests/SocialService.UnitTests/SocialService.UnitTests.csproj b/services/social-service-net/tests/SocialService.UnitTests/SocialService.UnitTests.csproj index 9eefaf65..1f26774f 100644 --- a/services/social-service-net/tests/SocialService.UnitTests/SocialService.UnitTests.csproj +++ b/services/social-service-net/tests/SocialService.UnitTests/SocialService.UnitTests.csproj @@ -18,7 +18,7 @@ - +