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; /// /// 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** /// public class PaymentOrderServicePropertyTests { private readonly Mock> _mockLogger = new(); /// /// 订单状态:待支付 /// private const byte StatusPending = 0; /// /// 订单状态:已支付 /// private const byte StatusPaid = 1; /// /// 奖励状态:未发放 /// private const byte RewardStatusPending = 0; /// /// 奖励状态:已发放 /// private const byte RewardStatusSuccess = 1; /// /// 奖励状态:发放失败 /// private const byte RewardStatusFailed = 2; #region Property 1: Payment Order Creation Integrity /// /// 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** /// [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; } /// /// 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** /// [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; } /// /// 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** /// [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; } /// /// 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** /// [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; } /// /// 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** /// [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; } /// /// 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** /// [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; } /// /// 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** /// [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 /// /// 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** /// [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; } /// /// 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** /// [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; } /// /// 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** /// [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; } /// /// 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** /// [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(); mockHandler.Setup(h => h.OrderType).Returns(orderType); mockHandler.Setup(h => h.ProcessRewardAsync(It.IsAny())) .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; } /// /// 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** /// [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(); mockHandler.Setup(h => h.OrderType).Returns(orderType); mockHandler.Setup(h => h.ProcessRewardAsync(It.IsAny())) .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; } /// /// 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** /// [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(); mockHandler.Setup(h => h.OrderType).Returns(orderType); mockHandler.Setup(h => h.ProcessRewardAsync(It.IsAny())) .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; } /// /// 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** /// [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; } /// /// 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** /// [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(); mockHandler.Setup(h => h.OrderType).Returns(orderType); mockHandler.Setup(h => h.ProcessRewardAsync(It.IsAny())) .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 /// /// 创建内存数据库上下文 /// private MiAssessmentDbContext CreateDbContext() { var options = new DbContextOptionsBuilder() .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) .Options; return new MiAssessmentDbContext(options); } /// /// 创建 PaymentOrderService 实例 /// private PaymentOrderService CreateService(MiAssessmentDbContext dbContext, IEnumerable? handlers = null) { return new PaymentOrderService( dbContext, handlers ?? Array.Empty(), _mockLogger.Object); } /// /// 创建有效的支付订单请求 /// 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}}}" }; } /// /// 生成有效的订单类型(只包含字母、数字和下划线) /// 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 }