322 lines
11 KiB
C#
322 lines
11 KiB
C#
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;
|
||
|
||
/// <summary>
|
||
/// 优惠券模块属性测试
|
||
/// </summary>
|
||
public class CouponPropertyTests
|
||
{
|
||
/// <summary>
|
||
/// 创建内存数据库上下文
|
||
/// </summary>
|
||
private static AppDbContext CreateDbContext(string dbName)
|
||
{
|
||
var options = new DbContextOptionsBuilder<AppDbContext>()
|
||
.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<ILogger<CouponService>>();
|
||
|
||
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<ILogger<CouponService>>();
|
||
|
||
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<ILogger<CouponService>>();
|
||
|
||
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<ILogger<CouponService>>();
|
||
|
||
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<ILogger<CouponService>>();
|
||
|
||
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;
|
||
}
|
||
}
|