using FsCheck; using FsCheck.Xunit; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using MiAssessment.Admin.Business.Data; using MiAssessment.Admin.Business.Entities; using MiAssessment.Admin.Business.Models; using MiAssessment.Admin.Business.Models.Common; using MiAssessment.Admin.Business.Models.Content; using MiAssessment.Admin.Business.Services; using Moq; using Xunit; namespace MiAssessment.Tests.Admin; /// /// Banner 属性测试 /// 验证轮播图服务的正确性属性 /// public class BannerPropertyTests { private readonly Mock> _mockLogger = new(); #region Property 1: Soft Delete Behavior /// /// Property 1: 软删除后实体仍存在于数据库中 /// *For any* entity that supports soft delete, when a delete operation is performed, /// the entity's IsDeleted field SHALL be set to true and the entity SHALL remain in the database. /// /// **Validates: Requirements 2.7** /// [Property(MaxTest = 100)] public bool SoftDeleteBehavior_EntityRemainsInDatabase(PositiveInt seed) { // Arrange: 创建包含轮播图的数据库 using var dbContext = CreateDbContext(); var bannerId = CreateTestBanner(dbContext, seed.Get); var service = new ContentService(dbContext, _mockLogger.Object); // Act: 执行删除操作 var result = service.DeleteBannerAsync(bannerId).GetAwaiter().GetResult(); // Assert: 实体应该仍然存在于数据库中,且 IsDeleted = true var deletedBanner = dbContext.Banners.FirstOrDefault(b => b.Id == bannerId); return result && deletedBanner != null && deletedBanner.IsDeleted; } /// /// Property 1: 软删除后的实体不应出现在列表查询中 /// *For any* deleted entity, it SHALL NOT appear in list queries. /// /// **Validates: Requirements 2.7** /// [Property(MaxTest = 100)] public bool SoftDeleteBehavior_DeletedEntityNotInListQuery(PositiveInt seed) { // Arrange: 创建多个轮播图 using var dbContext = CreateDbContext(); var bannerId1 = CreateTestBanner(dbContext, seed.Get); var bannerId2 = CreateTestBanner(dbContext, seed.Get + 1000); var service = new ContentService(dbContext, _mockLogger.Object); // Act: 删除其中一个轮播图 service.DeleteBannerAsync(bannerId1).GetAwaiter().GetResult(); // 查询列表 var request = new BannerQueryRequest { Page = 1, PageSize = 100 }; var result = service.GetBannerListAsync(request).GetAwaiter().GetResult(); // Assert: 删除的轮播图不应出现在列表中 var deletedBannerInList = result.List.Any(b => b.Id == bannerId1); var activeBannerInList = result.List.Any(b => b.Id == bannerId2); return !deletedBannerInList && activeBannerInList; } /// /// Property 1: 软删除后 UpdateTime 应该被更新 /// /// **Validates: Requirements 2.7** /// [Property(MaxTest = 50)] public bool SoftDeleteBehavior_UpdateTimeIsSet(PositiveInt seed) { // Arrange using var dbContext = CreateDbContext(); var originalUpdateTime = DateTime.Now.AddDays(-1); var banner = new Banner { Title = $"Test Banner {seed.Get}", ImageUrl = $"https://example.com/image_{seed.Get}.jpg", LinkType = 0, Sort = seed.Get % 100, Status = 1, CreateTime = originalUpdateTime, UpdateTime = originalUpdateTime, IsDeleted = false }; dbContext.Banners.Add(banner); dbContext.SaveChanges(); var service = new ContentService(dbContext, _mockLogger.Object); var beforeDelete = DateTime.Now; // Act: 执行删除操作 service.DeleteBannerAsync(banner.Id).GetAwaiter().GetResult(); var afterDelete = DateTime.Now; // Assert: UpdateTime 应该在删除前后的时间范围内 var deletedBanner = dbContext.Banners.First(b => b.Id == banner.Id); return deletedBanner.UpdateTime >= beforeDelete && deletedBanner.UpdateTime <= afterDelete && deletedBanner.UpdateTime > originalUpdateTime; } #endregion #region Property 4: Sorting Correctness /// /// Property 4: 列表按 Sort 字段降序排列 /// *For any* list query with a Sort field, the returned items SHALL be ordered /// by the Sort field in descending order. /// /// **Validates: Requirements 2.1** /// [Property(MaxTest = 100)] public bool SortingCorrectness_ListOrderedBySortDescending(PositiveInt seed) { // Arrange: 创建多个具有不同 Sort 值的轮播图 using var dbContext = CreateDbContext(); var sortValues = GenerateSortValues(seed.Get, 5); foreach (var sortValue in sortValues) { var banner = new Banner { Title = $"Banner Sort {sortValue}", ImageUrl = $"https://example.com/image_{sortValue}.jpg", LinkType = 0, Sort = sortValue, Status = 1, CreateTime = DateTime.Now, UpdateTime = DateTime.Now, IsDeleted = false }; dbContext.Banners.Add(banner); } dbContext.SaveChanges(); var service = new ContentService(dbContext, _mockLogger.Object); // Act: 查询列表 var request = new BannerQueryRequest { Page = 1, PageSize = 100 }; var result = service.GetBannerListAsync(request).GetAwaiter().GetResult(); // Assert: 列表应该按 Sort 降序排列 if (result.List.Count < 2) return true; for (int i = 0; i < result.List.Count - 1; i++) { if (result.List[i].Sort < result.List[i + 1].Sort) { return false; } } return true; } /// /// Property 4: 相同 Sort 值时按 CreateTime 降序排列 /// /// **Validates: Requirements 2.1** /// [Property(MaxTest = 50)] public bool SortingCorrectness_SameSortOrderedByCreateTimeDescending(PositiveInt seed) { // Arrange: 创建多个具有相同 Sort 值但不同 CreateTime 的轮播图 using var dbContext = CreateDbContext(); var sameSort = seed.Get % 100; var baseTime = DateTime.Now.AddDays(-10); for (int i = 0; i < 3; i++) { var banner = new Banner { Title = $"Banner {i}", ImageUrl = $"https://example.com/image_{i}.jpg", LinkType = 0, Sort = sameSort, Status = 1, CreateTime = baseTime.AddDays(i), // 不同的创建时间 UpdateTime = DateTime.Now, IsDeleted = false }; dbContext.Banners.Add(banner); } dbContext.SaveChanges(); var service = new ContentService(dbContext, _mockLogger.Object); // Act: 查询列表 var request = new BannerQueryRequest { Page = 1, PageSize = 100 }; var result = service.GetBannerListAsync(request).GetAwaiter().GetResult(); // Assert: 相同 Sort 值时应该按 CreateTime 降序排列 var sameSortBanners = result.List.Where(b => b.Sort == sameSort).ToList(); if (sameSortBanners.Count < 2) return true; for (int i = 0; i < sameSortBanners.Count - 1; i++) { if (sameSortBanners[i].CreateTime < sameSortBanners[i + 1].CreateTime) { return false; } } return true; } /// /// Property 4: 排序更新后列表顺序应该正确 /// /// **Validates: Requirements 2.1** /// [Property(MaxTest = 50)] public bool SortingCorrectness_AfterSortUpdate_ListOrderCorrect(PositiveInt seed) { // Arrange: 创建轮播图 using var dbContext = CreateDbContext(); var banner1 = CreateTestBannerWithSort(dbContext, seed.Get, 10); var banner2 = CreateTestBannerWithSort(dbContext, seed.Get + 1, 20); var banner3 = CreateTestBannerWithSort(dbContext, seed.Get + 2, 30); var service = new ContentService(dbContext, _mockLogger.Object); // Act: 更新排序,将 banner1 的 Sort 改为最大 var sortItems = new List { new SortItem { Id = banner1, Sort = 100 } }; service.UpdateBannerSortAsync(sortItems).GetAwaiter().GetResult(); // 查询列表 var request = new BannerQueryRequest { Page = 1, PageSize = 100 }; var result = service.GetBannerListAsync(request).GetAwaiter().GetResult(); // Assert: banner1 应该排在第一位 return result.List.Count > 0 && result.List[0].Id == banner1; } #endregion #region Property 7: Banner Link Type Validation /// /// Property 7: LinkType 1 或 2 时 LinkUrl 必填 /// *For any* Banner with LinkType 1 or 2, the LinkUrl SHALL be non-empty. /// /// **Validates: Requirements 2.4** /// [Property(MaxTest = 100)] public bool LinkTypeValidation_LinkUrlRequiredForType1And2(PositiveInt seed) { // Arrange using var dbContext = CreateDbContext(); var service = new ContentService(dbContext, _mockLogger.Object); // 测试 LinkType 1 (内部页面) 和 2 (外部链接) var linkType = (seed.Get % 2) + 1; // 1 或 2 var request = new CreateBannerRequest { Title = $"Test Banner {seed.Get}", ImageUrl = $"https://example.com/image_{seed.Get}.jpg", LinkType = linkType, LinkUrl = null, // 空的 LinkUrl Sort = seed.Get % 100, Status = 1 }; // Act & Assert: 应该抛出 BusinessException try { service.CreateBannerAsync(request).GetAwaiter().GetResult(); return false; // 应该抛出异常 } catch (BusinessException ex) { return ex.Code == ErrorCodes.BannerLinkUrlRequired; } } /// /// Property 7: LinkType 1 或 2 时提供有效 LinkUrl 应该成功 /// /// **Validates: Requirements 2.4** /// [Property(MaxTest = 100)] public bool LinkTypeValidation_ValidLinkUrlForType1And2_Success(PositiveInt seed) { // Arrange using var dbContext = CreateDbContext(); var service = new ContentService(dbContext, _mockLogger.Object); var linkType = (seed.Get % 2) + 1; // 1 或 2 var request = new CreateBannerRequest { Title = $"Test Banner {seed.Get}", ImageUrl = $"https://example.com/image_{seed.Get}.jpg", LinkType = linkType, LinkUrl = $"https://example.com/page_{seed.Get}", // 有效的 LinkUrl Sort = seed.Get % 100, Status = 1 }; // Act var bannerId = service.CreateBannerAsync(request).GetAwaiter().GetResult(); // Assert: 应该创建成功 var banner = dbContext.Banners.FirstOrDefault(b => b.Id == bannerId); return banner != null && banner.LinkType == linkType && !string.IsNullOrEmpty(banner.LinkUrl); } /// /// Property 7: LinkType 3 时 LinkUrl 和 AppId 都必填 /// *For any* Banner with LinkType 3, both LinkUrl and AppId SHALL be non-empty. /// /// **Validates: Requirements 2.5** /// [Property(MaxTest = 100)] public bool LinkTypeValidation_LinkUrlAndAppIdRequiredForType3(PositiveInt seed) { // Arrange using var dbContext = CreateDbContext(); var service = new ContentService(dbContext, _mockLogger.Object); // 测试场景:LinkUrl 为空 var request1 = new CreateBannerRequest { Title = $"Test Banner {seed.Get}", ImageUrl = $"https://example.com/image_{seed.Get}.jpg", LinkType = 3, // 小程序 LinkUrl = null, // 空的 LinkUrl AppId = "wx1234567890", Sort = seed.Get % 100, Status = 1 }; bool linkUrlValidationFailed = false; try { service.CreateBannerAsync(request1).GetAwaiter().GetResult(); } catch (BusinessException ex) { linkUrlValidationFailed = ex.Code == ErrorCodes.BannerLinkUrlRequired; } // 测试场景:AppId 为空 var request2 = new CreateBannerRequest { Title = $"Test Banner {seed.Get + 1}", ImageUrl = $"https://example.com/image_{seed.Get + 1}.jpg", LinkType = 3, // 小程序 LinkUrl = "/pages/index/index", AppId = null, // 空的 AppId Sort = seed.Get % 100, Status = 1 }; bool appIdValidationFailed = false; try { service.CreateBannerAsync(request2).GetAwaiter().GetResult(); } catch (BusinessException ex) { appIdValidationFailed = ex.Code == ErrorCodes.BannerLinkUrlRequired; } return linkUrlValidationFailed && appIdValidationFailed; } /// /// Property 7: LinkType 3 时提供有效 LinkUrl 和 AppId 应该成功 /// /// **Validates: Requirements 2.5** /// [Property(MaxTest = 100)] public bool LinkTypeValidation_ValidLinkUrlAndAppIdForType3_Success(PositiveInt seed) { // Arrange using var dbContext = CreateDbContext(); var service = new ContentService(dbContext, _mockLogger.Object); var request = new CreateBannerRequest { Title = $"Test Banner {seed.Get}", ImageUrl = $"https://example.com/image_{seed.Get}.jpg", LinkType = 3, // 小程序 LinkUrl = "/pages/index/index", AppId = $"wx{seed.Get:D10}", // 有效的 AppId Sort = seed.Get % 100, Status = 1 }; // Act var bannerId = service.CreateBannerAsync(request).GetAwaiter().GetResult(); // Assert: 应该创建成功 var banner = dbContext.Banners.FirstOrDefault(b => b.Id == bannerId); return banner != null && banner.LinkType == 3 && !string.IsNullOrEmpty(banner.LinkUrl) && !string.IsNullOrEmpty(banner.AppId); } /// /// Property 7: LinkType 0 时不需要 LinkUrl 和 AppId /// *For any* Banner with LinkType 0, the LinkUrl and AppId are optional. /// /// **Validates: Requirements 2.3** /// [Property(MaxTest = 100)] public bool LinkTypeValidation_NoValidationForType0(PositiveInt seed) { // Arrange using var dbContext = CreateDbContext(); var service = new ContentService(dbContext, _mockLogger.Object); var request = new CreateBannerRequest { Title = $"Test Banner {seed.Get}", ImageUrl = $"https://example.com/image_{seed.Get}.jpg", LinkType = 0, // 无跳转 LinkUrl = null, // 空的 LinkUrl AppId = null, // 空的 AppId Sort = seed.Get % 100, Status = 1 }; // Act try { var bannerId = service.CreateBannerAsync(request).GetAwaiter().GetResult(); // Assert: 应该创建成功 var banner = dbContext.Banners.FirstOrDefault(b => b.Id == bannerId); return banner != null && banner.LinkType == 0; } catch (BusinessException) { return false; // 不应该抛出异常 } } /// /// Property 7: 更新时也应验证 LinkType /// /// **Validates: Requirements 2.3, 2.4, 2.5** /// [Property(MaxTest = 50)] public bool LinkTypeValidation_UpdateAlsoValidates(PositiveInt seed) { // Arrange: 先创建一个有效的轮播图 using var dbContext = CreateDbContext(); var bannerId = CreateTestBanner(dbContext, seed.Get); var service = new ContentService(dbContext, _mockLogger.Object); // Act: 尝试更新为 LinkType 1 但不提供 LinkUrl var updateRequest = new UpdateBannerRequest { Id = bannerId, Title = $"Updated Banner {seed.Get}", ImageUrl = $"https://example.com/updated_{seed.Get}.jpg", LinkType = 1, // 内部页面 LinkUrl = null, // 空的 LinkUrl Sort = seed.Get % 100, Status = 1 }; // Assert: 应该抛出 BusinessException try { service.UpdateBannerAsync(updateRequest).GetAwaiter().GetResult(); return false; } catch (BusinessException ex) { return ex.Code == ErrorCodes.BannerLinkUrlRequired; } } #endregion #region 辅助方法 /// /// 创建内存数据库上下文 /// private AdminBusinessDbContext CreateDbContext() { var options = new DbContextOptionsBuilder() .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) .Options; return new AdminBusinessDbContext(options); } /// /// 创建测试轮播图 /// private long CreateTestBanner(AdminBusinessDbContext dbContext, int seed) { var banner = new Banner { Title = $"Test Banner {seed}", ImageUrl = $"https://example.com/image_{seed}.jpg", LinkType = 0, Sort = seed % 100, Status = 1, CreateTime = DateTime.Now, UpdateTime = DateTime.Now, IsDeleted = false }; dbContext.Banners.Add(banner); dbContext.SaveChanges(); return banner.Id; } /// /// 创建指定 Sort 值的测试轮播图 /// private long CreateTestBannerWithSort(AdminBusinessDbContext dbContext, int seed, int sort) { var banner = new Banner { Title = $"Test Banner {seed}", ImageUrl = $"https://example.com/image_{seed}.jpg", LinkType = 0, Sort = sort, Status = 1, CreateTime = DateTime.Now, UpdateTime = DateTime.Now, IsDeleted = false }; dbContext.Banners.Add(banner); dbContext.SaveChanges(); return banner.Id; } /// /// 生成不同的 Sort 值列表 /// private List GenerateSortValues(int seed, int count) { var values = new List(); var random = new Random(seed); for (int i = 0; i < count; i++) { values.Add(random.Next(1, 1000)); } return values; } #endregion }