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

243 lines
8.9 KiB
C#
Raw Permalink 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.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;
}
}