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