using FsCheck; using FsCheck.Xunit; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.Extensions.Logging; using Moq; using VendingMachine.Application.Common; using VendingMachine.Application.DTOs.Vending; using VendingMachine.Application.Services; using VendingMachine.Domain.Entities; using VendingMachine.Infrastructure.Data; using VendingMachine.Infrastructure.Services; namespace VendingMachine.Tests; /// /// 贩卖机对接模块属性测试 /// public class VendingMachinePropertyTests { /// /// 创建内存数据库上下文 /// private static AppDbContext CreateDbContext(string dbName) { var options = new DbContextOptionsBuilder() .UseInMemoryDatabase(databaseName: dbName) .ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)) .Options; return new AppDbContext(options); } /// /// 内存 Redis 存储实现,用于测试 /// private class InMemoryRedisStore : IRedisStore { public Dictionary Store { get; } = new(); public Task SetAsync(string key, string value, TimeSpan expiry) { Store[key] = value; return Task.CompletedTask; } public Task GetAsync(string key) { return Task.FromResult(Store.TryGetValue(key, out var val) ? val : null); } public Task DeleteAsync(string key) { return Task.FromResult(Store.Remove(key)); } } // Feature: vending-machine-app, Property 18: 会员二维码时效性 // 对于任意生成的会员二维码,在有效期内应能被正常解析获取用户信息,过期后应被拒绝。 // **Validates: Requirements 9.1** [Property(MaxTest = 100)] public bool QrcodeValidity_ValidTokenShouldReturnUserInfo_ExpiredShouldBeRejected(PositiveInt seed) { var dbName = $"QrcodeValidity_{Guid.NewGuid()}"; using var db = CreateDbContext(dbName); var redis = new InMemoryRedisStore(); var pointsService = Mock.Of(); var logger = Mock.Of>(); var user = new User { Uid = "testuser00001", Phone = "13800000001", AreaCode = "+86", Nickname = "测试用户", IsMember = true, MembershipType = MembershipType.Monthly, PointsBalance = 100 }; db.Users.Add(user); db.SaveChanges(); var service = new VendingMachineService(db, redis, pointsService, logger); // 生成二维码 var genResult = service.GenerateQrcodeAsync("testuser00001").GetAwaiter().GetResult(); if (!genResult.Success || genResult.Data == null) return false; var token = genResult.Data.Token; // 有效 token 应能获取用户信息 var validResult = service.GetUserByQrcodeAsync( new QrcodeRequest { QrcodeToken = token }, "machine001").GetAwaiter().GetResult(); // GetUserByQrcodeAsync 会删除 token,所以再次使用同一 token 应失败(模拟过期/已使用) var expiredResult = service.GetUserByQrcodeAsync( new QrcodeRequest { QrcodeToken = token }, "machine002").GetAwaiter().GetResult(); return validResult.Success && validResult.Data != null && validResult.Data.UserId == "testuser00001" && !expiredResult.Success; } // Feature: vending-machine-app, Property 19: 用户锁定机制 // 对于任意用户,当贩卖机扫码成功后该用户应被锁定, // 锁定期间其他贩卖机请求该用户信息应返回锁定状态。 // **Validates: Requirements 9.2, 9.3, 9.4** [Property(MaxTest = 100)] public bool UserLocking_ShouldPreventConcurrentAccess(PositiveInt seed) { var dbName = $"UserLocking_{Guid.NewGuid()}"; using var db = CreateDbContext(dbName); var redis = new InMemoryRedisStore(); var pointsService = Mock.Of(); var logger = Mock.Of>(); var user = new User { Uid = "testuser00001", Phone = "13800000001", AreaCode = "+86", Nickname = "测试用户", IsMember = true, MembershipType = MembershipType.Monthly, PointsBalance = 100 }; db.Users.Add(user); db.SaveChanges(); var service = new VendingMachineService(db, redis, pointsService, logger); // 第一台贩卖机扫码 var gen1 = service.GenerateQrcodeAsync("testuser00001").GetAwaiter().GetResult(); var result1 = service.GetUserByQrcodeAsync( new QrcodeRequest { QrcodeToken = gen1.Data!.Token }, "machine001").GetAwaiter().GetResult(); // 第二台贩卖机尝试访问(需要新的二维码) var gen2 = service.GenerateQrcodeAsync("testuser00001").GetAwaiter().GetResult(); var result2 = service.GetUserByQrcodeAsync( new QrcodeRequest { QrcodeToken = gen2.Data!.Token }, "machine002").GetAwaiter().GetResult(); // 第一台应成功且未锁定 var firstOk = result1.Success && result1.Data != null && !result1.Data.IsLocked; // 第二台应返回锁定状态 var secondLocked = result2.Success && result2.Data != null && result2.Data.IsLocked; return firstOk && secondLocked; } // Feature: vending-machine-app, Property 20: 优惠券排序优先级 // 对于任意优惠券列表,排序后应按到期时间升序排列, // 到期时间相同时按抵扣金额降序排列(优先使用快到期且抵扣最大的优惠券)。 // **Validates: Requirements 9.6** [Property(MaxTest = 100)] public bool CouponSorting_ShouldOrderByExpireThenByDiscountDesc(PositiveInt countRaw) { var count = (countRaw.Get % 20) + 2; // 2~21 张优惠券 var random = new Random(countRaw.Get); var baseDate = DateTime.UtcNow; // 生成随机优惠券列表 var coupons = Enumerable.Range(0, count).Select(i => new VendingCouponInfoDto { CouponId = $"coupon_{i}", Type = "direct_discount", ThresholdAmount = null, DiscountAmount = random.Next(1, 100), ExpireAt = baseDate.AddDays(random.Next(1, 30)) }).ToList(); var sorted = IVendingMachineService.SortCoupons(coupons); // 验证排序正确性 for (int i = 1; i < sorted.Count; i++) { var prev = sorted[i - 1]; var curr = sorted[i]; // 到期时间应升序 if (prev.ExpireAt > curr.ExpireAt) return false; // 到期时间相同时,抵扣金额应降序 if (prev.ExpireAt == curr.ExpireAt && prev.DiscountAmount < curr.DiscountAmount) return false; } return true; } // Feature: vending-machine-app, Property 21: 支付结束解除锁定 // 对于任意支付结果(成功或失败),支付流程结束后用户锁定应被解除。 // **Validates: Requirements 9.7** [Property(MaxTest = 100)] public bool PaymentCallback_ShouldUnlockUser_RegardlessOfStatus(bool paymentSuccess) { var dbName = $"PaymentUnlock_{Guid.NewGuid()}"; using var db = CreateDbContext(dbName); var redis = new InMemoryRedisStore(); var pointsServiceMock = new Mock(); pointsServiceMock .Setup(p => p.AddPointsFromPaymentAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(ApiResponse.Ok(10)); var logger = Mock.Of>(); var user = new User { Uid = "testuser00001", Phone = "13800000001", AreaCode = "+86", Nickname = "测试用户", IsMember = true, MembershipType = MembershipType.Monthly, PointsBalance = 100 }; db.Users.Add(user); db.SaveChanges(); // 模拟用户已被锁定 redis.Store["vending:lock:testuser00001"] = "machine001"; var service = new VendingMachineService(db, redis, pointsServiceMock.Object, logger); var payload = new VendingPaymentPayload { UserId = "testuser00001", MachineId = "machine001", PaymentAmount = 50m, PaymentStatus = paymentSuccess ? "success" : "failed", TransactionId = $"txn_{Guid.NewGuid()}" }; var result = service.ReportPaymentAsync(payload).GetAwaiter().GetResult(); // 支付回调后,用户锁定应被解除 var lockExists = redis.Store.ContainsKey("vending:lock:testuser00001"); return result.Success && !lockExists; } }