243 lines
8.9 KiB
C#
243 lines
8.9 KiB
C#
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;
|
||
|
||
/// <summary>
|
||
/// 贩卖机对接模块属性测试
|
||
/// </summary>
|
||
public class VendingMachinePropertyTests
|
||
{
|
||
/// <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);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 内存 Redis 存储实现,用于测试
|
||
/// </summary>
|
||
private class InMemoryRedisStore : IRedisStore
|
||
{
|
||
public Dictionary<string, string> Store { get; } = new();
|
||
|
||
public Task SetAsync(string key, string value, TimeSpan expiry)
|
||
{
|
||
Store[key] = value;
|
||
return Task.CompletedTask;
|
||
}
|
||
|
||
public Task<string?> GetAsync(string key)
|
||
{
|
||
return Task.FromResult(Store.TryGetValue(key, out var val) ? val : null);
|
||
}
|
||
|
||
public Task<bool> 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<IPointsService>();
|
||
var logger = Mock.Of<ILogger<VendingMachineService>>();
|
||
|
||
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<IPointsService>();
|
||
var logger = Mock.Of<ILogger<VendingMachineService>>();
|
||
|
||
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<IPointsService>();
|
||
pointsServiceMock
|
||
.Setup(p => p.AddPointsFromPaymentAsync(It.IsAny<string>(), It.IsAny<decimal>()))
|
||
.ReturnsAsync(ApiResponse<int>.Ok(10));
|
||
var logger = Mock.Of<ILogger<VendingMachineService>>();
|
||
|
||
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;
|
||
}
|
||
}
|