HaniBlindBox/server/HoneyBox/tests/HoneyBox.Tests/Services/AnnouncementServicePropertyTests.cs
2026-02-02 07:59:16 +08:00

831 lines
28 KiB
C#

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;
/// <summary>
/// AnnouncementService 属性测试
/// 测试首页中大奖公告功能的核心属性
/// </summary>
public class AnnouncementServicePropertyTests
{
private readonly Mock<ILogger<AnnouncementService>> _mockLogger = new();
private (HoneyBoxDbContext dbContext, AnnouncementService service) CreateService()
{
var options = new DbContextOptionsBuilder<HoneyBoxDbContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
var dbContext = new HoneyBoxDbContext(options);
var service = new AnnouncementService(dbContext, _mockLogger.Object);
return (dbContext, service);
}
/// <summary>
/// 生成有效的非空字符串(用于必填字段)
/// </summary>
private static string GenerateValidString(string prefix, int seed)
{
return $"{prefix}_{Math.Abs(seed) % 1000}";
}
#region Property 1: CRUD Round-Trip Consistency
/// <summary>
/// **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**
/// </summary>
[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();
}
}
/// <summary>
/// **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**
/// </summary>
[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();
}
}
/// <summary>
/// **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**
/// </summary>
[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();
}
}
/// <summary>
/// **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**
/// </summary>
[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();
}
}
/// <summary>
/// **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**
/// </summary>
[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<int>();
// 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
/// <summary>
/// **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**
/// </summary>
[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();
}
}
/// <summary>
/// **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**
/// </summary>
[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();
}
}
/// <summary>
/// **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**
/// </summary>
[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();
}
}
/// <summary>
/// **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**
/// </summary>
[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();
}
}
/// <summary>
/// **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**
/// </summary>
[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();
}
}
/// <summary>
/// **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**
/// </summary>
[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();
}
}
/// <summary>
/// **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**
/// </summary>
[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();
}
}
/// <summary>
/// **Feature: homepage-prize-announcement, Property 4: Required Field Validation**
/// Valid requests with non-empty required fields should succeed.
/// **Validates: Requirements 1.5**
/// </summary>
[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
}