namespace WebApi.Test.Application; /// /// BudgetApplication 单元测试 /// public class BudgetApplicationTest : BaseApplicationTest { private readonly IBudgetService _budgetService; private readonly IBudgetRepository _budgetRepository; private readonly BudgetApplication _application; public BudgetApplicationTest() { _budgetService = Substitute.For(); _budgetRepository = Substitute.For(); _application = new BudgetApplication(_budgetService, _budgetRepository); } #region GetListAsync Tests [Fact] public async Task GetListAsync_应返回排序后的预算列表() { // Arrange var referenceDate = new DateTime(2026, 2, 10); var testData = new List { new() { Id = 1, Name = "餐饮", Category = BudgetCategory.Expense, IsMandatoryExpense = false, Limit = 1000, Current = 500 }, new() { Id = 2, Name = "房租", Category = BudgetCategory.Expense, IsMandatoryExpense = true, Limit = 3000, Current = 3000 } }; _budgetService.GetListAsync(referenceDate).Returns(testData); // Act var result = await _application.GetListAsync(referenceDate); // Assert result.Should().NotBeNull(); result.Should().HaveCount(2); result[0].Name.Should().Be("房租"); // 刚性支出优先 result[1].Name.Should().Be("餐饮"); } #endregion #region CreateAsync Tests [Fact] public async Task CreateAsync_有效请求_应返回新预算ID() { // Arrange var request = new CreateBudgetRequest { Name = "测试预算", Type = BudgetPeriodType.Month, Category = BudgetCategory.Expense, Limit = 1000, SelectedCategories = new[] { "餐饮", "交通" }, NoLimit = false, IsMandatoryExpense = false }; _budgetRepository.GetAllAsync().Returns(new List()); _budgetRepository.AddAsync(Arg.Any()).Returns(true); // Act var id = await _application.CreateAsync(request); // Assert id.Should().BeGreaterThan(0); await _budgetRepository.Received(1).AddAsync(Arg.Is( b => b.Name == "测试预算" && b.Limit == 1000 )); } [Fact] public async Task CreateAsync_空名称_应抛出ValidationException() { // Arrange var request = new CreateBudgetRequest { Name = "", Type = BudgetPeriodType.Month, Category = BudgetCategory.Expense, Limit = 1000, SelectedCategories = new[] { "餐饮" } }; // Act & Assert await Assert.ThrowsAsync(() => _application.CreateAsync(request)); } [Fact] public async Task CreateAsync_金额为0且非不记额_应抛出ValidationException() { // Arrange var request = new CreateBudgetRequest { Name = "测试预算", Type = BudgetPeriodType.Month, Category = BudgetCategory.Expense, Limit = 0, NoLimit = false, SelectedCategories = new[] { "餐饮" } }; // Act & Assert await Assert.ThrowsAsync(() => _application.CreateAsync(request)); } [Fact] public async Task CreateAsync_未选择分类_应抛出ValidationException() { // Arrange var request = new CreateBudgetRequest { Name = "测试预算", Type = BudgetPeriodType.Month, Category = BudgetCategory.Expense, Limit = 1000, SelectedCategories = Array.Empty() }; // Act & Assert await Assert.ThrowsAsync(() => _application.CreateAsync(request)); } [Fact] public async Task CreateAsync_不记额预算非年度_应抛出ValidationException() { // Arrange var request = new CreateBudgetRequest { Name = "错误预算", Type = BudgetPeriodType.Month, // 月度 Category = BudgetCategory.Expense, NoLimit = true, // 不记额 SelectedCategories = new[] { "其他" } }; _budgetRepository.GetAllAsync().Returns(new List()); // Act & Assert var exception = await Assert.ThrowsAsync( () => _application.CreateAsync(request) ); exception.Message.Should().Contain("不记额预算只能设置为年度预算"); } [Fact] public async Task CreateAsync_分类冲突_应抛出ValidationException() { // Arrange var existingBudget = new BudgetRecord { Id = 1, Name = "现有预算", Category = BudgetCategory.Expense, SelectedCategories = "餐饮,交通" }; _budgetRepository.GetAllAsync().Returns(new List { existingBudget }); var request = new CreateBudgetRequest { Name = "新预算", Type = BudgetPeriodType.Month, Category = BudgetCategory.Expense, Limit = 1000, SelectedCategories = new[] { "餐饮" }, // 冲突 NoLimit = false }; // Act & Assert var exception = await Assert.ThrowsAsync( () => _application.CreateAsync(request) ); exception.Message.Should().Contain("存在分类冲突"); } [Fact] public async Task CreateAsync_不记额预算_应将Limit设为0() { // Arrange var request = new CreateBudgetRequest { Name = "不记额预算", Type = BudgetPeriodType.Year, Category = BudgetCategory.Expense, Limit = 999, // 即使传入金额 NoLimit = true, SelectedCategories = new[] { "其他" } }; _budgetRepository.GetAllAsync().Returns(new List()); _budgetRepository.AddAsync(Arg.Any()).Returns(true); // Act await _application.CreateAsync(request); // Assert await _budgetRepository.Received(1).AddAsync(Arg.Is( b => b.NoLimit && b.Limit == 0 )); } [Fact] public async Task CreateAsync_Repository添加失败_应抛出BusinessException() { // Arrange var request = new CreateBudgetRequest { Name = "测试预算", Type = BudgetPeriodType.Month, Category = BudgetCategory.Expense, Limit = 1000, SelectedCategories = new[] { "餐饮" } }; _budgetRepository.GetAllAsync().Returns(new List()); _budgetRepository.AddAsync(Arg.Any()).Returns(false); // Act & Assert await Assert.ThrowsAsync(() => _application.CreateAsync(request)); } #endregion #region UpdateAsync Tests [Fact] public async Task UpdateAsync_有效请求_应成功更新() { // Arrange var existingBudget = new BudgetRecord { Id = 1, Name = "旧名称", Limit = 500 }; _budgetRepository.GetByIdAsync(1).Returns(existingBudget); _budgetRepository.GetAllAsync().Returns(new List { existingBudget }); _budgetRepository.UpdateAsync(Arg.Any()).Returns(true); var request = new UpdateBudgetRequest { Id = 1, Name = "新名称", Type = BudgetPeriodType.Month, Category = BudgetCategory.Expense, Limit = 1000, SelectedCategories = new[] { "餐饮" }, NoLimit = false }; // Act await _application.UpdateAsync(request); // Assert await _budgetRepository.Received(1).UpdateAsync(Arg.Is( b => b.Id == 1 && b.Name == "新名称" && b.Limit == 1000 )); } [Fact] public async Task UpdateAsync_预算不存在_应抛出NotFoundException() { // Arrange _budgetRepository.GetByIdAsync(999).Returns((BudgetRecord?)null); var request = new UpdateBudgetRequest { Id = 999, Name = "不存在的预算", Type = BudgetPeriodType.Month, Category = BudgetCategory.Expense, Limit = 1000, SelectedCategories = new[] { "餐饮" } }; // Act & Assert await Assert.ThrowsAsync( () => _application.UpdateAsync(request) ); } #endregion #region DeleteByIdAsync Tests [Fact] public async Task DeleteByIdAsync_成功删除_不应抛出异常() { // Arrange _budgetRepository.DeleteAsync(1).Returns(true); // Act await _application.DeleteByIdAsync(1); // Assert await _budgetRepository.Received(1).DeleteAsync(1); } [Fact] public async Task DeleteByIdAsync_删除失败_应抛出BusinessException() { // Arrange _budgetRepository.DeleteAsync(999).Returns(false); // Act & Assert await Assert.ThrowsAsync( () => _application.DeleteByIdAsync(999) ); } #endregion #region GetCategoryStatsAsync Tests [Fact] public async Task GetCategoryStatsAsync_Should_Include_Trend_And_Description_In_Month_Stats() { // Arrange var referenceDate = new DateTime(2026, 2, 14); var category = BudgetCategory.Expense; var serviceResponse = new BudgetCategoryStats { Month = new BudgetStatsDto { Limit = 3000, Current = 1200, Rate = 40, Trend = new List { 100, 200, 300, 400, 500, null, null }, Description = "
日期金额
" }, Year = new BudgetStatsDto { Limit = 36000, Current = 5000, Rate = 13.89m, Trend = new List { 1000, 2000, 3000, null }, Description = "
月份金额
" } }; _budgetService.GetCategoryStatsAsync(category, referenceDate).Returns(serviceResponse); // Act var result = await _application.GetCategoryStatsAsync(category, referenceDate); // Assert result.Should().NotBeNull(); // 验证 Month 数据 result.Month.Limit.Should().Be(3000); result.Month.Current.Should().Be(1200); result.Month.Remaining.Should().Be(1800); result.Month.UsagePercentage.Should().Be(40); result.Month.Trend.Should().NotBeNull(); result.Month.Trend.Should().HaveCount(7); result.Month.Trend[0].Should().Be(100); result.Month.Trend[5].Should().BeNull(); result.Month.Description.Should().NotBeEmpty(); result.Month.Description.Should().Contain(""); } [Fact] public async Task GetCategoryStatsAsync_Should_Include_Trend_And_Description_In_Year_Stats() { // Arrange var referenceDate = new DateTime(2026, 2, 14); var category = BudgetCategory.Income; var serviceResponse = new BudgetCategoryStats { Month = new BudgetStatsDto { Limit = 5000, Current = 3000, Rate = 60, Trend = new List { 500, 1000, 1500, 2000, 2500, 3000 }, Description = "

月度收入明细

" }, Year = new BudgetStatsDto { Limit = 60000, Current = 10000, Rate = 16.67m, Trend = new List { 5000, 10000, null, null, null, null, null, null, null, null, null, null }, Description = "

年度收入明细

" } }; _budgetService.GetCategoryStatsAsync(category, referenceDate).Returns(serviceResponse); // Act var result = await _application.GetCategoryStatsAsync(category, referenceDate); // Assert result.Should().NotBeNull(); // 验证 Year 数据 result.Year.Limit.Should().Be(60000); result.Year.Current.Should().Be(10000); result.Year.Remaining.Should().Be(50000); result.Year.UsagePercentage.Should().Be(16.67m); result.Year.Trend.Should().NotBeNull(); result.Year.Trend.Should().HaveCount(12); result.Year.Trend[0].Should().Be(5000); result.Year.Trend[1].Should().Be(10000); result.Year.Trend[2].Should().BeNull(); result.Year.Description.Should().NotBeEmpty(); result.Year.Description.Should().Contain("年度收入明细"); } #endregion }