using Microsoft.Extensions.Logging; using Service.Transaction; namespace WebApi.Test.Transaction; public class TransactionPeriodicServiceTest : BaseTest { private readonly ITransactionPeriodicRepository _periodicRepository = Substitute.For(); private readonly ITransactionRecordRepository _transactionRepository = Substitute.For(); private readonly IMessageRecordRepository _messageRepository = Substitute.For(); private readonly ILogger _logger = Substitute.For>(); private readonly ITransactionPeriodicService _service; public TransactionPeriodicServiceTest() { _service = new TransactionPeriodicService( _periodicRepository, _transactionRepository, _messageRepository, _logger ); } [Fact] public async Task ExecutePeriodicBillsAsync_每日账单() { // Arrange var today = new DateTime(2024, 1, 15, 10, 0, 0); var periodicBill = new TransactionPeriodic { Id = 1, PeriodicType = PeriodicType.Daily, Amount = 100m, Type = TransactionType.Expense, Classify = "餐饮", Reason = "每日餐费", IsEnabled = true, LastExecuteTime = new DateTime(2024, 1, 14, 10, 0, 0), NextExecuteTime = new DateTime(2024, 1, 15, 10, 0, 0) }; _periodicRepository.GetPendingPeriodicBillsAsync().Returns(new[] { periodicBill }); _transactionRepository.AddAsync(Arg.Any()).Returns(Task.FromResult(true)); _messageRepository.AddAsync(Arg.Any()).Returns(Task.CompletedTask); _periodicRepository.UpdateExecuteTimeAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.CompletedTask); // Act await _service.ExecutePeriodicBillsAsync(); // Assert await _transactionRepository.Received(1).AddAsync(Arg.Is(t => t.Amount == -100m && t.Type == TransactionType.Expense && t.Classify == "餐饮" && t.Reason == "每日餐费" && t.Card == "周期性账单" && t.ImportFrom == "周期性账单自动生成" )); await _messageRepository.Received(1).AddAsync(Arg.Is(m => m.Title == "周期性账单提醒" && m.Content.Contains("支出") && m.Content.Contains("100.00") && m.Content.Contains("每日餐费") && m.IsRead == false )); await _periodicRepository.Received(1).UpdateExecuteTimeAsync( Arg.Is(1L), Arg.Is(dt => dt.Date == today.Date), Arg.Is(dt => dt.HasValue && dt.Value.Date == today.Date.AddDays(1)) ); } [Fact] public async Task ExecutePeriodicBillsAsync_每周账单() { // Arrange var periodicBill = new TransactionPeriodic { Id = 1, PeriodicType = PeriodicType.Weekly, PeriodicConfig = "1,3,5", // 周一、三、五 Amount = 200m, Type = TransactionType.Expense, Classify = "交通", Reason = "每周通勤费", IsEnabled = true, LastExecuteTime = new DateTime(2024, 1, 10, 10, 0, 0), NextExecuteTime = new DateTime(2024, 1, 15, 10, 0, 0) }; _periodicRepository.GetPendingPeriodicBillsAsync().Returns(new[] { periodicBill }); _transactionRepository.AddAsync(Arg.Any()).Returns(Task.FromResult(true)); _messageRepository.AddAsync(Arg.Any()).Returns(Task.CompletedTask); _periodicRepository.UpdateExecuteTimeAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.CompletedTask); // Act await _service.ExecutePeriodicBillsAsync(); // Assert await _transactionRepository.Received(1).AddAsync(Arg.Is(t => t.Amount == -200m && t.Type == TransactionType.Expense && t.Classify == "交通" && t.Reason == "每周通勤费" )); } [Fact] public async Task ExecutePeriodicBillsAsync_每月账单() { // Arrange var periodicBill = new TransactionPeriodic { Id = 1, PeriodicType = PeriodicType.Monthly, PeriodicConfig = "1,15", // 每月1号和15号 Amount = 5000m, Type = TransactionType.Income, Classify = "工资", Reason = "每月工资", IsEnabled = true, LastExecuteTime = new DateTime(2024, 1, 1, 10, 0, 0), NextExecuteTime = new DateTime(2024, 1, 15, 10, 0, 0) }; _periodicRepository.GetPendingPeriodicBillsAsync().Returns(new[] { periodicBill }); _transactionRepository.AddAsync(Arg.Any()).Returns(Task.FromResult(true)); _messageRepository.AddAsync(Arg.Any()).Returns(Task.CompletedTask); _periodicRepository.UpdateExecuteTimeAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.CompletedTask); // Act await _service.ExecutePeriodicBillsAsync(); // Assert await _transactionRepository.Received(1).AddAsync(Arg.Is(t => t.Amount == 5000m && t.Type == TransactionType.Income && t.Classify == "工资" && t.Reason == "每月工资" )); await _messageRepository.Received(1).AddAsync(Arg.Is(m => m.Content.Contains("收入") && m.Content.Contains("5000.00") && m.Content.Contains("每月工资") )); } [Fact] public async Task ExecutePeriodicBillsAsync_未达到执行时间() { // Arrange var periodicBill = new TransactionPeriodic { Id = 1, PeriodicType = PeriodicType.Weekly, PeriodicConfig = "1,3,5", // 只在周一(1)、三(3)、五(5)执行 Amount = 200m, Type = TransactionType.Expense, Classify = "交通", Reason = "每周通勤费", IsEnabled = true, LastExecuteTime = new DateTime(2024, 1, 10, 10, 0, 0), NextExecuteTime = new DateTime(2024, 1, 15, 10, 0, 0) }; _periodicRepository.GetPendingPeriodicBillsAsync().Returns(new[] { periodicBill }); // Act await _service.ExecutePeriodicBillsAsync(); // Assert await _transactionRepository.Received(0).AddAsync(Arg.Any()); await _messageRepository.Received(0).AddAsync(Arg.Any()); await _periodicRepository.Received(0).UpdateExecuteTimeAsync(Arg.Any(), Arg.Any(), Arg.Any()); } [Fact] public async Task ExecutePeriodicBillsAsync_今天已执行过() { // Arrange var periodicBill = new TransactionPeriodic { Id = 1, PeriodicType = PeriodicType.Daily, Amount = 100m, Type = TransactionType.Expense, Classify = "餐饮", Reason = "每日餐费", IsEnabled = true, LastExecuteTime = new DateTime(2024, 1, 15, 8, 0, 0), // 今天已经执行过 NextExecuteTime = new DateTime(2024, 1, 16, 10, 0, 0) }; _periodicRepository.GetPendingPeriodicBillsAsync().Returns(new[] { periodicBill }); // Act await _service.ExecutePeriodicBillsAsync(); // Assert // 由于 LastExecuteTime 日期是今天,所以不会再次执行 await _transactionRepository.Received(0).AddAsync(Arg.Any()); await _messageRepository.Received(0).AddAsync(Arg.Any()); await _periodicRepository.Received(0).UpdateExecuteTimeAsync(Arg.Any(), Arg.Any(), Arg.Any()); } [Fact] public async Task ExecutePeriodicBillsAsync_从未执行过() { // Arrange var periodicBill = new TransactionPeriodic { Id = 1, PeriodicType = PeriodicType.Weekly, PeriodicConfig = "1,3,5", Amount = 200m, Type = TransactionType.Expense, Classify = "交通", Reason = "每周通勤费", IsEnabled = true, LastExecuteTime = null, // 从未执行过 NextExecuteTime = null }; _periodicRepository.GetPendingPeriodicBillsAsync().Returns(new[] { periodicBill }); _transactionRepository.AddAsync(Arg.Any()).Returns(Task.FromResult(true)); _messageRepository.AddAsync(Arg.Any()).Returns(Task.CompletedTask); _periodicRepository.UpdateExecuteTimeAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.CompletedTask); // Act await _service.ExecutePeriodicBillsAsync(); // Assert await _transactionRepository.Received(1).AddAsync(Arg.Any()); } [Fact] public async Task ExecutePeriodicBillsAsync_添加交易记录失败() { // Arrange var periodicBill = new TransactionPeriodic { Id = 1, PeriodicType = PeriodicType.Daily, Amount = 100m, Type = TransactionType.Expense, Classify = "餐饮", Reason = "每日餐费", IsEnabled = true, LastExecuteTime = new DateTime(2024, 1, 14, 10, 0, 0), NextExecuteTime = new DateTime(2024, 1, 15, 10, 0, 0) }; _periodicRepository.GetPendingPeriodicBillsAsync().Returns(new[] { periodicBill }); _transactionRepository.AddAsync(Arg.Any()).Returns(false); // 添加失败 // Act await _service.ExecutePeriodicBillsAsync(); // Assert await _messageRepository.Received(0).AddAsync(Arg.Any()); await _periodicRepository.Received(0).UpdateExecuteTimeAsync(Arg.Any(), Arg.Any(), Arg.Any()); } [Fact] public async Task ExecutePeriodicBillsAsync_多条账单() { // Arrange var periodicBills = new[] { new TransactionPeriodic { Id = 1, PeriodicType = PeriodicType.Daily, Amount = 100m, Type = TransactionType.Expense, Classify = "餐饮", Reason = "每日餐费", IsEnabled = true, LastExecuteTime = new DateTime(2024, 1, 14, 10, 0, 0), NextExecuteTime = new DateTime(2024, 1, 15, 10, 0, 0) }, new TransactionPeriodic { Id = 2, PeriodicType = PeriodicType.Daily, Amount = 200m, Type = TransactionType.Expense, Classify = "交通", Reason = "每日交通", IsEnabled = true, LastExecuteTime = new DateTime(2024, 1, 14, 10, 0, 0), NextExecuteTime = new DateTime(2024, 1, 15, 10, 0, 0) } }; _periodicRepository.GetPendingPeriodicBillsAsync().Returns(periodicBills); _transactionRepository.AddAsync(Arg.Any()).Returns(Task.FromResult(true)); _messageRepository.AddAsync(Arg.Any()).Returns(Task.CompletedTask); _periodicRepository.UpdateExecuteTimeAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.CompletedTask); // Act await _service.ExecutePeriodicBillsAsync(); // Assert await _transactionRepository.Received(2).AddAsync(Arg.Any()); await _messageRepository.Received(2).AddAsync(Arg.Any()); await _periodicRepository.Received(2).UpdateExecuteTimeAsync(Arg.Any(), Arg.Any(), Arg.Any()); } [Fact] public void CalculateNextExecuteTime_每日() { // Arrange var periodic = new TransactionPeriodic { Id = 1, PeriodicType = PeriodicType.Daily, PeriodicConfig = "" }; var baseTime = new DateTime(2024, 1, 15, 10, 0, 0); // Act var result = _service.CalculateNextExecuteTime(periodic, baseTime); // Assert result.Should().Be(new DateTime(2024, 1, 16, 0, 0, 0)); } [Fact] public void CalculateNextExecuteTime_每周_本周内() { // Arrange var periodic = new TransactionPeriodic { Id = 1, PeriodicType = PeriodicType.Weekly, PeriodicConfig = "1,3,5" // 周一、三、五 }; var baseTime = new DateTime(2024, 1, 15, 10, 0, 0); // 周一 // Act var result = _service.CalculateNextExecuteTime(periodic, baseTime); // Assert result.Should().Be(new DateTime(2024, 1, 17, 0, 0, 0)); // 下周三 } [Fact] public void CalculateNextExecuteTime_每周_跨周() { // Arrange var periodic = new TransactionPeriodic { Id = 1, PeriodicType = PeriodicType.Weekly, PeriodicConfig = "1,3,5" // 周一、三、五 }; var baseTime = new DateTime(2024, 1, 19, 10, 0, 0); // 周五 // Act var result = _service.CalculateNextExecuteTime(periodic, baseTime); // Assert result.Should().Be(new DateTime(2024, 1, 22, 0, 0, 0)); // 下周一 } [Fact] public void CalculateNextExecuteTime_每月_本月内() { // Arrange var periodic = new TransactionPeriodic { Id = 1, PeriodicType = PeriodicType.Monthly, PeriodicConfig = "1,15" // 每月1号和15号 }; var baseTime = new DateTime(2024, 1, 10, 10, 0, 0); // Act var result = _service.CalculateNextExecuteTime(periodic, baseTime); // Assert result.Should().Be(new DateTime(2024, 1, 15, 0, 0, 0)); } [Fact] public void CalculateNextExecuteTime_每月_跨月() { // Arrange var periodic = new TransactionPeriodic { Id = 1, PeriodicType = PeriodicType.Monthly, PeriodicConfig = "1,15" // 每月1号和15号 }; var baseTime = new DateTime(2024, 1, 16, 10, 0, 0); // Act var result = _service.CalculateNextExecuteTime(periodic, baseTime); // Assert result.Should().Be(new DateTime(2024, 2, 1, 0, 0, 0)); } [Fact] public void CalculateNextExecuteTime_每月_月末() { // Arrange var periodic = new TransactionPeriodic { Id = 1, PeriodicType = PeriodicType.Monthly, PeriodicConfig = "30,31" // 每月30号和31号 }; var baseTime = new DateTime(2024, 1, 15, 10, 0, 0); // 1月只有31天 // Act var result = _service.CalculateNextExecuteTime(periodic, baseTime); // Assert result.Should().Be(new DateTime(2024, 1, 30, 0, 0, 0)); } [Fact] public void CalculateNextExecuteTime_每月_小月() { // Arrange var periodic = new TransactionPeriodic { Id = 1, PeriodicType = PeriodicType.Monthly, PeriodicConfig = "30,31" // 每月30号和31号 }; var baseTime = new DateTime(2024, 4, 25, 10, 0, 0); // 4月只有30天 // Act var result = _service.CalculateNextExecuteTime(periodic, baseTime); // Assert result.Should().Be(new DateTime(2024, 4, 30, 0, 0, 0)); // 30号,31号不存在 } [Fact] public void CalculateNextExecuteTime_每季度() { // Arrange var periodic = new TransactionPeriodic { Id = 1, PeriodicType = PeriodicType.Quarterly, PeriodicConfig = "15" // 每季度第15天 }; var baseTime = new DateTime(2024, 1, 15, 10, 0, 0); // Act var result = _service.CalculateNextExecuteTime(periodic, baseTime); // Assert result.Should().Be(new DateTime(2024, 4, 15, 0, 0, 0)); // 下季度 } [Fact] public void CalculateNextExecuteTime_每年() { // Arrange var periodic = new TransactionPeriodic { Id = 1, PeriodicType = PeriodicType.Yearly, PeriodicConfig = "100" // 每年第100天 }; var baseTime = new DateTime(2024, 4, 10, 10, 0, 0); // 第100天是4月9日 // Act var result = _service.CalculateNextExecuteTime(periodic, baseTime); // Assert result.Should().Be(new DateTime(2025, 4, 9, 0, 0, 0)); // 下一年 } [Fact] public void CalculateNextExecuteTime_未知周期类型() { // Arrange var periodic = new TransactionPeriodic { Id = 1, PeriodicType = (PeriodicType)99, // 未知类型 PeriodicConfig = "" }; var baseTime = new DateTime(2024, 1, 15, 10, 0, 0); // Act var result = _service.CalculateNextExecuteTime(periodic, baseTime); // Assert result.Should().BeNull(); } [Fact] public async Task ExecutePeriodicBillsAsync_处理异常不中断() { // Arrange var periodicBills = new[] { new TransactionPeriodic { Id = 1, PeriodicType = PeriodicType.Daily, Amount = 100m, Type = TransactionType.Expense, Classify = "餐饮", Reason = "每日餐费", IsEnabled = true, LastExecuteTime = new DateTime(2024, 1, 14, 10, 0, 0), NextExecuteTime = new DateTime(2024, 1, 15, 10, 0, 0) }, new TransactionPeriodic { Id = 2, PeriodicType = PeriodicType.Daily, Amount = 200m, Type = TransactionType.Expense, Classify = "交通", Reason = "每日交通", IsEnabled = true, LastExecuteTime = new DateTime(2024, 1, 14, 10, 0, 0), NextExecuteTime = new DateTime(2024, 1, 15, 10, 0, 0) } }; _periodicRepository.GetPendingPeriodicBillsAsync().Returns(periodicBills); _transactionRepository.AddAsync(Arg.Any()).Returns(Task.FromResult(true)); _messageRepository.AddAsync(Arg.Any()).Returns(Task.CompletedTask); _periodicRepository.UpdateExecuteTimeAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(args => { var id = (long)args[0]; if (id == 1) { throw new Exception("更新失败"); } return Task.CompletedTask; }); // Act await _service.ExecutePeriodicBillsAsync(); // Assert // 第二条记录应该成功处理 await _transactionRepository.Received(2).AddAsync(Arg.Any()); } [Fact] public async Task ExecutePeriodicBillsAsync_处理所有账单() { // Arrange var periodicBills = new[] { new TransactionPeriodic { Id = 1, PeriodicType = PeriodicType.Daily, Amount = 100m, Type = TransactionType.Expense, Classify = "餐饮", Reason = "每日餐费", IsEnabled = false, // 禁用 LastExecuteTime = new DateTime(2024, 1, 14, 10, 0, 0), NextExecuteTime = new DateTime(2024, 1, 15, 10, 0, 0) }, new TransactionPeriodic { Id = 2, PeriodicType = PeriodicType.Daily, Amount = 200m, Type = TransactionType.Expense, Classify = "交通", Reason = "每日交通", IsEnabled = true, LastExecuteTime = new DateTime(2024, 1, 14, 10, 0, 0), NextExecuteTime = new DateTime(2024, 1, 15, 10, 0, 0) } }; _periodicRepository.GetPendingPeriodicBillsAsync().Returns(periodicBills); _transactionRepository.AddAsync(Arg.Any()).Returns(Task.FromResult(true)); _messageRepository.AddAsync(Arg.Any()).Returns(Task.CompletedTask); _periodicRepository.UpdateExecuteTimeAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.CompletedTask); // Act await _service.ExecutePeriodicBillsAsync(); // Assert // 只有启用的账单会被处理 await _transactionRepository.Received(1).AddAsync(Arg.Is(t => t.Reason == "每日交通")); await _transactionRepository.Received(0).AddAsync(Arg.Is(t => t.Reason == "每日餐费")); } }