using FsCheck; using FsCheck.Xunit; using HoneyBox.Admin.Business.Models; using HoneyBox.Admin.Business.Models.DesignatedPrize; 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; /// /// DesignatedPrizeService 属性测试 /// public class DesignatedPrizeServicePropertyTests { private readonly Mock> _mockLogger = new(); private (HoneyBoxDbContext dbContext, DesignatedPrizeService service) CreateService() { var options = new DbContextOptionsBuilder() .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) .Options; var dbContext = new HoneyBoxDbContext(options); var service = new DesignatedPrizeService(dbContext, _mockLogger.Object); return (dbContext, service); } private async Task<(int goodsId, List prizeIds, int userId)> SeedTestDataAsync(HoneyBoxDbContext dbContext, int prizeCount) { // Create user var user = new User { OpenId = $"test_openid_{Guid.NewGuid()}", Uid = $"test_uid_{Guid.NewGuid()}", Nickname = "测试用户", HeadImg = "http://test.com/avatar.jpg", Mobile = "13800138000", CreatedAt = DateTime.Now, UpdatedAt = DateTime.Now }; dbContext.Users.Add(user); await dbContext.SaveChangesAsync(); // Create goods var goods = new Good { Title = "测试商品", ImgUrl = "http://test.com/goods.jpg", ImgUrlDetail = "http://test.com/goods_detail.jpg", Price = 100, Type = 1, Status = 1, Stock = 100, CreatedAt = DateTime.Now, UpdatedAt = DateTime.Now }; dbContext.Goods.Add(goods); await dbContext.SaveChangesAsync(); // Create prizes var prizeIds = new List(); for (int i = 0; i < prizeCount; i++) { var prize = new GoodsItem { GoodsId = goods.Id, Num = i + 1, Title = $"奖品{i + 1}", ImgUrl = $"http://test.com/prize{i + 1}.jpg", Stock = 1, SurplusStock = 1, Price = 500, Money = 300, ScMoney = 250, RealPro = 1, GoodsType = 1, PrizeCode = $"PC{i + 1:D3}", CreatedAt = DateTime.Now, UpdatedAt = DateTime.Now }; dbContext.GoodsItems.Add(prize); await dbContext.SaveChangesAsync(); prizeIds.Add(prize.Id); } return (goods.Id, prizeIds, user.Id); } #region Property 1: Unique Constraint Enforcement /// /// **Feature: designated-prize-winner, Property 1: Unique Constraint Enforcement** /// For any goods and goods_item combination, attempting to create a second designated prize /// configuration SHALL result in an error, ensuring only one user can be designated per prize. /// **Validates: Requirements 1.2, 4.5** /// [Property(MaxTest = 100)] public bool UniqueConstraint_ShouldPreventDuplicateConfiguration(PositiveInt prizeCount, PositiveInt userId1, PositiveInt userId2) { var actualPrizeCount = Math.Max(1, prizeCount.Get % 5 + 1); var actualUserId1 = userId1.Get; var actualUserId2 = userId2.Get; // Ensure different user IDs for the test if (actualUserId1 == actualUserId2) { actualUserId2 = actualUserId1 + 1; } var (dbContext, service) = CreateService(); try { // Seed test data var (goodsId, prizeIds, _) = SeedTestDataAsync(dbContext, actualPrizeCount).GetAwaiter().GetResult(); // Create additional users for the test var user1 = new User { OpenId = $"user1_{Guid.NewGuid()}", Uid = $"uid1_{Guid.NewGuid()}", Nickname = "用户1", HeadImg = "http://test.com/u1.jpg", CreatedAt = DateTime.Now, UpdatedAt = DateTime.Now }; var user2 = new User { OpenId = $"user2_{Guid.NewGuid()}", Uid = $"uid2_{Guid.NewGuid()}", Nickname = "用户2", HeadImg = "http://test.com/u2.jpg", CreatedAt = DateTime.Now, UpdatedAt = DateTime.Now }; dbContext.Users.AddRange(user1, user2); dbContext.SaveChanges(); var targetPrizeId = prizeIds[0]; // First creation should succeed var request1 = new CreateDesignatedPrizeRequest { GoodsItemId = targetPrizeId, UserId = user1.Id, Remark = "First config" }; var result1 = service.CreateAsync(goodsId, request1).GetAwaiter().GetResult(); // Second creation with same goods_id and goods_item_id should fail var request2 = new CreateDesignatedPrizeRequest { GoodsItemId = targetPrizeId, UserId = user2.Id, Remark = "Second config" }; try { service.CreateAsync(goodsId, request2).GetAwaiter().GetResult(); // If we reach here, the constraint was not enforced return false; } catch (BusinessException ex) { // Expected: Should throw BusinessException with Conflict code return ex.Code == BusinessErrorCodes.Conflict; } } finally { dbContext.Dispose(); } } /// /// **Feature: designated-prize-winner, Property 1: Unique Constraint Enforcement** /// Different prizes in the same goods can each have their own designated user. /// **Validates: Requirements 1.2, 4.5** /// [Property(MaxTest = 100)] public bool UniqueConstraint_ShouldAllowDifferentPrizesToHaveDifferentDesignatedUsers(PositiveInt prizeCount) { var actualPrizeCount = Math.Max(2, prizeCount.Get % 5 + 2); var (dbContext, service) = CreateService(); try { // Seed test data var (goodsId, prizeIds, _) = SeedTestDataAsync(dbContext, actualPrizeCount).GetAwaiter().GetResult(); // Create users for each prize var users = new List(); for (int i = 0; i < actualPrizeCount; i++) { var user = new User { OpenId = $"user{i}_{Guid.NewGuid()}", Uid = $"uid{i}_{Guid.NewGuid()}", Nickname = $"用户{i}", HeadImg = $"http://test.com/u{i}.jpg", CreatedAt = DateTime.Now, UpdatedAt = DateTime.Now }; users.Add(user); } dbContext.Users.AddRange(users); dbContext.SaveChanges(); // Each prize should be able to have its own designated user for (int i = 0; i < actualPrizeCount; i++) { var request = new CreateDesignatedPrizeRequest { GoodsItemId = prizeIds[i], UserId = users[i].Id, Remark = $"Config for prize {i}" }; try { var result = service.CreateAsync(goodsId, request).GetAwaiter().GetResult(); if (result == null) { return false; } } catch { // Should not throw for different prizes return false; } } // Verify all configurations were created var configs = service.GetByGoodsIdAsync(goodsId).GetAwaiter().GetResult(); return configs.Count == actualPrizeCount; } finally { dbContext.Dispose(); } } /// /// **Feature: designated-prize-winner, Property 1: Unique Constraint Enforcement** /// Same prize in different goods can have designated users (unique constraint is per goods+prize). /// **Validates: Requirements 1.2, 4.5** /// [Property(MaxTest = 50)] public bool UniqueConstraint_ShouldAllowSamePrizeIdInDifferentGoods(PositiveInt userId) { var (dbContext, service) = CreateService(); try { // Create user var user = new User { OpenId = $"user_{Guid.NewGuid()}", Uid = $"uid_{Guid.NewGuid()}", Nickname = "测试用户", HeadImg = "http://test.com/u.jpg", CreatedAt = DateTime.Now, UpdatedAt = DateTime.Now }; dbContext.Users.Add(user); dbContext.SaveChanges(); // Create two different goods var goods1 = new Good { Title = "商品1", ImgUrl = "http://test.com/g1.jpg", ImgUrlDetail = "http://test.com/g1_detail.jpg", Price = 100, Type = 1, Status = 1, Stock = 100, CreatedAt = DateTime.Now, UpdatedAt = DateTime.Now }; var goods2 = new Good { Title = "商品2", ImgUrl = "http://test.com/g2.jpg", ImgUrlDetail = "http://test.com/g2_detail.jpg", Price = 100, Type = 1, Status = 1, Stock = 100, CreatedAt = DateTime.Now, UpdatedAt = DateTime.Now }; dbContext.Goods.AddRange(goods1, goods2); dbContext.SaveChanges(); // Create prizes for each goods var prize1 = new GoodsItem { GoodsId = goods1.Id, Num = 1, Title = "奖品1", ImgUrl = "http://test.com/p1.jpg", Stock = 1, SurplusStock = 1, Price = 500, Money = 300, ScMoney = 250, RealPro = 1, GoodsType = 1, PrizeCode = "PC001", CreatedAt = DateTime.Now, UpdatedAt = DateTime.Now }; var prize2 = new GoodsItem { GoodsId = goods2.Id, Num = 1, Title = "奖品2", ImgUrl = "http://test.com/p2.jpg", Stock = 1, SurplusStock = 1, Price = 500, Money = 300, ScMoney = 250, RealPro = 1, GoodsType = 1, PrizeCode = "PC002", CreatedAt = DateTime.Now, UpdatedAt = DateTime.Now }; dbContext.GoodsItems.AddRange(prize1, prize2); dbContext.SaveChanges(); // Both should succeed (different goods) var request1 = new CreateDesignatedPrizeRequest { GoodsItemId = prize1.Id, UserId = user.Id, Remark = "Config for goods1" }; var request2 = new CreateDesignatedPrizeRequest { GoodsItemId = prize2.Id, UserId = user.Id, Remark = "Config for goods2" }; try { var result1 = service.CreateAsync(goods1.Id, request1).GetAwaiter().GetResult(); var result2 = service.CreateAsync(goods2.Id, request2).GetAwaiter().GetResult(); return result1 != null && result2 != null; } catch { return false; } } finally { dbContext.Dispose(); } } #endregion #region Property 8: Prize Data Immutability /// /// **Feature: designated-prize-winner, Property 8: Prize Data Immutability** /// For any designated prize configuration CREATE operation, the underlying prize's stock, /// real_pro, and other probability-related fields SHALL remain unchanged. /// **Validates: Requirements 5.2** /// [Property(MaxTest = 100)] public bool PrizeDataImmutability_CreateOperation_ShouldNotModifyPrizeFields(PositiveInt stock, PositiveInt realPro, PositiveInt price) { var actualStock = Math.Max(1, stock.Get % 100 + 1); var actualRealPro = Math.Max(1, realPro.Get % 100 + 1); var actualPrice = Math.Max(10, price.Get % 1000 + 10); var (dbContext, service) = CreateService(); try { // Create user var user = new User { OpenId = $"user_{Guid.NewGuid()}", Uid = $"uid_{Guid.NewGuid()}", Nickname = "测试用户", HeadImg = "http://test.com/u.jpg", CreatedAt = DateTime.Now, UpdatedAt = DateTime.Now }; dbContext.Users.Add(user); dbContext.SaveChanges(); // Create goods var goods = new Good { Title = "测试商品", ImgUrl = "http://test.com/g.jpg", ImgUrlDetail = "http://test.com/g_detail.jpg", Price = 100, Type = 1, Status = 1, Stock = 100, CreatedAt = DateTime.Now, UpdatedAt = DateTime.Now }; dbContext.Goods.Add(goods); dbContext.SaveChanges(); // Create prize with specific values var prize = new GoodsItem { GoodsId = goods.Id, Num = 1, Title = "测试奖品", ImgUrl = "http://test.com/p.jpg", Stock = actualStock, SurplusStock = actualStock, Price = actualPrice, Money = actualPrice / 2, ScMoney = actualPrice / 3, RealPro = actualRealPro, GoodsType = 1, PrizeCode = "PC001", CreatedAt = DateTime.Now, UpdatedAt = DateTime.Now }; dbContext.GoodsItems.Add(prize); dbContext.SaveChanges(); // Store original values var originalStock = prize.Stock; var originalSurplusStock = prize.SurplusStock; var originalRealPro = prize.RealPro; var originalPrice = prize.Price; var originalMoney = prize.Money; var originalScMoney = prize.ScMoney; // Create designated prize configuration var request = new CreateDesignatedPrizeRequest { GoodsItemId = prize.Id, UserId = user.Id, Remark = "Test config" }; service.CreateAsync(goods.Id, request).GetAwaiter().GetResult(); // Reload prize from database dbContext.Entry(prize).Reload(); // Verify all probability-related fields remain unchanged return prize.Stock == originalStock && prize.SurplusStock == originalSurplusStock && prize.RealPro == originalRealPro && prize.Price == originalPrice && prize.Money == originalMoney && prize.ScMoney == originalScMoney; } finally { dbContext.Dispose(); } } /// /// **Feature: designated-prize-winner, Property 8: Prize Data Immutability** /// For any designated prize configuration UPDATE operation, the underlying prize's stock, /// real_pro, and other probability-related fields SHALL remain unchanged. /// **Validates: Requirements 5.2** /// [Property(MaxTest = 100)] public bool PrizeDataImmutability_UpdateOperation_ShouldNotModifyPrizeFields(PositiveInt stock, PositiveInt realPro) { var actualStock = Math.Max(1, stock.Get % 100 + 1); var actualRealPro = Math.Max(1, realPro.Get % 100 + 1); var (dbContext, service) = CreateService(); try { // Create user var user = new User { OpenId = $"user_{Guid.NewGuid()}", Uid = $"uid_{Guid.NewGuid()}", Nickname = "测试用户", HeadImg = "http://test.com/u.jpg", CreatedAt = DateTime.Now, UpdatedAt = DateTime.Now }; dbContext.Users.Add(user); dbContext.SaveChanges(); // Create goods var goods = new Good { Title = "测试商品", ImgUrl = "http://test.com/g.jpg", ImgUrlDetail = "http://test.com/g_detail.jpg", Price = 100, Type = 1, Status = 1, Stock = 100, CreatedAt = DateTime.Now, UpdatedAt = DateTime.Now }; dbContext.Goods.Add(goods); dbContext.SaveChanges(); // Create prize var prize = new GoodsItem { GoodsId = goods.Id, Num = 1, Title = "测试奖品", ImgUrl = "http://test.com/p.jpg", Stock = actualStock, SurplusStock = actualStock, Price = 500, Money = 300, ScMoney = 250, RealPro = actualRealPro, GoodsType = 1, PrizeCode = "PC001", CreatedAt = DateTime.Now, UpdatedAt = DateTime.Now }; dbContext.GoodsItems.Add(prize); dbContext.SaveChanges(); // Create designated prize configuration var createRequest = new CreateDesignatedPrizeRequest { GoodsItemId = prize.Id, UserId = user.Id, Remark = "Initial config" }; var config = service.CreateAsync(goods.Id, createRequest).GetAwaiter().GetResult(); // Store original values var originalStock = prize.Stock; var originalSurplusStock = prize.SurplusStock; var originalRealPro = prize.RealPro; var originalPrice = prize.Price; // Update the configuration var updateRequest = new UpdateDesignatedPrizeRequest { IsActive = false, Remark = "Updated config" }; service.UpdateAsync(config.Id, updateRequest).GetAwaiter().GetResult(); // Reload prize from database dbContext.Entry(prize).Reload(); // Verify all probability-related fields remain unchanged return prize.Stock == originalStock && prize.SurplusStock == originalSurplusStock && prize.RealPro == originalRealPro && prize.Price == originalPrice; } finally { dbContext.Dispose(); } } /// /// **Feature: designated-prize-winner, Property 8: Prize Data Immutability** /// For any designated prize configuration DELETE operation, the underlying prize's stock, /// real_pro, and other probability-related fields SHALL remain unchanged. /// **Validates: Requirements 5.2** /// [Property(MaxTest = 100)] public bool PrizeDataImmutability_DeleteOperation_ShouldNotModifyPrizeFields(PositiveInt stock, PositiveInt realPro) { var actualStock = Math.Max(1, stock.Get % 100 + 1); var actualRealPro = Math.Max(1, realPro.Get % 100 + 1); var (dbContext, service) = CreateService(); try { // Create user var user = new User { OpenId = $"user_{Guid.NewGuid()}", Uid = $"uid_{Guid.NewGuid()}", Nickname = "测试用户", HeadImg = "http://test.com/u.jpg", CreatedAt = DateTime.Now, UpdatedAt = DateTime.Now }; dbContext.Users.Add(user); dbContext.SaveChanges(); // Create goods var goods = new Good { Title = "测试商品", ImgUrl = "http://test.com/g.jpg", ImgUrlDetail = "http://test.com/g_detail.jpg", Price = 100, Type = 1, Status = 1, Stock = 100, CreatedAt = DateTime.Now, UpdatedAt = DateTime.Now }; dbContext.Goods.Add(goods); dbContext.SaveChanges(); // Create prize var prize = new GoodsItem { GoodsId = goods.Id, Num = 1, Title = "测试奖品", ImgUrl = "http://test.com/p.jpg", Stock = actualStock, SurplusStock = actualStock, Price = 500, Money = 300, ScMoney = 250, RealPro = actualRealPro, GoodsType = 1, PrizeCode = "PC001", CreatedAt = DateTime.Now, UpdatedAt = DateTime.Now }; dbContext.GoodsItems.Add(prize); dbContext.SaveChanges(); // Create designated prize configuration var createRequest = new CreateDesignatedPrizeRequest { GoodsItemId = prize.Id, UserId = user.Id, Remark = "Config to delete" }; var config = service.CreateAsync(goods.Id, createRequest).GetAwaiter().GetResult(); // Store original values var originalStock = prize.Stock; var originalSurplusStock = prize.SurplusStock; var originalRealPro = prize.RealPro; var originalPrice = prize.Price; // Delete the configuration service.DeleteAsync(config.Id).GetAwaiter().GetResult(); // Reload prize from database dbContext.Entry(prize).Reload(); // Verify all probability-related fields remain unchanged return prize.Stock == originalStock && prize.SurplusStock == originalSurplusStock && prize.RealPro == originalRealPro && prize.Price == originalPrice; } finally { dbContext.Dispose(); } } /// /// **Feature: designated-prize-winner, Property 8: Prize Data Immutability** /// Multiple CRUD operations on designated prize configurations should not affect prize data. /// **Validates: Requirements 5.2** /// [Property(MaxTest = 50)] public bool PrizeDataImmutability_MultipleCrudOperations_ShouldNotModifyPrizeFields(PositiveInt operationCount) { var actualOperationCount = Math.Max(3, operationCount.Get % 10 + 3); var (dbContext, service) = CreateService(); try { // Create users var users = new List(); for (int i = 0; i < actualOperationCount; i++) { var user = new User { OpenId = $"user{i}_{Guid.NewGuid()}", Uid = $"uid{i}_{Guid.NewGuid()}", Nickname = $"用户{i}", HeadImg = $"http://test.com/u{i}.jpg", CreatedAt = DateTime.Now, UpdatedAt = DateTime.Now }; users.Add(user); } dbContext.Users.AddRange(users); dbContext.SaveChanges(); // Create goods var goods = new Good { Title = "测试商品", ImgUrl = "http://test.com/g.jpg", ImgUrlDetail = "http://test.com/g_detail.jpg", Price = 100, Type = 1, Status = 1, Stock = 100, CreatedAt = DateTime.Now, UpdatedAt = DateTime.Now }; dbContext.Goods.Add(goods); dbContext.SaveChanges(); // Create prize var prize = new GoodsItem { GoodsId = goods.Id, Num = 1, Title = "测试奖品", ImgUrl = "http://test.com/p.jpg", Stock = 50, SurplusStock = 50, Price = 500, Money = 300, ScMoney = 250, RealPro = 25, GoodsType = 1, PrizeCode = "PC001", CreatedAt = DateTime.Now, UpdatedAt = DateTime.Now }; dbContext.GoodsItems.Add(prize); dbContext.SaveChanges(); // Store original values var originalStock = prize.Stock; var originalSurplusStock = prize.SurplusStock; var originalRealPro = prize.RealPro; var originalPrice = prize.Price; var originalMoney = prize.Money; var originalScMoney = prize.ScMoney; // Perform multiple CRUD operations for (int i = 0; i < actualOperationCount; i++) { // Create var createRequest = new CreateDesignatedPrizeRequest { GoodsItemId = prize.Id, UserId = users[i].Id, Remark = $"Config {i}" }; var config = service.CreateAsync(goods.Id, createRequest).GetAwaiter().GetResult(); // Update var updateRequest = new UpdateDesignatedPrizeRequest { IsActive = i % 2 == 0, Remark = $"Updated config {i}" }; service.UpdateAsync(config.Id, updateRequest).GetAwaiter().GetResult(); // Delete service.DeleteAsync(config.Id).GetAwaiter().GetResult(); } // Reload prize from database dbContext.Entry(prize).Reload(); // Verify all probability-related fields remain unchanged after all operations return prize.Stock == originalStock && prize.SurplusStock == originalSurplusStock && prize.RealPro == originalRealPro && prize.Price == originalPrice && prize.Money == originalMoney && prize.ScMoney == originalScMoney; } finally { dbContext.Dispose(); } } #endregion }