mi-assessment/server/MiAssessment/tests/MiAssessment.Tests/Core/PaymentOrderServicePropertyTests.cs
2026-02-03 14:25:01 +08:00

608 lines
23 KiB
C#

using FsCheck;
using FsCheck.Xunit;
using MiAssessment.Core.Interfaces;
using MiAssessment.Core.Services;
using MiAssessment.Model.Data;
using MiAssessment.Model.Entities;
using MiAssessment.Model.Models.Payment;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Moq;
using Xunit;
namespace MiAssessment.Tests.Core;
/// <summary>
/// PaymentOrderService 属性测试
/// Feature: framework-template
///
/// Property 1: Payment Order Creation Integrity
/// *For any* payment order creation request with valid user ID, order type, and amount, the created order SHALL have:
/// - A unique order number that does not exist in the database
/// - The order_type field correctly set to the requested type
/// - The amount field correctly set to the requested amount
/// - The biz_data field correctly storing the provided JSON data
/// **Validates: Requirements 5.1, 5.2, 5.3**
///
/// Property 2: Payment Order State Transition
/// *For any* payment order that receives a successful payment callback:
/// - The status SHALL be updated from 0 (pending) to 1 (paid)
/// - The paid_at timestamp SHALL be set
/// - The transaction_id SHALL be recorded
/// - The reward processing SHALL be triggered
/// - If reward succeeds, reward_status SHALL be 1 and reward_at SHALL be set
/// - If reward fails, reward_status SHALL be 2
/// **Validates: Requirements 5.4, 5.5, 5.6, 5.7**
/// </summary>
public class PaymentOrderServicePropertyTests
{
private readonly Mock<ILogger<PaymentOrderService>> _mockLogger = new();
/// <summary>
/// 订单状态:待支付
/// </summary>
private const byte StatusPending = 0;
/// <summary>
/// 订单状态:已支付
/// </summary>
private const byte StatusPaid = 1;
/// <summary>
/// 奖励状态:未发放
/// </summary>
private const byte RewardStatusPending = 0;
/// <summary>
/// 奖励状态:已发放
/// </summary>
private const byte RewardStatusSuccess = 1;
/// <summary>
/// 奖励状态:发放失败
/// </summary>
private const byte RewardStatusFailed = 2;
#region Property 1: Payment Order Creation Integrity
/// <summary>
/// Feature: framework-template, Property 1: Payment Order Creation Integrity
///
/// Property 1.1: Created order has unique order number
/// A unique order number that does not exist in the database
///
/// **Validates: Requirements 5.1**
/// </summary>
[Property(MaxTest = 100)]
public bool CreateOrder_ShouldGenerateUniqueOrderNo(PositiveInt userId, PositiveInt seed)
{
// Arrange
using var dbContext = CreateDbContext();
var service = CreateService(dbContext);
var request1 = CreateValidRequest(userId.Get, seed.Get);
var request2 = CreateValidRequest(userId.Get, seed.Get + 1);
// Act: Create two orders
var order1 = service.CreateOrderAsync(request1).GetAwaiter().GetResult();
var order2 = service.CreateOrderAsync(request2).GetAwaiter().GetResult();
// Assert: Order numbers should be unique
return order1.OrderNo != order2.OrderNo;
}
/// <summary>
/// Feature: framework-template, Property 1: Payment Order Creation Integrity
///
/// Property 1.2: Created order has correct order_type
/// The order_type field correctly set to the requested type
///
/// **Validates: Requirements 5.2**
/// </summary>
[Property(MaxTest = 100)]
public bool CreateOrder_ShouldSetCorrectOrderType(PositiveInt userId, NonEmptyString orderType, PositiveInt seed)
{
// Arrange
using var dbContext = CreateDbContext();
var service = CreateService(dbContext);
// Generate a valid order type (alphanumeric with underscores)
var validOrderType = GenerateValidOrderType(orderType.Get, seed.Get);
var request = CreateValidRequest(userId.Get, seed.Get);
request.OrderType = validOrderType;
// Act
var order = service.CreateOrderAsync(request).GetAwaiter().GetResult();
// Assert: Order type should match the request
return order.OrderType == validOrderType;
}
/// <summary>
/// Feature: framework-template, Property 1: Payment Order Creation Integrity
///
/// Property 1.3: Created order has correct amount
/// The amount field correctly set to the requested amount
///
/// **Validates: Requirements 5.3**
/// </summary>
[Property(MaxTest = 100)]
public bool CreateOrder_ShouldSetCorrectAmount(PositiveInt userId, PositiveInt amountCents, PositiveInt seed)
{
// Arrange
using var dbContext = CreateDbContext();
var service = CreateService(dbContext);
// Generate a valid amount (convert cents to decimal to avoid floating point issues)
var amount = Math.Round((decimal)(amountCents.Get % 100000 + 1) / 100, 2);
var request = CreateValidRequest(userId.Get, seed.Get);
request.Amount = amount;
// Act
var order = service.CreateOrderAsync(request).GetAwaiter().GetResult();
// Assert: Amount should match the request
return order.Amount == amount;
}
/// <summary>
/// Feature: framework-template, Property 1: Payment Order Creation Integrity
///
/// Property 1.4: Created order has correct biz_data
/// The biz_data field correctly storing the provided JSON data
///
/// **Validates: Requirements 5.3**
/// </summary>
[Property(MaxTest = 100)]
public bool CreateOrder_ShouldSetCorrectBizData(PositiveInt userId, PositiveInt seed)
{
// Arrange
using var dbContext = CreateDbContext();
var service = CreateService(dbContext);
// Generate valid JSON biz_data
var bizData = $"{{\"product_id\":{seed.Get},\"quantity\":{(seed.Get % 10) + 1}}}";
var request = CreateValidRequest(userId.Get, seed.Get);
request.BizData = bizData;
// Act
var order = service.CreateOrderAsync(request).GetAwaiter().GetResult();
// Assert: BizData should match the request
return order.BizData == bizData;
}
/// <summary>
/// Feature: framework-template, Property 1: Payment Order Creation Integrity
///
/// Property 1.5: Created order has initial status of pending (0)
/// The created order should have status = 0 (pending)
///
/// **Validates: Requirements 5.1**
/// </summary>
[Property(MaxTest = 100)]
public bool CreateOrder_ShouldHavePendingStatus(PositiveInt userId, PositiveInt seed)
{
// Arrange
using var dbContext = CreateDbContext();
var service = CreateService(dbContext);
var request = CreateValidRequest(userId.Get, seed.Get);
// Act
var order = service.CreateOrderAsync(request).GetAwaiter().GetResult();
// Assert: Status should be pending (0)
return order.Status == StatusPending;
}
/// <summary>
/// Feature: framework-template, Property 1: Payment Order Creation Integrity
///
/// Property 1.6: Created order has initial reward_status of pending (0)
/// The created order should have reward_status = 0 (not issued)
///
/// **Validates: Requirements 5.1**
/// </summary>
[Property(MaxTest = 100)]
public bool CreateOrder_ShouldHavePendingRewardStatus(PositiveInt userId, PositiveInt seed)
{
// Arrange
using var dbContext = CreateDbContext();
var service = CreateService(dbContext);
var request = CreateValidRequest(userId.Get, seed.Get);
// Act
var order = service.CreateOrderAsync(request).GetAwaiter().GetResult();
// Assert: RewardStatus should be pending (0)
return order.RewardStatus == RewardStatusPending;
}
/// <summary>
/// Feature: framework-template, Property 1: Payment Order Creation Integrity
///
/// Property 1.7: Order number does not exist in database before creation
/// A unique order number that does not exist in the database
///
/// **Validates: Requirements 5.1**
/// </summary>
[Property(MaxTest = 100)]
public bool CreateOrder_OrderNoShouldNotExistBeforeCreation(PositiveInt userId, PositiveInt seed)
{
// Arrange
using var dbContext = CreateDbContext();
var service = CreateService(dbContext);
var request = CreateValidRequest(userId.Get, seed.Get);
// Act
var order = service.CreateOrderAsync(request).GetAwaiter().GetResult();
// Verify: The order number should exist exactly once in the database
var count = dbContext.PaymentOrders.Count(o => o.OrderNo == order.OrderNo);
// Assert: Should exist exactly once
return count == 1;
}
#endregion
#region Property 2: Payment Order State Transition
/// <summary>
/// Feature: framework-template, Property 2: Payment Order State Transition
///
/// Property 2.1: Payment success updates status from 0 to 1
/// The status SHALL be updated from 0 (pending) to 1 (paid)
///
/// **Validates: Requirements 5.4**
/// </summary>
[Property(MaxTest = 100)]
public bool HandlePaymentSuccess_ShouldUpdateStatusToPaid(PositiveInt userId, PositiveInt seed)
{
// Arrange
using var dbContext = CreateDbContext();
var service = CreateService(dbContext);
var request = CreateValidRequest(userId.Get, seed.Get);
var order = service.CreateOrderAsync(request).GetAwaiter().GetResult();
var transactionId = $"TX_{seed.Get}_{DateTime.Now.Ticks}";
// Act
var result = service.HandlePaymentSuccessAsync(order.OrderNo, transactionId, order.Amount).GetAwaiter().GetResult();
// Refresh the order from database
var updatedOrder = service.GetOrderByNoAsync(order.OrderNo).GetAwaiter().GetResult();
// Assert: Status should be updated to paid (1)
return result && updatedOrder != null && updatedOrder.Status == StatusPaid;
}
/// <summary>
/// Feature: framework-template, Property 2: Payment Order State Transition
///
/// Property 2.2: Payment success sets paid_at timestamp
/// The paid_at timestamp SHALL be set
///
/// **Validates: Requirements 5.4**
/// </summary>
[Property(MaxTest = 100)]
public bool HandlePaymentSuccess_ShouldSetPaidAtTimestamp(PositiveInt userId, PositiveInt seed)
{
// Arrange
using var dbContext = CreateDbContext();
var service = CreateService(dbContext);
var request = CreateValidRequest(userId.Get, seed.Get);
var order = service.CreateOrderAsync(request).GetAwaiter().GetResult();
var beforePayment = DateTime.Now.AddSeconds(-1);
var transactionId = $"TX_{seed.Get}_{DateTime.Now.Ticks}";
// Act
service.HandlePaymentSuccessAsync(order.OrderNo, transactionId, order.Amount).GetAwaiter().GetResult();
// Refresh the order from database
var updatedOrder = service.GetOrderByNoAsync(order.OrderNo).GetAwaiter().GetResult();
var afterPayment = DateTime.Now.AddSeconds(1);
// Assert: PaidAt should be set and within reasonable time range
return updatedOrder != null &&
updatedOrder.PaidAt.HasValue &&
updatedOrder.PaidAt.Value >= beforePayment &&
updatedOrder.PaidAt.Value <= afterPayment;
}
/// <summary>
/// Feature: framework-template, Property 2: Payment Order State Transition
///
/// Property 2.3: Payment success records transaction_id
/// The transaction_id SHALL be recorded
///
/// **Validates: Requirements 5.4**
/// </summary>
[Property(MaxTest = 100)]
public bool HandlePaymentSuccess_ShouldRecordTransactionId(PositiveInt userId, PositiveInt seed)
{
// Arrange
using var dbContext = CreateDbContext();
var service = CreateService(dbContext);
var request = CreateValidRequest(userId.Get, seed.Get);
var order = service.CreateOrderAsync(request).GetAwaiter().GetResult();
var transactionId = $"TX_{seed.Get}_{DateTime.Now.Ticks}";
// Act
service.HandlePaymentSuccessAsync(order.OrderNo, transactionId, order.Amount).GetAwaiter().GetResult();
// Refresh the order from database
var updatedOrder = service.GetOrderByNoAsync(order.OrderNo).GetAwaiter().GetResult();
// Assert: TransactionId should be recorded
return updatedOrder != null && updatedOrder.TransactionId == transactionId;
}
/// <summary>
/// Feature: framework-template, Property 2: Payment Order State Transition
///
/// Property 2.4: Payment success with successful reward handler sets reward_status to 1
/// If reward succeeds, reward_status SHALL be 1 and reward_at SHALL be set
///
/// **Validates: Requirements 5.5, 5.6**
/// </summary>
[Property(MaxTest = 100)]
public bool HandlePaymentSuccess_WithSuccessfulReward_ShouldSetRewardStatusToSuccess(PositiveInt userId, PositiveInt seed)
{
// Arrange
using var dbContext = CreateDbContext();
var orderType = $"test_reward_success_{seed.Get % 100}";
var rewardData = $"{{\"reward_id\":{seed.Get}}}";
// Create a mock reward handler that succeeds
var mockHandler = new Mock<IPaymentRewardHandler>();
mockHandler.Setup(h => h.OrderType).Returns(orderType);
mockHandler.Setup(h => h.ProcessRewardAsync(It.IsAny<PaymentOrder>()))
.ReturnsAsync(RewardResult.Ok(rewardData));
var service = CreateService(dbContext, new[] { mockHandler.Object });
var request = CreateValidRequest(userId.Get, seed.Get);
request.OrderType = orderType;
var order = service.CreateOrderAsync(request).GetAwaiter().GetResult();
var transactionId = $"TX_{seed.Get}_{DateTime.Now.Ticks}";
// Act
service.HandlePaymentSuccessAsync(order.OrderNo, transactionId, order.Amount).GetAwaiter().GetResult();
// Refresh the order from database
var updatedOrder = service.GetOrderByNoAsync(order.OrderNo).GetAwaiter().GetResult();
// Assert: RewardStatus should be success (1) and RewardAt should be set
return updatedOrder != null &&
updatedOrder.RewardStatus == RewardStatusSuccess &&
updatedOrder.RewardAt.HasValue;
}
/// <summary>
/// Feature: framework-template, Property 2: Payment Order State Transition
///
/// Property 2.5: Payment success with failed reward handler sets reward_status to 2
/// If reward fails, reward_status SHALL be 2
///
/// **Validates: Requirements 5.7**
/// </summary>
[Property(MaxTest = 100)]
public bool HandlePaymentSuccess_WithFailedReward_ShouldSetRewardStatusToFailed(PositiveInt userId, PositiveInt seed)
{
// Arrange
using var dbContext = CreateDbContext();
var orderType = $"test_reward_fail_{seed.Get % 100}";
var errorMessage = $"Reward processing failed for seed {seed.Get}";
// Create a mock reward handler that fails
var mockHandler = new Mock<IPaymentRewardHandler>();
mockHandler.Setup(h => h.OrderType).Returns(orderType);
mockHandler.Setup(h => h.ProcessRewardAsync(It.IsAny<PaymentOrder>()))
.ReturnsAsync(RewardResult.Fail(errorMessage));
var service = CreateService(dbContext, new[] { mockHandler.Object });
var request = CreateValidRequest(userId.Get, seed.Get);
request.OrderType = orderType;
var order = service.CreateOrderAsync(request).GetAwaiter().GetResult();
var transactionId = $"TX_{seed.Get}_{DateTime.Now.Ticks}";
// Act
service.HandlePaymentSuccessAsync(order.OrderNo, transactionId, order.Amount).GetAwaiter().GetResult();
// Refresh the order from database
var updatedOrder = service.GetOrderByNoAsync(order.OrderNo).GetAwaiter().GetResult();
// Assert: RewardStatus should be failed (2)
return updatedOrder != null && updatedOrder.RewardStatus == RewardStatusFailed;
}
/// <summary>
/// Feature: framework-template, Property 2: Payment Order State Transition
///
/// Property 2.6: Payment success triggers reward processing
/// The reward processing SHALL be triggered
///
/// **Validates: Requirements 5.5**
/// </summary>
[Property(MaxTest = 100)]
public bool HandlePaymentSuccess_ShouldTriggerRewardProcessing(PositiveInt userId, PositiveInt seed)
{
// Arrange
using var dbContext = CreateDbContext();
var orderType = $"test_trigger_{seed.Get % 100}";
var rewardProcessed = false;
// Create a mock reward handler that tracks if it was called
var mockHandler = new Mock<IPaymentRewardHandler>();
mockHandler.Setup(h => h.OrderType).Returns(orderType);
mockHandler.Setup(h => h.ProcessRewardAsync(It.IsAny<PaymentOrder>()))
.Callback(() => rewardProcessed = true)
.ReturnsAsync(RewardResult.Ok());
var service = CreateService(dbContext, new[] { mockHandler.Object });
var request = CreateValidRequest(userId.Get, seed.Get);
request.OrderType = orderType;
var order = service.CreateOrderAsync(request).GetAwaiter().GetResult();
var transactionId = $"TX_{seed.Get}_{DateTime.Now.Ticks}";
// Act
service.HandlePaymentSuccessAsync(order.OrderNo, transactionId, order.Amount).GetAwaiter().GetResult();
// Assert: Reward handler should have been called
return rewardProcessed;
}
/// <summary>
/// Feature: framework-template, Property 2: Payment Order State Transition
///
/// Property 2.7: Idempotent payment success handling
/// Processing the same payment twice should not change the order state
///
/// **Validates: Requirements 5.4**
/// </summary>
[Property(MaxTest = 100)]
public bool HandlePaymentSuccess_ShouldBeIdempotent(PositiveInt userId, PositiveInt seed)
{
// Arrange
using var dbContext = CreateDbContext();
var service = CreateService(dbContext);
var request = CreateValidRequest(userId.Get, seed.Get);
var order = service.CreateOrderAsync(request).GetAwaiter().GetResult();
var transactionId = $"TX_{seed.Get}_{DateTime.Now.Ticks}";
// Act: Process payment twice
var result1 = service.HandlePaymentSuccessAsync(order.OrderNo, transactionId, order.Amount).GetAwaiter().GetResult();
var result2 = service.HandlePaymentSuccessAsync(order.OrderNo, transactionId, order.Amount).GetAwaiter().GetResult();
// Refresh the order from database
var updatedOrder = service.GetOrderByNoAsync(order.OrderNo).GetAwaiter().GetResult();
// Assert: Both calls should succeed and order should be in paid state
return result1 && result2 && updatedOrder != null && updatedOrder.Status == StatusPaid;
}
/// <summary>
/// Feature: framework-template, Property 2: Payment Order State Transition
///
/// Property 2.8: Successful reward stores reward_data
/// If reward succeeds, reward_data SHALL contain the reward information
///
/// **Validates: Requirements 5.6**
/// </summary>
[Property(MaxTest = 100)]
public bool HandlePaymentSuccess_WithSuccessfulReward_ShouldStoreRewardData(PositiveInt userId, PositiveInt seed)
{
// Arrange
using var dbContext = CreateDbContext();
var orderType = $"test_reward_data_{seed.Get % 100}";
var rewardData = $"{{\"diamonds\":{seed.Get % 1000 + 100},\"bonus\":{seed.Get % 50}}}";
// Create a mock reward handler that returns reward data
var mockHandler = new Mock<IPaymentRewardHandler>();
mockHandler.Setup(h => h.OrderType).Returns(orderType);
mockHandler.Setup(h => h.ProcessRewardAsync(It.IsAny<PaymentOrder>()))
.ReturnsAsync(RewardResult.Ok(rewardData));
var service = CreateService(dbContext, new[] { mockHandler.Object });
var request = CreateValidRequest(userId.Get, seed.Get);
request.OrderType = orderType;
var order = service.CreateOrderAsync(request).GetAwaiter().GetResult();
var transactionId = $"TX_{seed.Get}_{DateTime.Now.Ticks}";
// Act
service.HandlePaymentSuccessAsync(order.OrderNo, transactionId, order.Amount).GetAwaiter().GetResult();
// Refresh the order from database
var updatedOrder = service.GetOrderByNoAsync(order.OrderNo).GetAwaiter().GetResult();
// Assert: RewardData should be stored
return updatedOrder != null && updatedOrder.RewardData == rewardData;
}
#endregion
#region Helper Methods
/// <summary>
/// 创建内存数据库上下文
/// </summary>
private MiAssessmentDbContext CreateDbContext()
{
var options = new DbContextOptionsBuilder<MiAssessmentDbContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
return new MiAssessmentDbContext(options);
}
/// <summary>
/// 创建 PaymentOrderService 实例
/// </summary>
private PaymentOrderService CreateService(MiAssessmentDbContext dbContext, IEnumerable<IPaymentRewardHandler>? handlers = null)
{
return new PaymentOrderService(
dbContext,
handlers ?? Array.Empty<IPaymentRewardHandler>(),
_mockLogger.Object);
}
/// <summary>
/// 创建有效的支付订单请求
/// </summary>
private CreatePaymentOrderRequest CreateValidRequest(int userId, int seed)
{
return new CreatePaymentOrderRequest
{
UserId = Math.Max(1, userId), // Ensure positive user ID
OrderType = $"test_order_{seed % 100}",
Title = $"测试订单 {seed}",
Amount = Math.Round((decimal)((seed % 10000) + 100) / 100, 2), // 1.00 to 101.00
PayMethod = "wechat",
BizData = $"{{\"seed\":{seed}}}"
};
}
/// <summary>
/// 生成有效的订单类型(只包含字母、数字和下划线)
/// </summary>
private string GenerateValidOrderType(string input, int seed)
{
// Filter to only alphanumeric and underscore characters
var filtered = new string(input.Where(c => char.IsLetterOrDigit(c) || c == '_').ToArray());
// Ensure it's not empty and has reasonable length
if (string.IsNullOrEmpty(filtered))
{
filtered = "order";
}
// Limit length and add seed for uniqueness
var maxLength = Math.Min(filtered.Length, 20);
return $"{filtered.Substring(0, maxLength)}_{seed % 1000}";
}
#endregion
}