using FsCheck; using FsCheck.Xunit; using HoneyBox.Admin.Business.Models; using HoneyBox.Admin.Business.Models.Announcement; using HoneyBox.Admin.Business.Services; using HoneyBox.Model.Data; using HoneyBox.Model.Entities; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Moq; using Xunit; namespace HoneyBox.Tests.Services; /// /// AnnouncementService 属性测试 /// 测试首页中大奖公告功能的核心属性 /// public class AnnouncementServicePropertyTests { private readonly Mock> _mockLogger = new(); private (HoneyBoxDbContext dbContext, AnnouncementService service) CreateService() { var options = new DbContextOptionsBuilder() .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) .Options; var dbContext = new HoneyBoxDbContext(options); var service = new AnnouncementService(dbContext, _mockLogger.Object); return (dbContext, service); } /// /// 生成有效的非空字符串(用于必填字段) /// private static string GenerateValidString(string prefix, int seed) { return $"{prefix}_{Math.Abs(seed) % 1000}"; } #region Property 1: CRUD Round-Trip Consistency /// /// **Feature: homepage-prize-announcement, Property 1: CRUD Round-Trip Consistency** /// For any valid announcement data, creating an announcement and then reading it back /// should return the same data. /// **Validates: Requirements 1.1, 1.2, 1.3, 2.4** /// [Property(MaxTest = 100)] public bool CrudRoundTrip_CreateAndRead_ShouldReturnSameData( NonEmptyString userName, NonEmptyString prizeLevel, NonEmptyString prizeName, PositiveInt sort, bool isEnabled) { var actualUserName = userName.Get.Trim(); var actualPrizeLevel = prizeLevel.Get.Trim(); var actualPrizeName = prizeName.Get.Trim(); var actualSort = sort.Get % 1000; // Skip if any required field becomes empty after trim if (string.IsNullOrWhiteSpace(actualUserName) || string.IsNullOrWhiteSpace(actualPrizeLevel) || string.IsNullOrWhiteSpace(actualPrizeName)) { return true; // Skip this test case } var (dbContext, service) = CreateService(); try { // Create announcement var createRequest = new CreateAnnouncementRequest { UserAvatar = "http://test.com/avatar.jpg", UserName = actualUserName, PrizeLevel = actualPrizeLevel, PrizeName = actualPrizeName, Sort = actualSort, IsEnabled = isEnabled }; var created = service.CreateAsync(createRequest).GetAwaiter().GetResult(); // Read it back var retrieved = service.GetByIdAsync(created.Id).GetAwaiter().GetResult(); // Verify data consistency return retrieved.UserName == actualUserName && retrieved.PrizeLevel == actualPrizeLevel && retrieved.PrizeName == actualPrizeName && retrieved.Sort == actualSort && retrieved.IsEnabled == isEnabled && retrieved.UserAvatar == "http://test.com/avatar.jpg"; } finally { dbContext.Dispose(); } } /// /// **Feature: homepage-prize-announcement, Property 1: CRUD Round-Trip Consistency** /// For any valid announcement data, updating it and reading again should reflect the updates. /// **Validates: Requirements 1.1, 1.2, 1.3, 2.4** /// [Property(MaxTest = 100)] public bool CrudRoundTrip_UpdateAndRead_ShouldReflectUpdates( PositiveInt seed1, PositiveInt seed2, PositiveInt sort1, PositiveInt sort2, bool isEnabled1, bool isEnabled2) { var userName1 = GenerateValidString("User", seed1.Get); var prizeLevel1 = GenerateValidString("Level", seed1.Get); var prizeName1 = GenerateValidString("Prize", seed1.Get); var userName2 = GenerateValidString("UpdatedUser", seed2.Get); var prizeLevel2 = GenerateValidString("UpdatedLevel", seed2.Get); var prizeName2 = GenerateValidString("UpdatedPrize", seed2.Get); var actualSort1 = sort1.Get % 1000; var actualSort2 = sort2.Get % 1000; var (dbContext, service) = CreateService(); try { // Create initial announcement var createRequest = new CreateAnnouncementRequest { UserAvatar = "http://test.com/avatar1.jpg", UserName = userName1, PrizeLevel = prizeLevel1, PrizeName = prizeName1, Sort = actualSort1, IsEnabled = isEnabled1 }; var created = service.CreateAsync(createRequest).GetAwaiter().GetResult(); // Update the announcement var updateRequest = new UpdateAnnouncementRequest { UserAvatar = "http://test.com/avatar2.jpg", UserName = userName2, PrizeLevel = prizeLevel2, PrizeName = prizeName2, Sort = actualSort2, IsEnabled = isEnabled2 }; service.UpdateAsync(created.Id, updateRequest).GetAwaiter().GetResult(); // Read it back var retrieved = service.GetByIdAsync(created.Id).GetAwaiter().GetResult(); // Verify updates are reflected return retrieved.UserName == userName2 && retrieved.PrizeLevel == prizeLevel2 && retrieved.PrizeName == prizeName2 && retrieved.Sort == actualSort2 && retrieved.IsEnabled == isEnabled2 && retrieved.UserAvatar == "http://test.com/avatar2.jpg"; } finally { dbContext.Dispose(); } } /// /// **Feature: homepage-prize-announcement, Property 1: CRUD Round-Trip Consistency** /// For any valid announcement, deleting it should make it no longer retrievable. /// **Validates: Requirements 1.1, 1.2, 1.3, 2.4** /// [Property(MaxTest = 100)] public bool CrudRoundTrip_Delete_ShouldMakeUnretrievable(PositiveInt seed) { var userName = GenerateValidString("User", seed.Get); var prizeLevel = GenerateValidString("Level", seed.Get); var prizeName = GenerateValidString("Prize", seed.Get); var (dbContext, service) = CreateService(); try { // Create announcement var createRequest = new CreateAnnouncementRequest { UserName = userName, PrizeLevel = prizeLevel, PrizeName = prizeName, Sort = 0, IsEnabled = true }; var created = service.CreateAsync(createRequest).GetAwaiter().GetResult(); var createdId = created.Id; // Delete the announcement var deleteResult = service.DeleteAsync(createdId).GetAwaiter().GetResult(); if (!deleteResult) { return false; } // Try to retrieve it - should throw NotFound exception try { service.GetByIdAsync(createdId).GetAwaiter().GetResult(); return false; // Should not reach here } catch (BusinessException ex) { return ex.Code == BusinessErrorCodes.NotFound; } } finally { dbContext.Dispose(); } } /// /// **Feature: homepage-prize-announcement, Property 1: CRUD Round-Trip Consistency** /// Partial updates should only modify specified fields, leaving others unchanged. /// **Validates: Requirements 1.1, 1.2, 1.3, 2.4** /// [Property(MaxTest = 100)] public bool CrudRoundTrip_PartialUpdate_ShouldOnlyModifySpecifiedFields( PositiveInt seed, PositiveInt newSort) { var userName = GenerateValidString("User", seed.Get); var prizeLevel = GenerateValidString("Level", seed.Get); var prizeName = GenerateValidString("Prize", seed.Get); var originalSort = seed.Get % 500; var updatedSort = (newSort.Get % 500) + 500; // Ensure different value var (dbContext, service) = CreateService(); try { // Create announcement var createRequest = new CreateAnnouncementRequest { UserAvatar = "http://test.com/original.jpg", UserName = userName, PrizeLevel = prizeLevel, PrizeName = prizeName, Sort = originalSort, IsEnabled = true }; var created = service.CreateAsync(createRequest).GetAwaiter().GetResult(); // Partial update - only update Sort var updateRequest = new UpdateAnnouncementRequest { Sort = updatedSort }; service.UpdateAsync(created.Id, updateRequest).GetAwaiter().GetResult(); // Read it back var retrieved = service.GetByIdAsync(created.Id).GetAwaiter().GetResult(); // Verify only Sort changed, other fields remain the same return retrieved.Sort == updatedSort && retrieved.UserName == userName && retrieved.PrizeLevel == prizeLevel && retrieved.PrizeName == prizeName && retrieved.UserAvatar == "http://test.com/original.jpg" && retrieved.IsEnabled == true; } finally { dbContext.Dispose(); } } /// /// **Feature: homepage-prize-announcement, Property 1: CRUD Round-Trip Consistency** /// Multiple CRUD operations should maintain data consistency. /// **Validates: Requirements 1.1, 1.2, 1.3, 2.4** /// [Property(MaxTest = 50)] public bool CrudRoundTrip_MultipleCrudOperations_ShouldMaintainConsistency(PositiveInt operationCount) { var actualCount = Math.Max(2, operationCount.Get % 5 + 2); var (dbContext, service) = CreateService(); try { var createdIds = new List(); // Create multiple announcements for (int i = 0; i < actualCount; i++) { var createRequest = new CreateAnnouncementRequest { UserName = $"User_{i}", PrizeLevel = $"Level_{i}", PrizeName = $"Prize_{i}", Sort = i, IsEnabled = true }; var created = service.CreateAsync(createRequest).GetAwaiter().GetResult(); createdIds.Add(created.Id); } // Update each announcement for (int i = 0; i < actualCount; i++) { var updateRequest = new UpdateAnnouncementRequest { UserName = $"UpdatedUser_{i}", Sort = i + 100 }; service.UpdateAsync(createdIds[i], updateRequest).GetAwaiter().GetResult(); } // Verify all updates for (int i = 0; i < actualCount; i++) { var retrieved = service.GetByIdAsync(createdIds[i]).GetAwaiter().GetResult(); if (retrieved.UserName != $"UpdatedUser_{i}" || retrieved.Sort != i + 100) { return false; } } // Delete half of them for (int i = 0; i < actualCount / 2; i++) { service.DeleteAsync(createdIds[i]).GetAwaiter().GetResult(); } // Verify deleted ones are not retrievable for (int i = 0; i < actualCount / 2; i++) { try { service.GetByIdAsync(createdIds[i]).GetAwaiter().GetResult(); return false; // Should have thrown } catch (BusinessException ex) { if (ex.Code != BusinessErrorCodes.NotFound) { return false; } } } // Verify remaining ones are still retrievable for (int i = actualCount / 2; i < actualCount; i++) { var retrieved = service.GetByIdAsync(createdIds[i]).GetAwaiter().GetResult(); if (retrieved.UserName != $"UpdatedUser_{i}") { return false; } } return true; } finally { dbContext.Dispose(); } } #endregion #region Property 4: Required Field Validation /// /// **Feature: homepage-prize-announcement, Property 4: Required Field Validation** /// For any create request where UserName is empty or whitespace-only, /// the system should reject the request and return a validation error. /// **Validates: Requirements 1.5** /// [Property(MaxTest = 100)] public bool RequiredFieldValidation_EmptyUserName_ShouldRejectCreate(PositiveInt seed) { var prizeLevel = GenerateValidString("Level", seed.Get); var prizeName = GenerateValidString("Prize", seed.Get); var (dbContext, service) = CreateService(); try { var emptyUserNames = new[] { "", " ", "\t", "\n", " \t\n " }; foreach (var emptyUserName in emptyUserNames) { var createRequest = new CreateAnnouncementRequest { UserName = emptyUserName, PrizeLevel = prizeLevel, PrizeName = prizeName, Sort = 0, IsEnabled = true }; try { service.CreateAsync(createRequest).GetAwaiter().GetResult(); return false; // Should have thrown } catch (BusinessException ex) { if (ex.Code != BusinessErrorCodes.ValidationFailed) { return false; } } } // Verify database is not modified var count = dbContext.PrizeAnnouncements.Count(); return count == 0; } finally { dbContext.Dispose(); } } /// /// **Feature: homepage-prize-announcement, Property 4: Required Field Validation** /// For any create request where PrizeLevel is empty or whitespace-only, /// the system should reject the request and return a validation error. /// **Validates: Requirements 1.5** /// [Property(MaxTest = 100)] public bool RequiredFieldValidation_EmptyPrizeLevel_ShouldRejectCreate(PositiveInt seed) { var userName = GenerateValidString("User", seed.Get); var prizeName = GenerateValidString("Prize", seed.Get); var (dbContext, service) = CreateService(); try { var emptyPrizeLevels = new[] { "", " ", "\t", "\n", " \t\n " }; foreach (var emptyPrizeLevel in emptyPrizeLevels) { var createRequest = new CreateAnnouncementRequest { UserName = userName, PrizeLevel = emptyPrizeLevel, PrizeName = prizeName, Sort = 0, IsEnabled = true }; try { service.CreateAsync(createRequest).GetAwaiter().GetResult(); return false; // Should have thrown } catch (BusinessException ex) { if (ex.Code != BusinessErrorCodes.ValidationFailed) { return false; } } } // Verify database is not modified var count = dbContext.PrizeAnnouncements.Count(); return count == 0; } finally { dbContext.Dispose(); } } /// /// **Feature: homepage-prize-announcement, Property 4: Required Field Validation** /// For any create request where PrizeName is empty or whitespace-only, /// the system should reject the request and return a validation error. /// **Validates: Requirements 1.5** /// [Property(MaxTest = 100)] public bool RequiredFieldValidation_EmptyPrizeName_ShouldRejectCreate(PositiveInt seed) { var userName = GenerateValidString("User", seed.Get); var prizeLevel = GenerateValidString("Level", seed.Get); var (dbContext, service) = CreateService(); try { var emptyPrizeNames = new[] { "", " ", "\t", "\n", " \t\n " }; foreach (var emptyPrizeName in emptyPrizeNames) { var createRequest = new CreateAnnouncementRequest { UserName = userName, PrizeLevel = prizeLevel, PrizeName = emptyPrizeName, Sort = 0, IsEnabled = true }; try { service.CreateAsync(createRequest).GetAwaiter().GetResult(); return false; // Should have thrown } catch (BusinessException ex) { if (ex.Code != BusinessErrorCodes.ValidationFailed) { return false; } } } // Verify database is not modified var count = dbContext.PrizeAnnouncements.Count(); return count == 0; } finally { dbContext.Dispose(); } } /// /// **Feature: homepage-prize-announcement, Property 4: Required Field Validation** /// For any update request where UserName is set to empty or whitespace-only, /// the system should reject the request and return a validation error without modifying the database. /// **Validates: Requirements 1.5** /// [Property(MaxTest = 100)] public bool RequiredFieldValidation_EmptyUserName_ShouldRejectUpdate(PositiveInt seed) { var userName = GenerateValidString("User", seed.Get); var prizeLevel = GenerateValidString("Level", seed.Get); var prizeName = GenerateValidString("Prize", seed.Get); var (dbContext, service) = CreateService(); try { // First create a valid announcement var createRequest = new CreateAnnouncementRequest { UserName = userName, PrizeLevel = prizeLevel, PrizeName = prizeName, Sort = 0, IsEnabled = true }; var created = service.CreateAsync(createRequest).GetAwaiter().GetResult(); var emptyUserNames = new[] { "", " ", "\t", "\n" }; foreach (var emptyUserName in emptyUserNames) { var updateRequest = new UpdateAnnouncementRequest { UserName = emptyUserName }; try { service.UpdateAsync(created.Id, updateRequest).GetAwaiter().GetResult(); return false; // Should have thrown } catch (BusinessException ex) { if (ex.Code != BusinessErrorCodes.ValidationFailed) { return false; } } } // Verify original data is not modified var retrieved = service.GetByIdAsync(created.Id).GetAwaiter().GetResult(); return retrieved.UserName == userName; } finally { dbContext.Dispose(); } } /// /// **Feature: homepage-prize-announcement, Property 4: Required Field Validation** /// For any update request where PrizeLevel is set to empty or whitespace-only, /// the system should reject the request and return a validation error without modifying the database. /// **Validates: Requirements 1.5** /// [Property(MaxTest = 100)] public bool RequiredFieldValidation_EmptyPrizeLevel_ShouldRejectUpdate(PositiveInt seed) { var userName = GenerateValidString("User", seed.Get); var prizeLevel = GenerateValidString("Level", seed.Get); var prizeName = GenerateValidString("Prize", seed.Get); var (dbContext, service) = CreateService(); try { // First create a valid announcement var createRequest = new CreateAnnouncementRequest { UserName = userName, PrizeLevel = prizeLevel, PrizeName = prizeName, Sort = 0, IsEnabled = true }; var created = service.CreateAsync(createRequest).GetAwaiter().GetResult(); var emptyPrizeLevels = new[] { "", " ", "\t", "\n" }; foreach (var emptyPrizeLevel in emptyPrizeLevels) { var updateRequest = new UpdateAnnouncementRequest { PrizeLevel = emptyPrizeLevel }; try { service.UpdateAsync(created.Id, updateRequest).GetAwaiter().GetResult(); return false; // Should have thrown } catch (BusinessException ex) { if (ex.Code != BusinessErrorCodes.ValidationFailed) { return false; } } } // Verify original data is not modified var retrieved = service.GetByIdAsync(created.Id).GetAwaiter().GetResult(); return retrieved.PrizeLevel == prizeLevel; } finally { dbContext.Dispose(); } } /// /// **Feature: homepage-prize-announcement, Property 4: Required Field Validation** /// For any update request where PrizeName is set to empty or whitespace-only, /// the system should reject the request and return a validation error without modifying the database. /// **Validates: Requirements 1.5** /// [Property(MaxTest = 100)] public bool RequiredFieldValidation_EmptyPrizeName_ShouldRejectUpdate(PositiveInt seed) { var userName = GenerateValidString("User", seed.Get); var prizeLevel = GenerateValidString("Level", seed.Get); var prizeName = GenerateValidString("Prize", seed.Get); var (dbContext, service) = CreateService(); try { // First create a valid announcement var createRequest = new CreateAnnouncementRequest { UserName = userName, PrizeLevel = prizeLevel, PrizeName = prizeName, Sort = 0, IsEnabled = true }; var created = service.CreateAsync(createRequest).GetAwaiter().GetResult(); var emptyPrizeNames = new[] { "", " ", "\t", "\n" }; foreach (var emptyPrizeName in emptyPrizeNames) { var updateRequest = new UpdateAnnouncementRequest { PrizeName = emptyPrizeName }; try { service.UpdateAsync(created.Id, updateRequest).GetAwaiter().GetResult(); return false; // Should have thrown } catch (BusinessException ex) { if (ex.Code != BusinessErrorCodes.ValidationFailed) { return false; } } } // Verify original data is not modified var retrieved = service.GetByIdAsync(created.Id).GetAwaiter().GetResult(); return retrieved.PrizeName == prizeName; } finally { dbContext.Dispose(); } } /// /// **Feature: homepage-prize-announcement, Property 4: Required Field Validation** /// For any create request with all required fields empty, /// the system should reject the request without modifying the database. /// **Validates: Requirements 1.5** /// [Property(MaxTest = 50)] public bool RequiredFieldValidation_AllFieldsEmpty_ShouldRejectCreate(PositiveInt seed) { var (dbContext, service) = CreateService(); try { var createRequest = new CreateAnnouncementRequest { UserName = "", PrizeLevel = "", PrizeName = "", Sort = 0, IsEnabled = true }; try { service.CreateAsync(createRequest).GetAwaiter().GetResult(); return false; // Should have thrown } catch (BusinessException ex) { if (ex.Code != BusinessErrorCodes.ValidationFailed) { return false; } } // Verify database is not modified var count = dbContext.PrizeAnnouncements.Count(); return count == 0; } finally { dbContext.Dispose(); } } /// /// **Feature: homepage-prize-announcement, Property 4: Required Field Validation** /// Valid requests with non-empty required fields should succeed. /// **Validates: Requirements 1.5** /// [Property(MaxTest = 100)] public bool RequiredFieldValidation_ValidFields_ShouldSucceed( NonEmptyString userName, NonEmptyString prizeLevel, NonEmptyString prizeName) { var actualUserName = userName.Get.Trim(); var actualPrizeLevel = prizeLevel.Get.Trim(); var actualPrizeName = prizeName.Get.Trim(); // Skip if any required field becomes empty after trim if (string.IsNullOrWhiteSpace(actualUserName) || string.IsNullOrWhiteSpace(actualPrizeLevel) || string.IsNullOrWhiteSpace(actualPrizeName)) { return true; // Skip this test case } var (dbContext, service) = CreateService(); try { var createRequest = new CreateAnnouncementRequest { UserName = actualUserName, PrizeLevel = actualPrizeLevel, PrizeName = actualPrizeName, Sort = 0, IsEnabled = true }; var created = service.CreateAsync(createRequest).GetAwaiter().GetResult(); // Verify creation succeeded return created.Id > 0 && created.UserName == actualUserName && created.PrizeLevel == actualPrizeLevel && created.PrizeName == actualPrizeName; } catch (BusinessException) { return false; // Should not throw for valid data } finally { dbContext.Dispose(); } } #endregion }