526 lines
20 KiB
C#
526 lines
20 KiB
C#
using FsCheck;
|
|
using FsCheck.Xunit;
|
|
using NSubstitute;
|
|
using Xunit;
|
|
using XiangYi.Application.Services;
|
|
using XiangYi.Core.Entities.Biz;
|
|
using XiangYi.Core.Enums;
|
|
using XiangYi.Core.Interfaces;
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
namespace XiangYi.Application.Tests.Services;
|
|
|
|
/// <summary>
|
|
/// AdminOrderService属性测试 - 订单删除功能
|
|
/// </summary>
|
|
public class AdminOrderDeletePropertyTests
|
|
{
|
|
/// <summary>
|
|
/// **Feature: order-delete, Property 3: Order deletion eligibility is determined by status**
|
|
/// **Validates: Requirements 2.1, 2.2, 2.3**
|
|
///
|
|
/// *For any* order, the order is deletable if and only if its status is NOT "已支付" (status = 2).
|
|
/// Orders with status 1 (待支付), 3 (已取消), or 4 (已退款) SHALL be deletable.
|
|
/// </summary>
|
|
[Property(MaxTest = 100)]
|
|
public Property CanDeleteOrder_PaidStatus_ShouldReturnFalse()
|
|
{
|
|
// Paid status (2) should NOT be deletable
|
|
return Prop.ForAll(
|
|
Arb.Default.PositiveInt(),
|
|
_ =>
|
|
{
|
|
var paidStatus = (int)OrderStatus.Paid; // 2
|
|
var service = new AdminOrderService(null!, null!, null!, null!, null!);
|
|
var canDelete = service.CanDeleteOrder(paidStatus);
|
|
return !canDelete;
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// **Feature: order-delete, Property 3: Order deletion eligibility is determined by status**
|
|
/// **Validates: Requirements 2.1, 2.2, 2.3**
|
|
///
|
|
/// Pending orders (status = 1) should be deletable
|
|
/// </summary>
|
|
[Property(MaxTest = 100)]
|
|
public Property CanDeleteOrder_PendingStatus_ShouldReturnTrue()
|
|
{
|
|
return Prop.ForAll(
|
|
Arb.Default.PositiveInt(),
|
|
_ =>
|
|
{
|
|
var pendingStatus = (int)OrderStatus.Pending; // 1
|
|
var service = new AdminOrderService(null!, null!, null!, null!, null!);
|
|
var canDelete = service.CanDeleteOrder(pendingStatus);
|
|
return canDelete;
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// **Feature: order-delete, Property 3: Order deletion eligibility is determined by status**
|
|
/// **Validates: Requirements 2.1, 2.2, 2.3**
|
|
///
|
|
/// Cancelled orders (status = 3) should be deletable
|
|
/// </summary>
|
|
[Property(MaxTest = 100)]
|
|
public Property CanDeleteOrder_CancelledStatus_ShouldReturnTrue()
|
|
{
|
|
return Prop.ForAll(
|
|
Arb.Default.PositiveInt(),
|
|
_ =>
|
|
{
|
|
var cancelledStatus = (int)OrderStatus.Cancelled; // 3
|
|
var service = new AdminOrderService(null!, null!, null!, null!, null!);
|
|
var canDelete = service.CanDeleteOrder(cancelledStatus);
|
|
return canDelete;
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// **Feature: order-delete, Property 3: Order deletion eligibility is determined by status**
|
|
/// **Validates: Requirements 2.1, 2.2, 2.3**
|
|
///
|
|
/// Refunded orders (status = 4) should be deletable
|
|
/// </summary>
|
|
[Property(MaxTest = 100)]
|
|
public Property CanDeleteOrder_RefundedStatus_ShouldReturnTrue()
|
|
{
|
|
return Prop.ForAll(
|
|
Arb.Default.PositiveInt(),
|
|
_ =>
|
|
{
|
|
var refundedStatus = (int)OrderStatus.Refunded; // 4
|
|
var service = new AdminOrderService(null!, null!, null!, null!, null!);
|
|
var canDelete = service.CanDeleteOrder(refundedStatus);
|
|
return canDelete;
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// **Feature: order-delete, Property 3: Order deletion eligibility is determined by status**
|
|
/// **Validates: Requirements 2.1, 2.2, 2.3**
|
|
///
|
|
/// *For any* valid order status, only paid status (2) should return false for CanDeleteOrder
|
|
/// </summary>
|
|
[Property(MaxTest = 100)]
|
|
public Property CanDeleteOrder_AllValidStatuses_OnlyPaidShouldBeFalse()
|
|
{
|
|
var validStatusArb = Gen.Elements(1, 2, 3, 4).ToArbitrary();
|
|
|
|
return Prop.ForAll(
|
|
validStatusArb,
|
|
status =>
|
|
{
|
|
var service = new AdminOrderService(null!, null!, null!, null!, null!);
|
|
var canDelete = service.CanDeleteOrder(status);
|
|
|
|
// Only status 2 (Paid) should return false
|
|
var expectedCanDelete = status != (int)OrderStatus.Paid;
|
|
return canDelete == expectedCanDelete;
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// **Feature: order-delete, Property 3: Order deletion eligibility is determined by status**
|
|
/// **Validates: Requirements 2.1, 2.2, 2.3**
|
|
///
|
|
/// *For any* random status value, the result should be consistent with the rule:
|
|
/// deletable if and only if status != 2
|
|
/// </summary>
|
|
[Property(MaxTest = 100)]
|
|
public Property CanDeleteOrder_AnyStatus_ShouldFollowRule()
|
|
{
|
|
var statusArb = Gen.Choose(-10, 10).ToArbitrary();
|
|
|
|
return Prop.ForAll(
|
|
statusArb,
|
|
status =>
|
|
{
|
|
var service = new AdminOrderService(null!, null!, null!, null!, null!);
|
|
var canDelete = service.CanDeleteOrder(status);
|
|
|
|
// Rule: deletable if and only if status != 2 (Paid)
|
|
var expectedCanDelete = status != (int)OrderStatus.Paid;
|
|
return canDelete == expectedCanDelete;
|
|
});
|
|
}
|
|
}
|
|
|
|
|
|
/// <summary>
|
|
/// AdminOrderService属性测试 - 软删除行为
|
|
/// </summary>
|
|
public class AdminOrderSoftDeletePropertyTests
|
|
{
|
|
/// <summary>
|
|
/// **Feature: order-delete, Property 1: Soft delete marks order as deleted**
|
|
/// **Validates: Requirements 1.2**
|
|
///
|
|
/// *For any* order that is eligible for deletion (status is pending, cancelled, or refunded),
|
|
/// when the delete operation is performed, the order's IsDeleted field SHALL be set to true
|
|
/// and DeleteTime SHALL be set to the current timestamp.
|
|
/// </summary>
|
|
[Property(MaxTest = 100)]
|
|
public Property DeleteOrder_EligibleOrder_ShouldSetIsDeletedAndDeleteTime()
|
|
{
|
|
// Generate eligible statuses (1=Pending, 3=Cancelled, 4=Refunded)
|
|
var eligibleStatusArb = Gen.Elements(
|
|
(int)OrderStatus.Pending,
|
|
(int)OrderStatus.Cancelled,
|
|
(int)OrderStatus.Refunded
|
|
).ToArbitrary();
|
|
|
|
var orderIdArb = Gen.Choose(1, int.MaxValue).Select(x => (long)x).ToArbitrary();
|
|
var adminIdArb = Gen.Choose(1, int.MaxValue).Select(x => (long)x).ToArbitrary();
|
|
|
|
return Prop.ForAll(
|
|
eligibleStatusArb,
|
|
orderIdArb,
|
|
adminIdArb,
|
|
(status, orderId, adminId) =>
|
|
{
|
|
// Arrange
|
|
var order = new Order
|
|
{
|
|
Id = orderId,
|
|
OrderNo = $"TEST{orderId}",
|
|
Status = status,
|
|
IsDeleted = false,
|
|
DeleteTime = null
|
|
};
|
|
|
|
var orderRepository = Substitute.For<IRepository<Order>>();
|
|
orderRepository.GetByIdAsync(orderId).Returns(Task.FromResult<Order?>(order));
|
|
orderRepository.UpdateAsync(Arg.Any<Order>()).Returns(Task.FromResult(1));
|
|
|
|
var userRepository = Substitute.For<IRepository<User>>();
|
|
var memberRepository = Substitute.For<IRepository<Member>>();
|
|
var weChatService = Substitute.For<Infrastructure.WeChat.IWeChatService>();
|
|
var logger = Substitute.For<ILogger<AdminOrderService>>();
|
|
|
|
var service = new AdminOrderService(
|
|
orderRepository, userRepository, memberRepository, weChatService, logger);
|
|
|
|
// Act
|
|
var beforeDelete = DateTime.Now;
|
|
service.DeleteOrderAsync(orderId, adminId).Wait();
|
|
var afterDelete = DateTime.Now;
|
|
|
|
// Assert
|
|
return order.IsDeleted == true &&
|
|
order.DeleteTime != null &&
|
|
order.DeleteTime >= beforeDelete &&
|
|
order.DeleteTime <= afterDelete;
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// **Feature: order-delete, Property 1: Soft delete marks order as deleted**
|
|
/// **Validates: Requirements 1.2**
|
|
///
|
|
/// Soft delete should preserve the original order data (only IsDeleted and DeleteTime change)
|
|
/// </summary>
|
|
[Property(MaxTest = 100)]
|
|
public Property DeleteOrder_ShouldPreserveOriginalData()
|
|
{
|
|
var eligibleStatusArb = Gen.Elements(
|
|
(int)OrderStatus.Pending,
|
|
(int)OrderStatus.Cancelled,
|
|
(int)OrderStatus.Refunded
|
|
).ToArbitrary();
|
|
|
|
var orderIdArb = Gen.Choose(1, int.MaxValue).Select(x => (long)x).ToArbitrary();
|
|
var amountArb = Gen.Choose(1, 10000).Select(x => (decimal)x).ToArbitrary();
|
|
|
|
return Prop.ForAll(
|
|
eligibleStatusArb,
|
|
orderIdArb,
|
|
amountArb,
|
|
(status, orderId, amount) =>
|
|
{
|
|
// Arrange
|
|
var userId = orderId + 1000; // Derive userId from orderId
|
|
var originalOrderNo = $"TEST{orderId}";
|
|
var originalProductName = "Test Product";
|
|
var originalOrderType = 1;
|
|
|
|
var order = new Order
|
|
{
|
|
Id = orderId,
|
|
OrderNo = originalOrderNo,
|
|
UserId = userId,
|
|
Status = status,
|
|
Amount = amount,
|
|
PayAmount = amount,
|
|
ProductName = originalProductName,
|
|
OrderType = originalOrderType,
|
|
IsDeleted = false,
|
|
DeleteTime = null
|
|
};
|
|
|
|
var orderRepository = Substitute.For<IRepository<Order>>();
|
|
orderRepository.GetByIdAsync(orderId).Returns(Task.FromResult<Order?>(order));
|
|
orderRepository.UpdateAsync(Arg.Any<Order>()).Returns(Task.FromResult(1));
|
|
|
|
var userRepository = Substitute.For<IRepository<User>>();
|
|
var memberRepository = Substitute.For<IRepository<Member>>();
|
|
var weChatService = Substitute.For<Infrastructure.WeChat.IWeChatService>();
|
|
var logger = Substitute.For<ILogger<AdminOrderService>>();
|
|
|
|
var service = new AdminOrderService(
|
|
orderRepository, userRepository, memberRepository, weChatService, logger);
|
|
|
|
// Act
|
|
service.DeleteOrderAsync(orderId, 1).Wait();
|
|
|
|
// Assert - Original data should be preserved
|
|
return order.OrderNo == originalOrderNo &&
|
|
order.UserId == userId &&
|
|
order.Status == status &&
|
|
order.Amount == amount &&
|
|
order.ProductName == originalProductName &&
|
|
order.OrderType == originalOrderType &&
|
|
order.IsDeleted == true; // Only IsDeleted should change
|
|
});
|
|
}
|
|
}
|
|
|
|
|
|
/// <summary>
|
|
/// AdminOrderService属性测试 - 批量删除过滤
|
|
/// </summary>
|
|
public class AdminOrderBatchDeletePropertyTests
|
|
{
|
|
/// <summary>
|
|
/// **Feature: order-delete, Property 4: Batch deletion correctly filters and reports results**
|
|
/// **Validates: Requirements 3.3, 3.4**
|
|
///
|
|
/// *For any* batch of order IDs submitted for deletion, the operation SHALL delete only
|
|
/// eligible orders (non-paid status), skip ineligible orders, and return accurate counts
|
|
/// of successful deletions and skipped orders.
|
|
/// </summary>
|
|
[Property(MaxTest = 100)]
|
|
public Property BatchDelete_ShouldCorrectlyFilterAndReportResults()
|
|
{
|
|
// Generate a mix of order statuses
|
|
var orderCountArb = Gen.Choose(1, 10).ToArbitrary();
|
|
|
|
return Prop.ForAll(
|
|
orderCountArb,
|
|
orderCount =>
|
|
{
|
|
// Arrange - Create orders with mixed statuses
|
|
var orders = new List<Order>();
|
|
var orderIds = new List<long>();
|
|
var expectedSuccessCount = 0;
|
|
var expectedSkippedCount = 0;
|
|
var expectedSkippedIds = new List<long>();
|
|
|
|
for (int i = 1; i <= orderCount; i++)
|
|
{
|
|
var orderId = (long)i;
|
|
// Alternate between statuses: 1, 2, 3, 4, 1, 2, 3, 4...
|
|
var status = ((i - 1) % 4) + 1;
|
|
|
|
var order = new Order
|
|
{
|
|
Id = orderId,
|
|
OrderNo = $"TEST{orderId}",
|
|
Status = status,
|
|
IsDeleted = false,
|
|
DeleteTime = null
|
|
};
|
|
orders.Add(order);
|
|
orderIds.Add(orderId);
|
|
|
|
if (status == (int)OrderStatus.Paid)
|
|
{
|
|
expectedSkippedCount++;
|
|
expectedSkippedIds.Add(orderId);
|
|
}
|
|
else
|
|
{
|
|
expectedSuccessCount++;
|
|
}
|
|
}
|
|
|
|
var orderRepository = Substitute.For<IRepository<Order>>();
|
|
orderRepository.GetListAsync(Arg.Any<System.Linq.Expressions.Expression<Func<Order, bool>>>())
|
|
.Returns(Task.FromResult<List<Order>>(orders));
|
|
orderRepository.UpdateAsync(Arg.Any<Order>()).Returns(Task.FromResult(1));
|
|
|
|
var userRepository = Substitute.For<IRepository<User>>();
|
|
var memberRepository = Substitute.For<IRepository<Member>>();
|
|
var weChatService = Substitute.For<Infrastructure.WeChat.IWeChatService>();
|
|
var logger = Substitute.For<ILogger<AdminOrderService>>();
|
|
|
|
var service = new AdminOrderService(
|
|
orderRepository, userRepository, memberRepository, weChatService, logger);
|
|
|
|
// Act
|
|
var result = service.BatchDeleteOrdersAsync(orderIds, 1).Result;
|
|
|
|
// Assert
|
|
return result.SuccessCount == expectedSuccessCount &&
|
|
result.SkippedCount == expectedSkippedCount &&
|
|
result.SkippedOrderIds.Count == expectedSkippedIds.Count;
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// **Feature: order-delete, Property 4: Batch deletion correctly filters and reports results**
|
|
/// **Validates: Requirements 3.3, 3.4**
|
|
///
|
|
/// When all orders are eligible (non-paid), all should be deleted successfully
|
|
/// </summary>
|
|
[Property(MaxTest = 100)]
|
|
public Property BatchDelete_AllEligible_ShouldDeleteAll()
|
|
{
|
|
var orderCountArb = Gen.Choose(1, 10).ToArbitrary();
|
|
var eligibleStatusArb = Gen.Elements(
|
|
(int)OrderStatus.Pending,
|
|
(int)OrderStatus.Cancelled,
|
|
(int)OrderStatus.Refunded
|
|
).ToArbitrary();
|
|
|
|
return Prop.ForAll(
|
|
orderCountArb,
|
|
eligibleStatusArb,
|
|
(orderCount, status) =>
|
|
{
|
|
// Arrange - Create orders with only eligible statuses
|
|
var orders = new List<Order>();
|
|
var orderIds = new List<long>();
|
|
|
|
for (int i = 1; i <= orderCount; i++)
|
|
{
|
|
var orderId = (long)i;
|
|
var order = new Order
|
|
{
|
|
Id = orderId,
|
|
OrderNo = $"TEST{orderId}",
|
|
Status = status,
|
|
IsDeleted = false,
|
|
DeleteTime = null
|
|
};
|
|
orders.Add(order);
|
|
orderIds.Add(orderId);
|
|
}
|
|
|
|
var orderRepository = Substitute.For<IRepository<Order>>();
|
|
orderRepository.GetListAsync(Arg.Any<System.Linq.Expressions.Expression<Func<Order, bool>>>())
|
|
.Returns(Task.FromResult<List<Order>>(orders));
|
|
orderRepository.UpdateAsync(Arg.Any<Order>()).Returns(Task.FromResult(1));
|
|
|
|
var userRepository = Substitute.For<IRepository<User>>();
|
|
var memberRepository = Substitute.For<IRepository<Member>>();
|
|
var weChatService = Substitute.For<Infrastructure.WeChat.IWeChatService>();
|
|
var logger = Substitute.For<ILogger<AdminOrderService>>();
|
|
|
|
var service = new AdminOrderService(
|
|
orderRepository, userRepository, memberRepository, weChatService, logger);
|
|
|
|
// Act
|
|
var result = service.BatchDeleteOrdersAsync(orderIds, 1).Result;
|
|
|
|
// Assert
|
|
return result.SuccessCount == orderCount &&
|
|
result.SkippedCount == 0 &&
|
|
result.SkippedOrderIds.Count == 0;
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// **Feature: order-delete, Property 4: Batch deletion correctly filters and reports results**
|
|
/// **Validates: Requirements 3.3, 3.4**
|
|
///
|
|
/// When all orders are paid (ineligible), none should be deleted
|
|
/// </summary>
|
|
[Property(MaxTest = 100)]
|
|
public Property BatchDelete_AllPaid_ShouldSkipAll()
|
|
{
|
|
var orderCountArb = Gen.Choose(1, 10).ToArbitrary();
|
|
|
|
return Prop.ForAll(
|
|
orderCountArb,
|
|
orderCount =>
|
|
{
|
|
// Arrange - Create orders with only paid status
|
|
var orders = new List<Order>();
|
|
var orderIds = new List<long>();
|
|
|
|
for (int i = 1; i <= orderCount; i++)
|
|
{
|
|
var orderId = (long)i;
|
|
var order = new Order
|
|
{
|
|
Id = orderId,
|
|
OrderNo = $"TEST{orderId}",
|
|
Status = (int)OrderStatus.Paid,
|
|
IsDeleted = false,
|
|
DeleteTime = null
|
|
};
|
|
orders.Add(order);
|
|
orderIds.Add(orderId);
|
|
}
|
|
|
|
var orderRepository = Substitute.For<IRepository<Order>>();
|
|
orderRepository.GetListAsync(Arg.Any<System.Linq.Expressions.Expression<Func<Order, bool>>>())
|
|
.Returns(Task.FromResult<List<Order>>(orders));
|
|
orderRepository.UpdateAsync(Arg.Any<Order>()).Returns(Task.FromResult(1));
|
|
|
|
var userRepository = Substitute.For<IRepository<User>>();
|
|
var memberRepository = Substitute.For<IRepository<Member>>();
|
|
var weChatService = Substitute.For<Infrastructure.WeChat.IWeChatService>();
|
|
var logger = Substitute.For<ILogger<AdminOrderService>>();
|
|
|
|
var service = new AdminOrderService(
|
|
orderRepository, userRepository, memberRepository, weChatService, logger);
|
|
|
|
// Act
|
|
var result = service.BatchDeleteOrdersAsync(orderIds, 1).Result;
|
|
|
|
// Assert
|
|
return result.SuccessCount == 0 &&
|
|
result.SkippedCount == orderCount &&
|
|
result.SkippedOrderIds.Count == orderCount;
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// **Feature: order-delete, Property 4: Batch deletion correctly filters and reports results**
|
|
/// **Validates: Requirements 3.3, 3.4**
|
|
///
|
|
/// Empty order list should return zero counts
|
|
/// </summary>
|
|
[Property(MaxTest = 100)]
|
|
public Property BatchDelete_EmptyList_ShouldReturnZeroCounts()
|
|
{
|
|
return Prop.ForAll(
|
|
Arb.Default.PositiveInt(),
|
|
_ =>
|
|
{
|
|
// Arrange
|
|
var orderIds = new List<long>();
|
|
|
|
var orderRepository = Substitute.For<IRepository<Order>>();
|
|
var userRepository = Substitute.For<IRepository<User>>();
|
|
var memberRepository = Substitute.For<IRepository<Member>>();
|
|
var weChatService = Substitute.For<Infrastructure.WeChat.IWeChatService>();
|
|
var logger = Substitute.For<ILogger<AdminOrderService>>();
|
|
|
|
var service = new AdminOrderService(
|
|
orderRepository, userRepository, memberRepository, weChatService, logger);
|
|
|
|
// Act
|
|
var result = service.BatchDeleteOrdersAsync(orderIds, 1).Result;
|
|
|
|
// Assert
|
|
return result.SuccessCount == 0 &&
|
|
result.SkippedCount == 0 &&
|
|
result.SkippedOrderIds.Count == 0;
|
|
});
|
|
}
|
|
}
|