vending-machine/backend/tests/VendingMachine.Tests/CouponPropertyTests.cs
2026-04-03 06:07:13 +08:00

322 lines
11 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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