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;
}
}