using FsCheck; using FsCheck.Xunit; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.Extensions.Logging; using Moq; using VendingMachine.Application.DTOs.Coupon; using VendingMachine.Application.Services; using VendingMachine.Domain.Entities; using VendingMachine.Infrastructure.Data; using VendingMachine.Infrastructure.Services; namespace VendingMachine.Tests; /// /// 优惠券模块属性测试 /// public class CouponPropertyTests { /// /// 创建内存数据库上下文 /// private static AppDbContext CreateDbContext(string dbName) { var options = new DbContextOptionsBuilder() .UseInMemoryDatabase(databaseName: dbName) .ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)) .Options; return new AppDbContext(options); } // Feature: vending-machine-app, Property 13: 优惠券兑换正确性 // 对于任意优惠券兑换操作,如果用户积分 >= 优惠券所需积分,则兑换成功且用户积分减少对应数量; // 如果用户积分 < 优惠券所需积分,则兑换失败且用户积分不变。 // **Validates: Requirements 6.2, 6.3** [Property(MaxTest = 100)] public bool RedeemCoupon_WhenSufficientPoints_ShouldSucceedAndDeductPoints( PositiveInt balanceRaw, PositiveInt costRaw) { // 确保积分充足:balance >= cost var cost = (costRaw.Get % 500) + 1; var balance = cost + (balanceRaw.Get % 1000); var dbName = $"RedeemSufficient_{Guid.NewGuid()}"; using var db = CreateDbContext(dbName); var logger = Mock.Of>(); var user = new User { Uid = "testuser00001", Phone = "13800000001", AreaCode = "+86", Nickname = "测试用户", PointsBalance = balance }; var template = new CouponTemplate { Id = "coupon001", NameZhCn = "测试优惠券", NameZhTw = "測試優惠券", NameEn = "Test Coupon", Type = CouponType.DirectDiscount, DiscountAmount = 5m, PointsCost = cost, ExpireAt = DateTime.UtcNow.AddDays(30), IsActive = true, IsStamp = false }; db.Users.Add(user); db.CouponTemplates.Add(template); db.SaveChanges(); var service = new CouponService(db, logger); var result = service.RedeemCouponAsync("testuser00001", "coupon001").GetAwaiter().GetResult(); db.Entry(user).Reload(); // 兑换成功且积分正确扣减 return result.Success && result.Data != null && result.Data.Redeemed && user.PointsBalance == balance - cost; } [Property(MaxTest = 100)] public bool RedeemCoupon_WhenInsufficientPoints_ShouldFailAndKeepPoints( PositiveInt balanceRaw, PositiveInt extraRaw) { // 确保积分不足:balance < cost var balance = (balanceRaw.Get % 100) + 1; var cost = balance + (extraRaw.Get % 500) + 1; var dbName = $"RedeemInsufficient_{Guid.NewGuid()}"; using var db = CreateDbContext(dbName); var logger = Mock.Of>(); var user = new User { Uid = "testuser00001", Phone = "13800000001", AreaCode = "+86", Nickname = "测试用户", PointsBalance = balance }; var template = new CouponTemplate { Id = "coupon001", NameZhCn = "测试优惠券", NameZhTw = "測試優惠券", NameEn = "Test Coupon", Type = CouponType.DirectDiscount, DiscountAmount = 5m, PointsCost = cost, ExpireAt = DateTime.UtcNow.AddDays(30), IsActive = true, IsStamp = false }; db.Users.Add(user); db.CouponTemplates.Add(template); db.SaveChanges(); var balanceBefore = user.PointsBalance; var service = new CouponService(db, logger); var result = service.RedeemCouponAsync("testuser00001", "coupon001").GetAwaiter().GetResult(); db.Entry(user).Reload(); // 兑换失败且积分不变 return !result.Success && user.PointsBalance == balanceBefore; } // Feature: vending-machine-app, Property 14: 优惠券状态分类与标识 // 对于任意用户优惠券列表,每张优惠券应被正确分类为"可使用""已使用"或"已过期", // 且已使用和已过期的优惠券应带有对应状态标识。 // **Validates: Requirements 6.6, 6.7** [Property(MaxTest = 100)] public bool CouponStatus_ShouldBeCorrectlyClassified(PositiveInt seed) { var dbName = $"CouponStatus_{Guid.NewGuid()}"; using var db = CreateDbContext(dbName); var logger = Mock.Of>(); var user = new User { Uid = "testuser00001", Phone = "13800000001", AreaCode = "+86", Nickname = "测试用户", PointsBalance = 1000 }; db.Users.Add(user); var template = new CouponTemplate { Id = "tpl001", NameZhCn = "测试券", NameZhTw = "測試券", NameEn = "Test", Type = CouponType.DirectDiscount, DiscountAmount = 5m, PointsCost = 10, ExpireAt = DateTime.UtcNow.AddDays(30), IsActive = true, IsStamp = false }; db.CouponTemplates.Add(template); // 创建三种状态的优惠券 var available = new UserCoupon { Id = "uc_avail", CouponTemplateId = "tpl001", UserId = "testuser00001", Status = CouponStatus.Available, ExpireAt = DateTime.UtcNow.AddDays(30) }; var used = new UserCoupon { Id = "uc_used", CouponTemplateId = "tpl001", UserId = "testuser00001", Status = CouponStatus.Used, ExpireAt = DateTime.UtcNow.AddDays(30), UsedAt = DateTime.UtcNow.AddDays(-1) }; var expired = new UserCoupon { Id = "uc_expired", CouponTemplateId = "tpl001", UserId = "testuser00001", Status = CouponStatus.Expired, ExpireAt = DateTime.UtcNow.AddDays(-1) }; db.UserCoupons.AddRange(available, used, expired); db.SaveChanges(); var service = new CouponService(db, logger); var result = service.GetMyCouponsAsync("testuser00001", null, "zh-CN").GetAwaiter().GetResult(); if (!result.Success || result.Data == null) return false; var coupons = result.Data; // 验证三种状态都存在且正确分类 var availableCoupon = coupons.FirstOrDefault(c => c.Id == "uc_avail"); var usedCoupon = coupons.FirstOrDefault(c => c.Id == "uc_used"); var expiredCoupon = coupons.FirstOrDefault(c => c.Id == "uc_expired"); return availableCoupon != null && availableCoupon.Status == "available" && usedCoupon != null && usedCoupon.Status == "used" && expiredCoupon != null && expiredCoupon.Status == "expired"; } // Feature: vending-machine-app, Property 15: 印花兑换权限取决于会员状态 // 对于任意用户点击印花优惠券兑换按钮,会员用户应执行兑换流程,非会员用户应被拒绝。 // **Validates: Requirements 7.3, 7.4** [Property(MaxTest = 100)] public bool StampRedeem_MemberShouldSucceed_NonMemberShouldFail(bool isMember, PositiveInt balanceRaw) { var balance = (balanceRaw.Get % 1000) + 100; // 确保积分充足 var dbName = $"StampPermission_{Guid.NewGuid()}"; using var db = CreateDbContext(dbName); var logger = Mock.Of>(); var user = new User { Uid = "testuser00001", Phone = "13800000001", AreaCode = "+86", Nickname = "测试用户", IsMember = isMember, MembershipType = isMember ? MembershipType.Monthly : MembershipType.None, PointsBalance = balance }; var stamp = new CouponTemplate { Id = "stamp001", NameZhCn = "节日印花", NameZhTw = "節日印花", NameEn = "Holiday Stamp", Type = CouponType.DirectDiscount, DiscountAmount = 10m, PointsCost = 0, ExpireAt = DateTime.UtcNow.AddDays(30), IsActive = true, IsStamp = true }; db.Users.Add(user); db.CouponTemplates.Add(stamp); db.SaveChanges(); var service = new CouponService(db, logger); var result = service.RedeemStampCouponAsync("testuser00001", "stamp001").GetAwaiter().GetResult(); if (isMember) { // 会员应兑换成功 return result.Success && result.Data != null && result.Data.Redeemed; } else { // 非会员应被拒绝 return !result.Success; } } // Feature: vending-machine-app, Property 16: 印花兑换幂等性 // 对于任意会员用户和印花优惠券,第一次兑换应成功,后续重复兑换应被拒绝。 // **Validates: Requirements 7.5, 7.6** [Property(MaxTest = 100)] public bool StampRedeem_ShouldBeIdempotent_SecondAttemptRejected(PositiveInt seed) { var dbName = $"StampIdempotent_{Guid.NewGuid()}"; using var db = CreateDbContext(dbName); var logger = Mock.Of>(); var user = new User { Uid = "testuser00001", Phone = "13800000001", AreaCode = "+86", Nickname = "测试用户", IsMember = true, MembershipType = MembershipType.Monthly, PointsBalance = 1000 }; var stamp = new CouponTemplate { Id = "stamp001", NameZhCn = "节日印花", NameZhTw = "節日印花", NameEn = "Holiday Stamp", Type = CouponType.DirectDiscount, DiscountAmount = 10m, PointsCost = 0, ExpireAt = DateTime.UtcNow.AddDays(30), IsActive = true, IsStamp = true }; db.Users.Add(user); db.CouponTemplates.Add(stamp); db.SaveChanges(); var service = new CouponService(db, logger); // 第一次兑换应成功 var firstResult = service.RedeemStampCouponAsync("testuser00001", "stamp001").GetAwaiter().GetResult(); // 第二次兑换应被拒绝 var secondResult = service.RedeemStampCouponAsync("testuser00001", "stamp001").GetAwaiter().GetResult(); return firstResult.Success && firstResult.Data != null && firstResult.Data.Redeemed && !secondResult.Success; } }