2026-02-10 17:49:19 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
namespace WebApi.Test.Application;
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// BudgetApplication 单元测试
|
|
|
|
|
/// </summary>
|
|
|
|
|
public class BudgetApplicationTest : BaseApplicationTest
|
|
|
|
|
{
|
|
|
|
|
private readonly IBudgetService _budgetService;
|
|
|
|
|
private readonly IBudgetRepository _budgetRepository;
|
|
|
|
|
private readonly BudgetApplication _application;
|
|
|
|
|
|
|
|
|
|
public BudgetApplicationTest()
|
|
|
|
|
{
|
|
|
|
|
_budgetService = Substitute.For<IBudgetService>();
|
|
|
|
|
_budgetRepository = Substitute.For<IBudgetRepository>();
|
2026-02-11 13:00:01 +08:00
|
|
|
_application = new BudgetApplication(_budgetService, _budgetRepository);
|
2026-02-10 17:49:19 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#region GetListAsync Tests
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task GetListAsync_应返回排序后的预算列表()
|
|
|
|
|
{
|
|
|
|
|
// Arrange
|
|
|
|
|
var referenceDate = new DateTime(2026, 2, 10);
|
|
|
|
|
var testData = new List<BudgetResult>
|
|
|
|
|
{
|
|
|
|
|
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<BudgetRecord>());
|
|
|
|
|
_budgetRepository.AddAsync(Arg.Any<BudgetRecord>()).Returns(true);
|
|
|
|
|
|
|
|
|
|
// Act
|
|
|
|
|
var id = await _application.CreateAsync(request);
|
|
|
|
|
|
|
|
|
|
// Assert
|
|
|
|
|
id.Should().BeGreaterThan(0);
|
|
|
|
|
await _budgetRepository.Received(1).AddAsync(Arg.Is<BudgetRecord>(
|
|
|
|
|
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<ValidationException>(() => _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<ValidationException>(() => _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<string>()
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Act & Assert
|
|
|
|
|
await Assert.ThrowsAsync<ValidationException>(() => _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<BudgetRecord>());
|
|
|
|
|
|
|
|
|
|
// Act & Assert
|
|
|
|
|
var exception = await Assert.ThrowsAsync<ValidationException>(
|
|
|
|
|
() => _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<BudgetRecord> { 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<ValidationException>(
|
|
|
|
|
() => _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<BudgetRecord>());
|
|
|
|
|
_budgetRepository.AddAsync(Arg.Any<BudgetRecord>()).Returns(true);
|
|
|
|
|
|
|
|
|
|
// Act
|
|
|
|
|
await _application.CreateAsync(request);
|
|
|
|
|
|
|
|
|
|
// Assert
|
|
|
|
|
await _budgetRepository.Received(1).AddAsync(Arg.Is<BudgetRecord>(
|
|
|
|
|
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<BudgetRecord>());
|
|
|
|
|
_budgetRepository.AddAsync(Arg.Any<BudgetRecord>()).Returns(false);
|
|
|
|
|
|
|
|
|
|
// Act & Assert
|
|
|
|
|
await Assert.ThrowsAsync<BusinessException>(() => _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<BudgetRecord> { existingBudget });
|
|
|
|
|
_budgetRepository.UpdateAsync(Arg.Any<BudgetRecord>()).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<BudgetRecord>(
|
|
|
|
|
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<NotFoundException>(
|
|
|
|
|
() => _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<BusinessException>(
|
|
|
|
|
() => _application.DeleteByIdAsync(999)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#endregion
|
2026-02-15 10:10:28 +08:00
|
|
|
|
|
|
|
|
#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<decimal?> { 100, 200, 300, 400, 500, null, null },
|
|
|
|
|
Description = "<table><tr><th>日期</th><th>金额</th></tr></table>"
|
|
|
|
|
},
|
|
|
|
|
Year = new BudgetStatsDto
|
|
|
|
|
{
|
|
|
|
|
Limit = 36000,
|
|
|
|
|
Current = 5000,
|
|
|
|
|
Rate = 13.89m,
|
|
|
|
|
Trend = new List<decimal?> { 1000, 2000, 3000, null },
|
|
|
|
|
Description = "<table><tr><th>月份</th><th>金额</th></tr></table>"
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
_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("<table>");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[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<decimal?> { 500, 1000, 1500, 2000, 2500, 3000 },
|
|
|
|
|
Description = "<p>月度收入明细</p>"
|
|
|
|
|
},
|
|
|
|
|
Year = new BudgetStatsDto
|
|
|
|
|
{
|
|
|
|
|
Limit = 60000,
|
|
|
|
|
Current = 10000,
|
|
|
|
|
Rate = 16.67m,
|
|
|
|
|
Trend = new List<decimal?> { 5000, 10000, null, null, null, null, null, null, null, null, null, null },
|
|
|
|
|
Description = "<p>年度收入明细</p>"
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
_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
|
2026-02-10 17:49:19 +08:00
|
|
|
}
|