using NSubstitute.ReturnsExtensions;
using Service.Transaction;
namespace WebApi.Test.Budget;
///
/// 预算统计 - 归档数据重复计算测试
///
public class BudgetStatsArchiveTest : BaseTest
{
private readonly IBudgetRepository _budgetRepo = Substitute.For();
private readonly IBudgetArchiveRepository _archiveRepo = Substitute.For();
private readonly ITransactionStatisticsService _transactionStatsService = Substitute.For();
private readonly IDateTimeProvider _dateTimeProvider = Substitute.For();
private IBudgetStatsService CreateService()
{
return new BudgetStatsService(
_budgetRepo,
_archiveRepo,
_transactionStatsService,
_dateTimeProvider,
Substitute.For>()
);
}
///
/// 测试场景:当前为2月,用户切换到1月(已归档)查看预算
/// 预期:年度统计不应重复计算1月的数据
///
[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(b => b.Id == 100),
Arg.Is(d => d >= feb1 && d <= feb28),
Arg.Any()
).Returns(3000m);
// 模拟交易统计数据(用于趋势图)
_transactionStatsService.GetFilteredTrendStatisticsAsync(
Arg.Any(),
Arg.Any(),
TransactionType.Expense,
Arg.Any>(),
true
).Returns(new Dictionary
{
{ new DateTime(2026, 1, 1), 9158.7m }, // 1月
{ new DateTime(2026, 2, 1), 3000m } // 2月
});
// 模拟月度统计的交易数据
_transactionStatsService.GetFilteredTrendStatisticsAsync(
Arg.Any(),
Arg.Any(),
TransactionType.Expense,
Arg.Any>()
).Returns(new Dictionary
{
{ 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);
}
///
/// 测试场景:当前为2月,用户切换到1月(已归档)查看预算,包含年度预算
/// 预期:年度预算只计算一次
///
[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(b => b.Id == 100),
Arg.Is(d => d >= feb1 && d <= feb28),
Arg.Any()
).Returns(1800m);
// 年度预算的当前实际值(整年累计,包括1月归档的7257)
var year1 = new DateTime(2026, 1, 1);
var year12 = new DateTime(2026, 12, 31);
_budgetRepo.GetCurrentAmountAsync(
Arg.Is(b => b.Id == 200),
Arg.Is(d => d >= year1),
Arg.Any()
).Returns(7257m); // 全年累计
_transactionStatsService.GetFilteredTrendStatisticsAsync(
Arg.Any(),
Arg.Any(),
TransactionType.Expense,
Arg.Any>(),
true
).Returns(new Dictionary
{
{ new DateTime(2026, 1, 1), 9257m }, // 1月: 教育7257 + 生活2000
{ new DateTime(2026, 2, 1), 1800m } // 2月: 生活1800
});
// 模拟月度统计的交易数据
_transactionStatsService.GetFilteredTrendStatisticsAsync(
Arg.Any(),
Arg.Any(),
TransactionType.Expense,
Arg.Any>()
).Returns(new Dictionary
{
{ 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);
}
///
/// 测试场景:当前为3月,用户切换到1月查看
/// 预期:年度统计应包含1月归档 + 2月归档 + 3月当前
///
[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(b => b.Id == 100),
Arg.Any(),
Arg.Any()
).Returns(4500m); // 3月已支出4500
_transactionStatsService.GetFilteredTrendStatisticsAsync(
Arg.Any(),
Arg.Any(),
TransactionType.Expense,
Arg.Any>(),
true
).Returns(new Dictionary
{
{ new DateTime(2026, 1, 1), 9158.7m },
{ new DateTime(2026, 2, 1), 9126.1m },
{ new DateTime(2026, 3, 1), 4500m }
});
// 模拟月度统计的交易数据
_transactionStatsService.GetFilteredTrendStatisticsAsync(
Arg.Any(),
Arg.Any(),
TransactionType.Expense,
Arg.Any>()
).Returns(new Dictionary
{
{ 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);
}
}