Files
EmailBill/WebApi.Test/Budget/BudgetStatsArchiveTest.cs

393 lines
14 KiB
C#
Raw Normal View History

2026-02-10 17:49:19 +08:00
using NSubstitute.ReturnsExtensions;
2026-02-01 10:27:04 +08:00
using Service.Transaction;
namespace WebApi.Test.Budget;
/// <summary>
/// 预算统计 - 归档数据重复计算测试
/// </summary>
public class BudgetStatsArchiveTest : BaseTest
{
private readonly IBudgetRepository _budgetRepo = Substitute.For<IBudgetRepository>();
private readonly IBudgetArchiveRepository _archiveRepo = Substitute.For<IBudgetArchiveRepository>();
private readonly ITransactionStatisticsService _transactionStatsService = Substitute.For<ITransactionStatisticsService>();
private readonly IDateTimeProvider _dateTimeProvider = Substitute.For<IDateTimeProvider>();
private IBudgetStatsService CreateService()
{
return new BudgetStatsService(
_budgetRepo,
_archiveRepo,
_transactionStatsService,
_dateTimeProvider,
Substitute.For<ILogger<BudgetStatsService>>()
);
}
/// <summary>
/// 测试场景当前为2月用户切换到1月已归档查看预算
/// 预期年度统计不应重复计算1月的数据
/// </summary>
[Fact]
public async Task GetCategoryStats_切换到已归档月份_年度统计不重复计算_Test()
{
// Arrange - 模拟当前时间为2026年2月1日
var now = new DateTime(2026, 2, 1);
_dateTimeProvider.Now.Returns(now);
// 用户在前端选择查看1月的预算referenceDate = 2026-01-01
var referenceDate = new DateTime(2026, 1, 1);
// 创建一个月度预算:房贷
var monthlyBudget = new BudgetRecord
{
Id = 100,
Name = "房贷",
Category = BudgetCategory.Expense,
Type = BudgetPeriodType.Month,
Limit = 9000, // 每月9000元
StartDate = new DateTime(2026, 1, 1),
SelectedCategories = "房贷",
IsMandatoryExpense = false,
NoLimit = false
};
// 当前预算列表
_budgetRepo.GetAllAsync().Returns([monthlyBudget]);
// 1月的归档数据实际支出9158.7
var januaryArchive = new BudgetArchive
{
Year = 2026,
Month = 1,
Content = new[]
{
new BudgetArchiveContent
{
Id = 100,
Name = "房贷",
Category = BudgetCategory.Expense,
Type = BudgetPeriodType.Month,
Limit = 9000,
Actual = 9158.7m, // 1月实际支出
SelectedCategories = ["房贷"],
IsMandatoryExpense = false,
NoLimit = false
}
}
};
_archiveRepo.GetArchiveAsync(2026, 1).Returns(januaryArchive);
_archiveRepo.GetArchiveAsync(2026, 2).ReturnsNull();
// 模拟2月的实际交易数据假设2月到现在实际支出了3000
var feb1 = new DateTime(2026, 2, 1);
var feb28 = new DateTime(2026, 2, 28);
_budgetRepo.GetCurrentAmountAsync(
Arg.Is<BudgetRecord>(b => b.Id == 100),
Arg.Is<DateTime>(d => d >= feb1 && d <= feb28),
Arg.Any<DateTime>()
).Returns(3000m);
// 模拟交易统计数据(用于趋势图)
_transactionStatsService.GetFilteredTrendStatisticsAsync(
Arg.Any<DateTime>(),
Arg.Any<DateTime>(),
TransactionType.Expense,
Arg.Any<List<string>>(),
true
).Returns(new Dictionary<DateTime, decimal>
{
{ new DateTime(2026, 1, 1), 9158.7m }, // 1月
{ new DateTime(2026, 2, 1), 3000m } // 2月
});
// 模拟月度统计的交易数据
_transactionStatsService.GetFilteredTrendStatisticsAsync(
Arg.Any<DateTime>(),
Arg.Any<DateTime>(),
TransactionType.Expense,
Arg.Any<List<string>>()
).Returns(new Dictionary<DateTime, decimal>
{
{ new DateTime(2026, 1, 1), 9158.7m }
});
var service = CreateService();
// Act - 调用获取分类统计用户选择查看1月
var result = await service.GetCategoryStatsAsync(BudgetCategory.Expense, referenceDate);
// Assert - 验证年度统计
result.Should().NotBeNull();
result.Year.Should().NotBeNull();
// 年度预算限额 = 1月归档(9000) + 2月当前(9000) + 未来10个月(9000 * 10) = 108000
result.Year.Limit.Should().Be(108000);
// 年度实际支出 = 1月归档(9158.7) + 2月当前(3000) = 12158.7
// 关键不应该包含两次1月的数据
result.Year.Current.Should().Be(12158.7m);
// 使用率 = 12158.7 / 108000 * 100 = 11.26%
result.Year.Rate.Should().BeApproximately(11.26m, 0.01m);
}
/// <summary>
/// 测试场景当前为2月用户切换到1月已归档查看预算包含年度预算
/// 预期:年度预算只计算一次
/// </summary>
[Fact]
public async Task GetCategoryStats_年度预算_切换到已归档月份_不重复计算_Test()
{
// Arrange - 模拟当前时间为2026年2月1日
var now = new DateTime(2026, 2, 1);
_dateTimeProvider.Now.Returns(now);
var referenceDate = new DateTime(2026, 1, 1);
// 创建年度预算和月度预算
var yearlyBudget = new BudgetRecord
{
Id = 200,
Name = "教育费",
Category = BudgetCategory.Expense,
Type = BudgetPeriodType.Year,
Limit = 8000, // 全年8000元
StartDate = new DateTime(2026, 1, 1),
SelectedCategories = "教育",
IsMandatoryExpense = false,
NoLimit = false
};
var monthlyBudget = new BudgetRecord
{
Id = 100,
Name = "生活费",
Category = BudgetCategory.Expense,
Type = BudgetPeriodType.Month,
Limit = 2000,
StartDate = new DateTime(2026, 1, 1),
SelectedCategories = "餐饮",
IsMandatoryExpense = false,
NoLimit = false
};
_budgetRepo.GetAllAsync().Returns([yearlyBudget, monthlyBudget]);
// 1月归档数据
var januaryArchive = new BudgetArchive
{
Year = 2026,
Month = 1,
Content = new[]
{
new BudgetArchiveContent
{
Id = 200,
Name = "教育费",
Category = BudgetCategory.Expense,
Type = BudgetPeriodType.Year,
Limit = 8000,
Actual = 7257m, // 全年实际从1月累计
SelectedCategories = ["教育"],
IsMandatoryExpense = false,
NoLimit = false
},
new BudgetArchiveContent
{
Id = 100,
Name = "生活费",
Category = BudgetCategory.Expense,
Type = BudgetPeriodType.Month,
Limit = 2000,
Actual = 2000m, // 1月实际
SelectedCategories = ["餐饮"],
IsMandatoryExpense = false,
NoLimit = false
}
}
};
_archiveRepo.GetArchiveAsync(2026, 1).Returns(januaryArchive);
_archiveRepo.GetArchiveAsync(2026, 2).ReturnsNull();
// 2月的实际数据
var feb1 = new DateTime(2026, 2, 1);
var feb28 = new DateTime(2026, 2, 28);
_budgetRepo.GetCurrentAmountAsync(
Arg.Is<BudgetRecord>(b => b.Id == 100),
Arg.Is<DateTime>(d => d >= feb1 && d <= feb28),
Arg.Any<DateTime>()
).Returns(1800m);
// 年度预算的当前实际值整年累计包括1月归档的7257
var year1 = new DateTime(2026, 1, 1);
var year12 = new DateTime(2026, 12, 31);
_budgetRepo.GetCurrentAmountAsync(
Arg.Is<BudgetRecord>(b => b.Id == 200),
Arg.Is<DateTime>(d => d >= year1),
Arg.Any<DateTime>()
).Returns(7257m); // 全年累计
_transactionStatsService.GetFilteredTrendStatisticsAsync(
Arg.Any<DateTime>(),
Arg.Any<DateTime>(),
TransactionType.Expense,
Arg.Any<List<string>>(),
true
).Returns(new Dictionary<DateTime, decimal>
{
{ new DateTime(2026, 1, 1), 9257m }, // 1月: 教育7257 + 生活2000
{ new DateTime(2026, 2, 1), 1800m } // 2月: 生活1800
});
// 模拟月度统计的交易数据
_transactionStatsService.GetFilteredTrendStatisticsAsync(
Arg.Any<DateTime>(),
Arg.Any<DateTime>(),
TransactionType.Expense,
Arg.Any<List<string>>()
).Returns(new Dictionary<DateTime, decimal>
{
{ new DateTime(2026, 1, 1), 9257m }
});
var service = CreateService();
// Act
var result = await service.GetCategoryStatsAsync(BudgetCategory.Expense, referenceDate);
// Assert
result.Year.Should().NotBeNull();
// 年度限额 = 教育费(8000) + 生活费1月归档(2000) + 生活费2月(2000) + 生活费未来10月(2000*10) = 32000
result.Year.Limit.Should().Be(32000);
// 年度实际支出 = 教育费(7257) + 生活费1月(2000) + 生活费2月(1800) = 11057
// 关键:教育费(年度预算)只应该计算一次!
result.Year.Current.Should().Be(11057m);
}
/// <summary>
/// 测试场景当前为3月用户切换到1月查看
/// 预期年度统计应包含1月归档 + 2月归档 + 3月当前
/// </summary>
[Fact]
public async Task GetCategoryStats_多个归档月份_不重复计算_Test()
{
// Arrange - 模拟当前时间为2026年3月15日
var now = new DateTime(2026, 3, 15);
_dateTimeProvider.Now.Returns(now);
var referenceDate = new DateTime(2026, 1, 1);
var monthlyBudget = new BudgetRecord
{
Id = 100,
Name = "房贷",
Category = BudgetCategory.Expense,
Type = BudgetPeriodType.Month,
Limit = 9000,
StartDate = new DateTime(2026, 1, 1),
SelectedCategories = "房贷",
IsMandatoryExpense = false,
NoLimit = false
};
_budgetRepo.GetAllAsync().Returns([monthlyBudget]);
// 1月归档
_archiveRepo.GetArchiveAsync(2026, 1).Returns(new BudgetArchive
{
Year = 2026,
Month = 1,
Content = new[]
{
new BudgetArchiveContent
{
Id = 100,
Name = "房贷",
Category = BudgetCategory.Expense,
Type = BudgetPeriodType.Month,
Limit = 9000,
Actual = 9158.7m,
SelectedCategories = ["房贷"],
IsMandatoryExpense = false,
NoLimit = false
}
}
});
// 2月归档
_archiveRepo.GetArchiveAsync(2026, 2).Returns(new BudgetArchive
{
Year = 2026,
Month = 2,
Content = new[]
{
new BudgetArchiveContent
{
Id = 100,
Name = "房贷",
Category = BudgetCategory.Expense,
Type = BudgetPeriodType.Month,
Limit = 9000,
Actual = 9126.1m,
SelectedCategories = ["房贷"],
IsMandatoryExpense = false,
NoLimit = false
}
}
});
_archiveRepo.GetArchiveAsync(2026, 3).ReturnsNull();
// 3月当前实际数据到3月15日
_budgetRepo.GetCurrentAmountAsync(
Arg.Is<BudgetRecord>(b => b.Id == 100),
Arg.Any<DateTime>(),
Arg.Any<DateTime>()
).Returns(4500m); // 3月已支出4500
_transactionStatsService.GetFilteredTrendStatisticsAsync(
Arg.Any<DateTime>(),
Arg.Any<DateTime>(),
TransactionType.Expense,
Arg.Any<List<string>>(),
true
).Returns(new Dictionary<DateTime, decimal>
{
{ new DateTime(2026, 1, 1), 9158.7m },
{ new DateTime(2026, 2, 1), 9126.1m },
{ new DateTime(2026, 3, 1), 4500m }
});
// 模拟月度统计的交易数据
_transactionStatsService.GetFilteredTrendStatisticsAsync(
Arg.Any<DateTime>(),
Arg.Any<DateTime>(),
TransactionType.Expense,
Arg.Any<List<string>>()
).Returns(new Dictionary<DateTime, decimal>
{
{ new DateTime(2026, 1, 1), 9158.7m }
});
var service = CreateService();
// Act
var result = await service.GetCategoryStatsAsync(BudgetCategory.Expense, referenceDate);
// Assert
result.Year.Should().NotBeNull();
// 年度限额 = 1月归档(9000) + 2月归档(9000) + 3月当前(9000) + 未来9月(9000*9) = 108000
result.Year.Limit.Should().Be(108000);
// 年度实际 = 1月归档(9158.7) + 2月归档(9126.1) + 3月当前(4500) = 22784.8
result.Year.Current.Should().Be(22784.8m);
// 验证每个月只计算了一次
result.Year.Rate.Should().BeApproximately(21.10m, 0.01m);
}
}