测试覆盖率
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 27s
Docker Build & Deploy / Deploy to Production (push) Successful in 9s
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 2s
Docker Build & Deploy / WeChat Notification (push) Successful in 2s

This commit is contained in:
SunCheng
2026-01-28 17:00:58 +08:00
parent 3ed9cf5ebd
commit e93c3d6bae
30 changed files with 2492 additions and 227 deletions

View File

@@ -16,14 +16,13 @@ public class BudgetStatsTest : BaseTest
private readonly ILogger<BudgetService> _logger = Substitute.For<ILogger<BudgetService>>();
private readonly IBudgetSavingsService _budgetSavingsService = Substitute.For<IBudgetSavingsService>();
private readonly IDateTimeProvider _dateTimeProvider = Substitute.For<IDateTimeProvider>();
private readonly IBudgetStatsService _budgetStatsService;
private readonly BudgetService _service;
public BudgetStatsTest()
{
_dateTimeProvider.Now.Returns(new DateTime(2024, 1, 15));
_budgetStatsService = new BudgetStatsService(
IBudgetStatsService budgetStatsService = new BudgetStatsService(
_budgetRepository,
_budgetArchiveRepository,
_transactionStatisticsService,
@@ -41,7 +40,7 @@ public class BudgetStatsTest : BaseTest
_logger,
_budgetSavingsService,
_dateTimeProvider,
_budgetStatsService
budgetStatsService
);
}
@@ -124,8 +123,7 @@ public class BudgetStatsTest : BaseTest
Arg.Is<DateTime>(d => d.Year == 2024 && d.Month == 1 && d.Day == 1),
Arg.Is<DateTime>(d => d.Year == 2024 && d.Month == 1 && d.Day == 31),
TransactionType.Expense,
Arg.Is<List<string>>(list => list.Count == 1 && list.Contains("餐饮")), // 只包含月度预算的分类
false)
Arg.Is<List<string>>(list => list.Count == 1 && list.Contains("餐饮")))
.Returns(new Dictionary<DateTime, decimal>
{
{ new DateTime(2024, 1, 15), 800m } // 1月15日月度吃饭累计800
@@ -281,8 +279,7 @@ public class BudgetStatsTest : BaseTest
Arg.Is<DateTime>(d => d.Year == 2024 && d.Month == 1),
Arg.Is<DateTime>(d => d.Year == 2024 && d.Month == 1),
TransactionType.Expense,
Arg.Is<List<string>>(list => list.Count == 3 && list.Contains("餐饮") && list.Contains("零食") && list.Contains("交通")),
false)
Arg.Is<List<string>>(list => list.Count == 3 && list.Contains("餐饮") && list.Contains("零食") && list.Contains("交通")))
.Returns(new Dictionary<DateTime, decimal>
{
{ new DateTime(2024, 1, 15), 1500m } // 1月15日累计1500吃喝1200+交通300不包含年度旅游2000
@@ -305,8 +302,7 @@ public class BudgetStatsTest : BaseTest
Arg.Is<DateTime>(d => d.Year == 2024 && d.Month == 1),
Arg.Is<DateTime>(d => d.Year == 2024 && d.Month == 1),
TransactionType.Income,
Arg.Any<List<string>>(),
false)
Arg.Any<List<string>>())
.Returns(new Dictionary<DateTime, decimal>()); // 月度收入为空
_transactionStatisticsService.GetFilteredTrendStatisticsAsync(

View File

@@ -0,0 +1,35 @@
using FluentAssertions;
namespace WebApi.Test.Repository;
public class BudgetArchiveRepositoryTest : RepositoryTestBase
{
private readonly IBudgetArchiveRepository _repository;
public BudgetArchiveRepositoryTest()
{
_repository = new BudgetArchiveRepository(FreeSql);
}
[Fact]
public async Task GetArchiveAsync_获取单条归档_Test()
{
await _repository.AddAsync(new BudgetArchive { Year = 2023, Month = 1 });
await _repository.AddAsync(new BudgetArchive { Year = 2023, Month = 2 });
var archive = await _repository.GetArchiveAsync(2023, 1);
archive.Should().NotBeNull();
archive!.Month.Should().Be(1);
}
[Fact]
public async Task GetArchivesByYearAsync_按年获取_Test()
{
await _repository.AddAsync(new BudgetArchive { Year = 2023, Month = 1 });
await _repository.AddAsync(new BudgetArchive { Year = 2023, Month = 2 });
await _repository.AddAsync(new BudgetArchive { Year = 2022, Month = 12 });
var list = await _repository.GetArchivesByYearAsync(2023);
list.Should().HaveCount(2);
}
}

View File

@@ -0,0 +1,72 @@
using FluentAssertions;
namespace WebApi.Test.Repository;
public class BudgetRepositoryTest : TransactionTestBase
{
private readonly IBudgetRepository _repository;
private readonly ITransactionRecordRepository _transactionRepository;
public BudgetRepositoryTest()
{
_repository = new BudgetRepository(FreeSql);
_transactionRepository = new TransactionRecordRepository(FreeSql);
}
[Fact]
public async Task GetCurrentAmountAsync_计算预算金额_Test()
{
// Arrange
// 插入一些交易记录
await _transactionRepository.AddAsync(CreateExpense(100, classify: "餐饮")); // A
await _transactionRepository.AddAsync(CreateExpense(200, classify: "交通")); // B
await _transactionRepository.AddAsync(CreateExpense(50, classify: "餐饮")); // C
await _transactionRepository.AddAsync(CreateIncome(1000)); // 收入,不应计入支出预算
var budget = new BudgetRecord
{
Limit = 2000,
Category = BudgetCategory.Expense,
SelectedCategories = "餐饮,购物", // Only 餐饮 matches
Name = "日常开销"
};
var startDate = DateTime.Now.AddDays(-1);
var endDate = DateTime.Now.AddDays(1);
// Act
var amount = await _repository.GetCurrentAmountAsync(budget, startDate, endDate);
// Assert
// Should sum A+C = -150. But wait, transaction amounts for expense are negative in CreateExpense?
// Let's check CreateExpense: return CreateTestRecord(-amount, ...);
// So actual stored values are -100, -200, -50.
// SumAsync sums them up. Result should be -150.
amount.Should().Be(-150);
}
[Fact]
public async Task UpdateBudgetCategoryNameAsync_更新分类名称_Test()
{
// Arrange
await _repository.AddAsync(new BudgetRecord { Name = "B1", SelectedCategories = "餐饮,交通", Category = BudgetCategory.Expense });
await _repository.AddAsync(new BudgetRecord { Name = "B2", SelectedCategories = "餐饮", Category = BudgetCategory.Expense });
await _repository.AddAsync(new BudgetRecord { Name = "B3", SelectedCategories = "住宿", Category = BudgetCategory.Expense });
// Act
// 将 "餐饮" 更新为 "美食"
await _repository.UpdateBudgetCategoryNameAsync("餐饮", "美食", TransactionType.Expense);
// Assert
var b1 = await _repository.GetByIdAsync(1); // Assuming ID 1 (Standard FreeSql behavior depending on implementation, but I used standard Add)
// Actually, IDs are snowflake. I should capture them.
var all = await _repository.GetAllAsync();
var b1_updated = all.First(b => b.Name == "B1");
b1_updated.SelectedCategories.Should().Contain("美食");
b1_updated.SelectedCategories.Should().NotContain("餐饮");
var b2_updated = all.First(b => b.Name == "B2");
b2_updated.SelectedCategories.Should().Be("美食");
}
}

View File

@@ -0,0 +1,23 @@
using FluentAssertions;
namespace WebApi.Test.Repository;
public class ConfigRepositoryTest : RepositoryTestBase
{
private readonly IConfigRepository _repository;
public ConfigRepositoryTest()
{
_repository = new ConfigRepository(FreeSql);
}
[Fact]
public async Task GetByKeyAsync_获取配置_Test()
{
await _repository.AddAsync(new ConfigEntity { Key = "k1", Value = "v1" });
var config = await _repository.GetByKeyAsync("k1");
config.Should().NotBeNull();
config!.Value.Should().Be("v1");
}
}

View File

@@ -0,0 +1,54 @@
using FluentAssertions;
namespace WebApi.Test.Repository;
public class EmailMessageRepositoryTest : RepositoryTestBase
{
private readonly IEmailMessageRepository _repository;
public EmailMessageRepositoryTest()
{
_repository = new EmailMessageRepository(FreeSql);
}
[Fact]
public async Task ExistsAsync_检查存在_Test()
{
await _repository.AddAsync(new EmailMessage { Md5 = "md5_value", Subject = "Test" });
var msg = await _repository.ExistsAsync("md5_value");
msg.Should().NotBeNull();
var notfound = await _repository.ExistsAsync("other");
notfound.Should().BeNull();
}
[Fact]
public async Task GetPagedListAsync_游标分页_Test()
{
// 插入3条数据时间倒序
var m1 = new EmailMessage { Subject = "M1", ReceivedDate = DateTime.Now }; // Latest
var m2 = new EmailMessage { Subject = "M2", ReceivedDate = DateTime.Now.AddDays(-1) };
var m3 = new EmailMessage { Subject = "M3", ReceivedDate = DateTime.Now.AddDays(-2) }; // Oldest
// FreeSql IDs are snowflakes, increasing.
// Assuming ID order follows insertion (mostly true for snowflakes if generated sequentially)
// But ReceivedDate is the primary sort in logic usually.
// Let's verify standard cursor pagination usually sorts by Date DESC, ID DESC.
await _repository.AddAsync(m1);
await _repository.AddAsync(m2);
await _repository.AddAsync(m3);
// Fetch page 1 (size 2)
var result1 = await _repository.GetPagedListAsync(null, null, 2);
result1.list.Should().HaveCount(2);
result1.list[0].Subject.Should().Be("M1");
result1.list[1].Subject.Should().Be("M2");
// Fetch page 2 using cursor
var result2 = await _repository.GetPagedListAsync(result1.lastReceivedDate, result1.lastId, 2);
result2.list.Should().HaveCount(1);
result2.list[0].Subject.Should().Be("M3");
}
}

View File

@@ -0,0 +1,39 @@
using FluentAssertions;
namespace WebApi.Test.Repository;
public class MessageRecordRepositoryTest : RepositoryTestBase
{
private readonly IMessageRecordRepository _repository;
public MessageRecordRepositoryTest()
{
_repository = new MessageRecordRepository(FreeSql);
}
[Fact]
public async Task GetPagedListAsync_分页_Test()
{
for (int i = 0; i < 5; i++)
{
await _repository.AddAsync(new MessageRecord { Content = $"Msg{i}" });
}
var result = await _repository.GetPagedListAsync(1, 2);
result.Total.Should().Be(5);
result.List.Should().HaveCount(2);
}
[Fact]
public async Task MarkAllAsReadAsync_全部标记已读_Test()
{
await _repository.AddAsync(new MessageRecord { IsRead = false });
await _repository.AddAsync(new MessageRecord { IsRead = false });
await _repository.AddAsync(new MessageRecord { IsRead = true });
await _repository.MarkAllAsReadAsync();
var all = await _repository.GetAllAsync();
all.All(x => x.IsRead).Should().BeTrue();
}
}

View File

@@ -0,0 +1,23 @@
using FluentAssertions;
namespace WebApi.Test.Repository;
public class PushSubscriptionRepositoryTest : RepositoryTestBase
{
private readonly IPushSubscriptionRepository _repository;
public PushSubscriptionRepositoryTest()
{
_repository = new PushSubscriptionRepository(FreeSql);
}
[Fact]
public async Task GetByEndpointAsync_通过Endpoint获取_Test()
{
await _repository.AddAsync(new PushSubscription { Endpoint = "ep1" });
var sub = await _repository.GetByEndpointAsync("ep1");
sub.Should().NotBeNull();
sub!.Endpoint.Should().Be("ep1");
}
}

View File

@@ -0,0 +1,30 @@
using FreeSql;
using WebApi.Test.Basic;
namespace WebApi.Test.Repository;
public abstract class RepositoryTestBase : BaseTest, IDisposable
{
protected IFreeSql FreeSql { get; }
protected RepositoryTestBase()
{
FreeSql = new FreeSqlBuilder()
.UseConnectionString(DataType.Sqlite, "Data Source=:memory:")
.UseAutoSyncStructure(true)
.UseNoneCommandParameter(true)
.Build();
}
public void Dispose()
{
try
{
FreeSql.Dispose();
}
catch
{
// ignore
}
}
}

View File

@@ -0,0 +1,52 @@
using FluentAssertions;
namespace WebApi.Test.Repository;
public class TransactionCategoryRepositoryTest : TransactionTestBase
{
private readonly ITransactionCategoryRepository _repository;
private readonly ITransactionRecordRepository _transactionRepository;
public TransactionCategoryRepositoryTest()
{
_repository = new TransactionCategoryRepository(FreeSql);
_transactionRepository = new TransactionRecordRepository(FreeSql);
}
[Fact]
public async Task GetCategoriesByTypeAsync_按类型获取_Test()
{
await _repository.AddAsync(new TransactionCategory { Name = "C1", Type = TransactionType.Expense });
await _repository.AddAsync(new TransactionCategory { Name = "C2", Type = TransactionType.Income });
var results = await _repository.GetCategoriesByTypeAsync(TransactionType.Expense);
results.Should().HaveCount(1);
results.First().Name.Should().Be("C1");
}
[Fact]
public async Task GetByNameAndTypeAsync_按名称和类型查找_Test()
{
await _repository.AddAsync(new TransactionCategory { Name = "C1", Type = TransactionType.Expense });
var category = await _repository.GetByNameAndTypeAsync("C1", TransactionType.Expense);
category.Should().NotBeNull();
category!.Name.Should().Be("C1");
}
[Fact]
public async Task IsCategoryInUseAsync_检查是否使用_Test()
{
var category = new TransactionCategory { Name = "UsedCategory", Type = TransactionType.Expense };
await _repository.AddAsync(category);
var unused = new TransactionCategory { Name = "Unused", Type = TransactionType.Expense };
await _repository.AddAsync(unused);
// Add transaction using "UsedCategory"
await _transactionRepository.AddAsync(CreateExpense(100, classify: "UsedCategory"));
(await _repository.IsCategoryInUseAsync(category.Id)).Should().BeTrue();
(await _repository.IsCategoryInUseAsync(unused.Id)).Should().BeFalse();
}
}

View File

@@ -0,0 +1,46 @@
using FluentAssertions;
namespace WebApi.Test.Repository;
public class TransactionPeriodicRepositoryTest : TransactionTestBase
{
private readonly ITransactionPeriodicRepository _repository;
public TransactionPeriodicRepositoryTest()
{
_repository = new TransactionPeriodicRepository(FreeSql);
}
[Fact]
public async Task GetPendingPeriodicBillsAsync_获取待执行账单_Test()
{
// 应该执行的NextExecuteTime <= Now
await _repository.AddAsync(new TransactionPeriodic { Reason = "Bill1", NextExecuteTime = DateTime.Now.AddDays(-1), IsEnabled = true });
// 不该执行的NextExecuteTime > Now
await _repository.AddAsync(new TransactionPeriodic { Reason = "Bill2", NextExecuteTime = DateTime.Now.AddDays(1), IsEnabled = true });
// 不该执行的:未激活
await _repository.AddAsync(new TransactionPeriodic { Reason = "Bill3", NextExecuteTime = DateTime.Now.AddDays(-1), IsEnabled = false });
var results = await _repository.GetPendingPeriodicBillsAsync();
results.Should().HaveCount(1);
results.First().Reason.Should().Be("Bill1");
}
[Fact]
public async Task UpdateExecuteTimeAsync_更新执行时间_Test()
{
var bill = new TransactionPeriodic { Reason = "Bill", NextExecuteTime = DateTime.Now };
await _repository.AddAsync(bill);
var last = DateTime.Now;
var next = DateTime.Now.AddMonths(1);
await _repository.UpdateExecuteTimeAsync(bill.Id, last, next);
var updated = await _repository.GetByIdAsync(bill.Id);
updated!.LastExecuteTime.Should().BeCloseTo(last, TimeSpan.FromSeconds(1));
updated.NextExecuteTime.Should().BeCloseTo(next, TimeSpan.FromSeconds(1));
}
}

View File

@@ -0,0 +1,108 @@
using FluentAssertions;
namespace WebApi.Test.Repository;
public class TransactionRecordRepositoryTest : TransactionTestBase
{
private readonly ITransactionRecordRepository _repository;
public TransactionRecordRepositoryTest()
{
_repository = new TransactionRecordRepository(FreeSql);
}
[Fact]
public async Task AddAsync_添加记录_Test()
{
var record = CreateTestRecord(-100);
var result = await _repository.AddAsync(record);
result.Should().BeTrue();
var dbRecord = await _repository.GetByIdAsync(record.Id);
dbRecord.Should().NotBeNull();
dbRecord!.Amount.Should().Be(-100);
}
[Fact]
public async Task QueryAsync_按类型筛选_Test()
{
await _repository.AddAsync(CreateExpense(100));
await _repository.AddAsync(CreateIncome(200));
var expenses = await _repository.QueryAsync(type: TransactionType.Expense);
expenses.Should().HaveCount(1);
expenses.First().Amount.Should().Be(-100);
var incomes = await _repository.QueryAsync(type: TransactionType.Income);
incomes.Should().HaveCount(1);
incomes.First().Amount.Should().Be(200);
}
[Fact]
public async Task QueryAsync_按通过时间范围筛选_Test()
{
await _repository.AddAsync(CreateExpense(100, new DateTime(2023, 1, 1)));
await _repository.AddAsync(CreateExpense(100, new DateTime(2023, 2, 1)));
await _repository.AddAsync(CreateExpense(100, new DateTime(2023, 3, 1)));
// 查询 1月到2月
var results = await _repository.QueryAsync(
startDate: new DateTime(2023, 1, 1),
endDate: new DateTime(2023, 2, 28)); // Include Feb
results.Should().HaveCount(2);
}
[Fact]
public async Task QueryAsync_按年月筛选_Test()
{
await _repository.AddAsync(CreateExpense(100, new DateTime(2023, 1, 15)));
await _repository.AddAsync(CreateExpense(100, new DateTime(2023, 2, 15)));
var results = await _repository.QueryAsync(year: 2023, month: 1);
results.Should().HaveCount(1);
results.First().OccurredAt.Month.Should().Be(1);
}
[Fact]
public async Task CountAsync_统计数量_Test()
{
await _repository.AddAsync(CreateExpense(100));
await _repository.AddAsync(CreateExpense(200));
await _repository.AddAsync(CreateIncome(3000));
var count = await _repository.CountAsync(type: TransactionType.Expense);
count.Should().Be(2);
}
[Fact]
public async Task GetDistinctClassifyAsync_获取去重分类_Test()
{
await _repository.AddAsync(CreateExpense(100, classify: "餐饮"));
await _repository.AddAsync(CreateExpense(100, classify: "餐饮"));
await _repository.AddAsync(CreateExpense(100, classify: "交通"));
var classifies = await _repository.GetDistinctClassifyAsync();
classifies.Should().HaveCount(2);
classifies.Should().Contain("餐饮");
classifies.Should().Contain("交通");
}
[Fact]
public async Task BatchUpdateByReasonAsync_批量更新_Test()
{
await _repository.AddAsync(CreateExpense(100, reason: "麦当劳", classify: "餐饮"));
await _repository.AddAsync(CreateExpense(100, reason: "麦当劳", classify: "餐饮"));
await _repository.AddAsync(CreateExpense(100, reason: "肯德基", classify: "餐饮"));
// 将所有"麦当劳"改为"快餐"分类,类型保持支出
var count = await _repository.BatchUpdateByReasonAsync("麦当劳", TransactionType.Expense, "快餐");
count.Should().Be(2);
var records = await _repository.QueryAsync(reason: "麦当劳");
records.All(r => r.Classify == "快餐").Should().BeTrue();
var kfc = await _repository.QueryAsync(reason: "肯德基");
kfc.First().Classify.Should().Be("餐饮");
}
}

View File

@@ -1,29 +1,7 @@
using FreeSql;
using Repository;
namespace WebApi.Test.Repository;
namespace WebApi.Test.Basic;
public class DatabaseTest : BaseTest, IDisposable
public class TransactionTestBase : RepositoryTestBase
{
protected IFreeSql FreeSql { get; }
protected ITransactionRecordRepository Repository { get; }
public DatabaseTest()
{
FreeSql = new FreeSqlBuilder()
.UseConnectionString(DataType.Sqlite, "Data Source=:memory:")
.UseAutoSyncStructure(true)
.UseNoneCommandParameter(true)
.Build();
Repository = new TransactionRecordRepository(FreeSql);
}
public void Dispose()
{
FreeSql.Dispose();
}
protected TransactionRecord CreateTestRecord(
decimal amount,
TransactionType type = TransactionType.Expense,
@@ -55,4 +33,4 @@ public class DatabaseTest : BaseTest, IDisposable
{
return CreateTestRecord(amount, TransactionType.Income, occurredAt, reason, classify);
}
}
}

View File

@@ -0,0 +1,608 @@
using Microsoft.Extensions.Logging;
using Service.Transaction;
namespace WebApi.Test.Transaction;
public class TransactionPeriodicServiceTest : BaseTest
{
private readonly ITransactionPeriodicRepository _periodicRepository = Substitute.For<ITransactionPeriodicRepository>();
private readonly ITransactionRecordRepository _transactionRepository = Substitute.For<ITransactionRecordRepository>();
private readonly IMessageRecordRepository _messageRepository = Substitute.For<IMessageRecordRepository>();
private readonly ILogger<TransactionPeriodicService> _logger = Substitute.For<ILogger<TransactionPeriodicService>>();
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<TransactionRecord>()).Returns(Task.FromResult(true));
_messageRepository.AddAsync(Arg.Any<MessageRecord>()).Returns(Task.CompletedTask);
_periodicRepository.UpdateExecuteTimeAsync(Arg.Any<long>(), Arg.Any<DateTime>(), Arg.Any<DateTime>())
.Returns(Task.CompletedTask);
// Act
await _service.ExecutePeriodicBillsAsync();
// Assert
await _transactionRepository.Received(1).AddAsync(Arg.Is<TransactionRecord>(t =>
t.Amount == -100m &&
t.Type == TransactionType.Expense &&
t.Classify == "餐饮" &&
t.Reason == "每日餐费" &&
t.Card == "周期性账单" &&
t.ImportFrom == "周期性账单自动生成"
));
await _messageRepository.Received(1).AddAsync(Arg.Is<MessageRecord>(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<DateTime>(dt => dt.Date == today.Date),
Arg.Is<DateTime?>(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<TransactionRecord>()).Returns(Task.FromResult(true));
_messageRepository.AddAsync(Arg.Any<MessageRecord>()).Returns(Task.CompletedTask);
_periodicRepository.UpdateExecuteTimeAsync(Arg.Any<long>(), Arg.Any<DateTime>(), Arg.Any<DateTime>())
.Returns(Task.CompletedTask);
// Act
await _service.ExecutePeriodicBillsAsync();
// Assert
await _transactionRepository.Received(1).AddAsync(Arg.Is<TransactionRecord>(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<TransactionRecord>()).Returns(Task.FromResult(true));
_messageRepository.AddAsync(Arg.Any<MessageRecord>()).Returns(Task.CompletedTask);
_periodicRepository.UpdateExecuteTimeAsync(Arg.Any<long>(), Arg.Any<DateTime>(), Arg.Any<DateTime>())
.Returns(Task.CompletedTask);
// Act
await _service.ExecutePeriodicBillsAsync();
// Assert
await _transactionRepository.Received(1).AddAsync(Arg.Is<TransactionRecord>(t =>
t.Amount == 5000m &&
t.Type == TransactionType.Income &&
t.Classify == "工资" &&
t.Reason == "每月工资"
));
await _messageRepository.Received(1).AddAsync(Arg.Is<MessageRecord>(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<TransactionRecord>());
await _messageRepository.Received(0).AddAsync(Arg.Any<MessageRecord>());
await _periodicRepository.Received(0).UpdateExecuteTimeAsync(Arg.Any<long>(), Arg.Any<DateTime>(), Arg.Any<DateTime>());
}
[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<TransactionRecord>());
await _messageRepository.Received(0).AddAsync(Arg.Any<MessageRecord>());
await _periodicRepository.Received(0).UpdateExecuteTimeAsync(Arg.Any<long>(), Arg.Any<DateTime>(), Arg.Any<DateTime>());
}
[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<TransactionRecord>()).Returns(Task.FromResult(true));
_messageRepository.AddAsync(Arg.Any<MessageRecord>()).Returns(Task.CompletedTask);
_periodicRepository.UpdateExecuteTimeAsync(Arg.Any<long>(), Arg.Any<DateTime>(), Arg.Any<DateTime>())
.Returns(Task.CompletedTask);
// Act
await _service.ExecutePeriodicBillsAsync();
// Assert
await _transactionRepository.Received(1).AddAsync(Arg.Any<TransactionRecord>());
}
[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<TransactionRecord>()).Returns(false); // 添加失败
// Act
await _service.ExecutePeriodicBillsAsync();
// Assert
await _messageRepository.Received(0).AddAsync(Arg.Any<MessageRecord>());
await _periodicRepository.Received(0).UpdateExecuteTimeAsync(Arg.Any<long>(), Arg.Any<DateTime>(), Arg.Any<DateTime>());
}
[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<TransactionRecord>()).Returns(Task.FromResult(true));
_messageRepository.AddAsync(Arg.Any<MessageRecord>()).Returns(Task.CompletedTask);
_periodicRepository.UpdateExecuteTimeAsync(Arg.Any<long>(), Arg.Any<DateTime>(), Arg.Any<DateTime>())
.Returns(Task.CompletedTask);
// Act
await _service.ExecutePeriodicBillsAsync();
// Assert
await _transactionRepository.Received(2).AddAsync(Arg.Any<TransactionRecord>());
await _messageRepository.Received(2).AddAsync(Arg.Any<MessageRecord>());
await _periodicRepository.Received(2).UpdateExecuteTimeAsync(Arg.Any<long>(), Arg.Any<DateTime>(), Arg.Any<DateTime>());
}
[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<TransactionRecord>()).Returns(Task.FromResult(true));
_messageRepository.AddAsync(Arg.Any<MessageRecord>()).Returns(Task.CompletedTask);
_periodicRepository.UpdateExecuteTimeAsync(Arg.Any<long>(), Arg.Any<DateTime>(), Arg.Any<DateTime>())
.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<TransactionRecord>());
}
[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<TransactionRecord>()).Returns(Task.FromResult(true));
_messageRepository.AddAsync(Arg.Any<MessageRecord>()).Returns(Task.CompletedTask);
_periodicRepository.UpdateExecuteTimeAsync(Arg.Any<long>(), Arg.Any<DateTime>(), Arg.Any<DateTime>())
.Returns(Task.CompletedTask);
// Act
await _service.ExecutePeriodicBillsAsync();
// Assert
// 只有启用的账单会被处理
await _transactionRepository.Received(1).AddAsync(Arg.Is<TransactionRecord>(t => t.Reason == "每日交通"));
await _transactionRepository.Received(0).AddAsync(Arg.Is<TransactionRecord>(t => t.Reason == "每日餐费"));
}
}

View File

@@ -0,0 +1,972 @@
using Service.Transaction;
namespace WebApi.Test.Transaction;
public class TransactionStatisticsServiceTest : BaseTest
{
private readonly ITransactionRecordRepository _transactionRepository = Substitute.For<ITransactionRecordRepository>();
private readonly ITransactionStatisticsService _service;
public TransactionStatisticsServiceTest()
{
// 默认配置 QueryAsync 返回空列表
_transactionRepository.QueryAsync(
Arg.Any<int>(),
Arg.Any<int>(),
Arg.Any<DateTime>(),
Arg.Any<DateTime>(),
Arg.Any<TransactionType>(),
Arg.Any<string[]>(),
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<int>(),
Arg.Any<int>(),
Arg.Any<bool>())
.ReturnsForAnyArgs(new List<TransactionRecord>());
_service = new TransactionStatisticsService(
_transactionRepository
);
}
private void ConfigureQueryAsync(List<TransactionRecord> data)
{
_transactionRepository.QueryAsync(
Arg.Any<int>(),
Arg.Any<int>(),
Arg.Any<DateTime>(),
Arg.Any<DateTime>(),
Arg.Any<TransactionType>(),
Arg.Any<string[]>(),
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<int>(),
Arg.Any<int>(),
Arg.Any<bool>())
.ReturnsForAnyArgs(data);
}
[Fact]
public async Task GetDailyStatisticsAsync_基本测试()
{
// Arrange
var year = 2024;
var month = 1;
var testData = new List<TransactionRecord>
{
new()
{
Id = 1,
OccurredAt = new DateTime(2024, 1, 1, 10, 0, 0),
Amount = -100m,
Type = TransactionType.Expense,
Classify = "餐饮",
Reason = "午餐"
},
new()
{
Id = 2,
OccurredAt = new DateTime(2024, 1, 1, 15, 0, 0),
Amount = -50m,
Type = TransactionType.Expense,
Classify = "交通",
Reason = "地铁"
},
new()
{
Id = 3,
OccurredAt = new DateTime(2024, 1, 2, 9, 0, 0),
Amount = 5000m,
Type = TransactionType.Income,
Classify = "工资",
Reason = "工资收入"
}
};
ConfigureQueryAsync(testData);
// Act
var result = await _service.GetDailyStatisticsAsync(year, month);
// Assert
result.Should().HaveCount(2);
result.Should().ContainKey("2024-01-01");
result.Should().ContainKey("2024-01-02");
result["2024-01-01"].count.Should().Be(2);
result["2024-01-01"].expense.Should().Be(150m);
result["2024-01-01"].income.Should().Be(0m);
result["2024-01-02"].count.Should().Be(1);
result["2024-01-02"].expense.Should().Be(0m);
result["2024-01-02"].income.Should().Be(5000m);
}
[Fact]
public async Task GetDailyStatisticsAsync_带储蓄分类()
{
// Arrange
var year = 2024;
var month = 1;
var savingClassify = "投资,存款";
var testData = new List<TransactionRecord>
{
new()
{
Id = 1,
OccurredAt = new DateTime(2024, 1, 1, 10, 0, 0),
Amount = -100m,
Type = TransactionType.Expense,
Classify = "餐饮",
Reason = "午餐"
},
new()
{
Id = 2,
OccurredAt = new DateTime(2024, 1, 1, 15, 0, 0),
Amount = -1000m,
Type = TransactionType.Expense,
Classify = "投资",
Reason = "基金定投"
},
new()
{
Id = 3,
OccurredAt = new DateTime(2024, 1, 2, 9, 0, 0),
Amount = -500m,
Type = TransactionType.Expense,
Classify = "存款",
Reason = "银行存款"
}
};
_transactionRepository.QueryAsync(
Arg.Any<int>(),
Arg.Any<int>(),
Arg.Any<DateTime>(),
Arg.Any<DateTime>(),
Arg.Any<TransactionType>(),
Arg.Any<string[]>(),
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<int>(),
Arg.Any<int>(),
Arg.Any<bool>())
.ReturnsForAnyArgs(testData);
// Act
var result = await _service.GetDailyStatisticsAsync(year, month, savingClassify);
// Assert
result.Should().HaveCount(2);
result["2024-01-01"].count.Should().Be(2);
result["2024-01-01"].expense.Should().Be(1100m);
result["2024-01-01"].income.Should().Be(0m);
result["2024-01-01"].saving.Should().Be(1000m);
result["2024-01-02"].count.Should().Be(1);
result["2024-01-02"].expense.Should().Be(500m);
result["2024-01-02"].income.Should().Be(0m);
result["2024-01-02"].saving.Should().Be(500m);
}
[Fact]
public async Task GetDailyStatisticsByRangeAsync_基本测试()
{
// Arrange
var startDate = new DateTime(2024, 1, 1);
var endDate = new DateTime(2024, 1, 5);
var testData = new List<TransactionRecord>
{
new()
{
Id = 1,
OccurredAt = new DateTime(2024, 1, 3, 10, 0, 0),
Amount = -100m,
Type = TransactionType.Expense,
Classify = "餐饮",
Reason = "午餐"
}
};
_transactionRepository.QueryAsync(
Arg.Any<int>(),
Arg.Any<int>(),
Arg.Any<DateTime>(),
Arg.Any<DateTime>(),
Arg.Any<TransactionType>(),
Arg.Any<string[]>(),
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<int>(),
Arg.Any<int>(),
Arg.Any<bool>())
.ReturnsForAnyArgs(testData);
// Act
var result = await _service.GetDailyStatisticsByRangeAsync(startDate, endDate);
// Assert
result.Should().HaveCount(1);
result.Should().ContainKey("2024-01-03");
result["2024-01-03"].count.Should().Be(1);
result["2024-01-03"].expense.Should().Be(100m);
}
[Fact]
public async Task GetMonthlyStatisticsAsync_基本测试()
{
// Arrange
var year = 2024;
var month = 1;
var testData = new List<TransactionRecord>
{
new()
{
Id = 1,
OccurredAt = new DateTime(2024, 1, 1, 10, 0, 0),
Amount = -100m,
Type = TransactionType.Expense,
Classify = "餐饮",
Reason = "午餐"
},
new()
{
Id = 2,
OccurredAt = new DateTime(2024, 1, 2, 15, 0, 0),
Amount = -50m,
Type = TransactionType.Expense,
Classify = "交通",
Reason = "地铁"
},
new()
{
Id = 3,
OccurredAt = new DateTime(2024, 1, 5, 9, 0, 0),
Amount = 5000m,
Type = TransactionType.Income,
Classify = "工资",
Reason = "工资收入"
},
new()
{
Id = 4,
OccurredAt = new DateTime(2024, 1, 10, 9, 0, 0),
Amount = 2000m,
Type = TransactionType.Income,
Classify = "奖金",
Reason = "奖金收入"
}
};
_transactionRepository.QueryAsync(
year,
month,
Arg.Any<DateTime>(),
Arg.Any<DateTime>(),
Arg.Any<TransactionType>(),
Arg.Any<string[]>(),
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<int>(),
Arg.Any<int>(),
Arg.Any<bool>())
.ReturnsForAnyArgs(testData);
// Act
var result = await _service.GetMonthlyStatisticsAsync(year, month);
// Assert
result.Year.Should().Be(year);
result.Month.Should().Be(month);
result.TotalExpense.Should().Be(150m);
result.TotalIncome.Should().Be(7000m);
result.Balance.Should().Be(6850m);
result.ExpenseCount.Should().Be(2);
result.IncomeCount.Should().Be(2);
result.TotalCount.Should().Be(4);
}
[Fact]
public async Task GetMonthlyStatisticsAsync_无数据()
{
// Arrange
var year = 2024;
var month = 2;
_transactionRepository.QueryAsync(
year,
month,
Arg.Any<DateTime>(),
Arg.Any<DateTime>(),
Arg.Any<TransactionType>(),
Arg.Any<string[]>(),
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<int>(),
Arg.Any<int>(),
Arg.Any<bool>())
.Returns(new List<TransactionRecord>());
// Act
var result = await _service.GetMonthlyStatisticsAsync(year, month);
// Assert
result.Year.Should().Be(year);
result.Month.Should().Be(month);
result.TotalExpense.Should().Be(0m);
result.TotalIncome.Should().Be(0m);
result.Balance.Should().Be(0m);
result.ExpenseCount.Should().Be(0);
result.IncomeCount.Should().Be(0);
result.TotalCount.Should().Be(0);
}
[Fact]
public async Task GetCategoryStatisticsAsync_支出分类()
{
// Arrange
var year = 2024;
var month = 1;
var type = TransactionType.Expense;
var testData = new List<TransactionRecord>
{
new()
{
Id = 1,
OccurredAt = new DateTime(2024, 1, 1, 10, 0, 0),
Amount = -100m,
Type = TransactionType.Expense,
Classify = "餐饮",
Reason = "午餐"
},
new()
{
Id = 2,
OccurredAt = new DateTime(2024, 1, 2, 15, 0, 0),
Amount = -50m,
Type = TransactionType.Expense,
Classify = "餐饮",
Reason = "晚餐"
},
new()
{
Id = 3,
OccurredAt = new DateTime(2024, 1, 3, 9, 0, 0),
Amount = -200m,
Type = TransactionType.Expense,
Classify = "交通",
Reason = "打车"
},
new()
{
Id = 4,
OccurredAt = new DateTime(2024, 1, 5, 9, 0, 0),
Amount = 5000m,
Type = TransactionType.Income,
Classify = "工资",
Reason = "工资收入"
}
};
_transactionRepository.QueryAsync(
year,
month,
Arg.Any<DateTime>(),
Arg.Any<DateTime>(),
type,
Arg.Any<string[]>(),
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<int>(),
Arg.Any<int>(),
Arg.Any<bool>())
.ReturnsForAnyArgs(testData);
// Act
var result = await _service.GetCategoryStatisticsAsync(year, month, type);
// Assert
result.Should().HaveCount(2);
var dining = result.First(c => c.Classify == "餐饮");
dining.Amount.Should().Be(150m);
dining.Count.Should().Be(2);
dining.Percent.Should().Be(42.9m);
var transport = result.First(c => c.Classify == "交通");
transport.Amount.Should().Be(200m);
transport.Count.Should().Be(1);
transport.Percent.Should().Be(57.1m);
}
[Fact]
public async Task GetCategoryStatisticsAsync_收入分类()
{
// Arrange
var year = 2024;
var month = 1;
var type = TransactionType.Income;
var testData = new List<TransactionRecord>
{
new()
{
Id = 1,
OccurredAt = new DateTime(2024, 1, 1, 10, 0, 0),
Amount = 5000m,
Type = TransactionType.Income,
Classify = "工资",
Reason = "工资收入"
},
new()
{
Id = 2,
OccurredAt = new DateTime(2024, 1, 2, 15, 0, 0),
Amount = 1000m,
Type = TransactionType.Income,
Classify = "奖金",
Reason = "绩效奖金"
},
new()
{
Id = 3,
OccurredAt = new DateTime(2024, 1, 3, 9, 0, 0),
Amount = -100m,
Type = TransactionType.Expense,
Classify = "餐饮",
Reason = "午餐"
}
};
_transactionRepository.QueryAsync(
year,
month,
Arg.Any<DateTime>(),
Arg.Any<DateTime>(),
type,
Arg.Any<string[]>(),
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<int>(),
Arg.Any<int>(),
Arg.Any<bool>())
.ReturnsForAnyArgs(testData);
// Act
var result = await _service.GetCategoryStatisticsAsync(year, month, type);
// Assert
result.Should().HaveCount(2);
var salary = result.First(c => c.Classify == "工资");
salary.Amount.Should().Be(5000m);
salary.Count.Should().Be(1);
salary.Percent.Should().Be(83.3m);
var bonus = result.First(c => c.Classify == "奖金");
bonus.Amount.Should().Be(1000m);
bonus.Count.Should().Be(1);
bonus.Percent.Should().Be(16.7m);
}
[Fact]
public async Task GetTrendStatisticsAsync_多个月份()
{
// Arrange
var startYear = 2024;
var startMonth = 1;
var monthCount = 3;
var mockData = new Dictionary<int, List<TransactionRecord>>
{
[1] = new List<TransactionRecord>
{
new() { Id = 1, OccurredAt = new DateTime(2024, 1, 1), Amount = -1000m, Type = TransactionType.Expense },
new() { Id = 2, OccurredAt = new DateTime(2024, 1, 5), Amount = 5000m, Type = TransactionType.Income }
},
[2] = new List<TransactionRecord>
{
new() { Id = 3, OccurredAt = new DateTime(2024, 2, 1), Amount = -1500m, Type = TransactionType.Expense },
new() { Id = 4, OccurredAt = new DateTime(2024, 2, 5), Amount = 5000m, Type = TransactionType.Income }
},
[3] = new List<TransactionRecord>
{
new() { Id = 5, OccurredAt = new DateTime(2024, 3, 1), Amount = -2000m, Type = TransactionType.Expense },
new() { Id = 6, OccurredAt = new DateTime(2024, 3, 5), Amount = 5000m, Type = TransactionType.Income }
}
};
_transactionRepository.QueryAsync(Arg.Any<int>(), Arg.Any<int>(), Arg.Any<DateTime>(), Arg.Any<DateTime>(), Arg.Any<TransactionType>(), Arg.Any<string[]>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<int>(), Arg.Any<int>(), Arg.Any<bool>())
.Returns(args =>
{
var month = (int)args[1];
if (mockData.ContainsKey(month))
{
return mockData[month];
}
return new List<TransactionRecord>();
});
// Act
var result = await _service.GetTrendStatisticsAsync(startYear, startMonth, monthCount);
// Assert
result.Should().HaveCount(3);
result[0].Year.Should().Be(2024);
result[0].Month.Should().Be(1);
result[0].Expense.Should().Be(1000m);
result[0].Income.Should().Be(5000m);
result[0].Balance.Should().Be(4000m);
result[1].Year.Should().Be(2024);
result[1].Month.Should().Be(2);
result[1].Expense.Should().Be(1500m);
result[1].Income.Should().Be(5000m);
result[1].Balance.Should().Be(3500m);
result[2].Year.Should().Be(2024);
result[2].Month.Should().Be(3);
result[2].Expense.Should().Be(2000m);
result[2].Income.Should().Be(5000m);
result[2].Balance.Should().Be(3000m);
}
[Fact]
public async Task GetTrendStatisticsAsync_跨年()
{
// Arrange
var startYear = 2024;
var startMonth = 11;
var monthCount = 4;
_transactionRepository.QueryAsync(Arg.Any<int>(), Arg.Any<int>(), Arg.Any<DateTime>(), Arg.Any<DateTime>(), Arg.Any<TransactionType>(), Arg.Any<string[]>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<int>(), Arg.Any<int>(), Arg.Any<bool>())
.Returns(new List<TransactionRecord>());
// Act
var result = await _service.GetTrendStatisticsAsync(startYear, startMonth, monthCount);
// Assert
result.Should().HaveCount(4);
result[0].Year.Should().Be(2024);
result[0].Month.Should().Be(11);
result[1].Year.Should().Be(2024);
result[1].Month.Should().Be(12);
result[2].Year.Should().Be(2025);
result[2].Month.Should().Be(1);
result[3].Year.Should().Be(2025);
result[3].Month.Should().Be(2);
}
[Fact]
public async Task GetReasonGroupsAsync_基本测试()
{
// Arrange
var testData = new List<TransactionRecord>
{
new()
{
Id = 1,
Reason = "麦当劳",
Classify = "",
Amount = -50m,
Type = TransactionType.Expense,
OccurredAt = new DateTime(2024, 1, 1)
},
new()
{
Id = 2,
Reason = "麦当劳",
Classify = "",
Amount = -80m,
Type = TransactionType.Expense,
OccurredAt = new DateTime(2024, 1, 2)
},
new()
{
Id = 3,
Reason = "肯德基",
Classify = "",
Amount = -60m,
Type = TransactionType.Expense,
OccurredAt = new DateTime(2024, 1, 3)
},
new()
{
Id = 4,
Reason = "麦当劳",
Classify = "快餐",
Amount = -45m,
Type = TransactionType.Expense,
OccurredAt = new DateTime(2024, 1, 4)
}
};
_transactionRepository.QueryAsync(
Arg.Any<int>(),
Arg.Any<int>(),
Arg.Any<DateTime>(),
Arg.Any<DateTime>(),
Arg.Any<TransactionType>(),
Arg.Any<string[]>(),
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<int>(),
Arg.Any<int>(),
Arg.Any<bool>())
.ReturnsForAnyArgs(testData);
// Act
var (list, total) = await _service.GetReasonGroupsAsync();
// Assert
total.Should().Be(2);
list.Should().HaveCount(2);
var mcdonalds = list.First(g => g.Reason == "麦当劳");
mcdonalds.Count.Should().Be(2);
mcdonalds.TotalAmount.Should().Be(130m);
mcdonalds.SampleType.Should().Be(TransactionType.Expense);
mcdonalds.SampleClassify.Should().Be("");
mcdonalds.TransactionIds.Should().Contain(1L);
mcdonalds.TransactionIds.Should().Contain(2L);
var kfc = list.First(g => g.Reason == "肯德基");
kfc.Count.Should().Be(1);
kfc.TotalAmount.Should().Be(60m);
}
[Fact]
public async Task GetClassifiedByKeywordsWithScoreAsync_基本匹配()
{
// Arrange
var keywords = new List<string> { "餐饮", "午餐" };
var testData = new List<TransactionRecord>
{
new()
{
Id = 1,
Reason = "今天午餐吃得很饱",
Classify = "餐饮",
OccurredAt = new DateTime(2024, 1, 1),
Amount = -50m
},
new()
{
Id = 2,
Reason = "餐饮支出",
Classify = "餐饮",
OccurredAt = new DateTime(2024, 1, 2),
Amount = -80m
},
new()
{
Id = 3,
Reason = "交通费",
Classify = "交通",
OccurredAt = new DateTime(2024, 1, 3),
Amount = -10m
}
};
_transactionRepository.GetClassifiedByKeywordsAsync(Arg.Any<List<string>>(), Arg.Any<int>())
.ReturnsForAnyArgs(testData);
// Act
var result = await _service.GetClassifiedByKeywordsWithScoreAsync(keywords, minMatchRate: 0.3, limit: 10);
// Assert
result.Should().HaveCount(2);
var first = result[0];
// 第一个结果应该是相关性分数最高的,可能是 Id=2"今天午餐吃得很饱"匹配两个关键词)
first.record.Id.Should().BeOneOf(1L, 2L);
first.relevanceScore.Should().BeGreaterThan(0.5);
var second = result[1];
second.record.Id.Should().BeOneOf(1L, 2L);
second.record.Id.Should().NotBe(first.record.Id);
second.relevanceScore.Should().BeGreaterThan(0.3);
}
[Fact]
public async Task GetClassifiedByKeywordsWithScoreAsync_精确匹配加分()
{
// Arrange
var keywords = new List<string> { "午餐" };
var testData = new List<TransactionRecord>
{
new()
{
Id = 1,
Reason = "午餐",
Classify = "餐饮",
OccurredAt = new DateTime(2024, 1, 1),
Amount = -50m
},
new()
{
Id = 2,
Reason = "今天中午吃了一顿午餐",
Classify = "餐饮",
OccurredAt = new DateTime(2024, 1, 2),
Amount = -80m
}
};
_transactionRepository.GetClassifiedByKeywordsAsync(Arg.Any<List<string>>(), Arg.Any<int>())
.ReturnsForAnyArgs(testData);
// Act
var result = await _service.GetClassifiedByKeywordsWithScoreAsync(keywords, minMatchRate: 0.3, limit: 10);
// Assert
result.Should().HaveCount(2);
// 精确匹配应该得分更高
result[0].record.Id.Should().Be(1);
result[0].relevanceScore.Should().BeGreaterThan(result[1].relevanceScore);
}
[Fact]
public async Task GetFilteredTrendStatisticsAsync_按日分组()
{
// Arrange
var startDate = new DateTime(2024, 1, 1);
var endDate = new DateTime(2024, 1, 5);
var type = TransactionType.Expense;
var classifies = new[] { "餐饮", "交通" };
var testData = new List<TransactionRecord>
{
new()
{
Id = 1,
OccurredAt = new DateTime(2024, 1, 1, 10, 0, 0),
Amount = -100m,
Type = TransactionType.Expense,
Classify = "餐饮"
},
new()
{
Id = 2,
OccurredAt = new DateTime(2024, 1, 1, 15, 0, 0),
Amount = -50m,
Type = TransactionType.Expense,
Classify = "交通"
},
new()
{
Id = 3,
OccurredAt = new DateTime(2024, 1, 2, 10, 0, 0),
Amount = -80m,
Type = TransactionType.Expense,
Classify = "餐饮"
}
};
_transactionRepository.QueryAsync(
Arg.Any<int>(),
Arg.Any<int>(),
startDate,
endDate,
type,
classifies,
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<int>(),
Arg.Any<int>(),
Arg.Any<bool>())
.ReturnsForAnyArgs(testData);
// Act
var result = await _service.GetFilteredTrendStatisticsAsync(startDate, endDate, type, classifies, groupByMonth: false);
// Assert
result.Should().HaveCount(2);
result.Should().ContainKey(new DateTime(2024, 1, 1));
result.Should().ContainKey(new DateTime(2024, 1, 2));
result[new DateTime(2024, 1, 1)].Should().Be(150m);
result[new DateTime(2024, 1, 2)].Should().Be(80m);
}
[Fact]
public async Task GetFilteredTrendStatisticsAsync_按月分组()
{
// Arrange
var startDate = new DateTime(2024, 1, 1);
var endDate = new DateTime(2024, 3, 31);
var type = TransactionType.Expense;
var classifies = new[] { "餐饮" };
var testData = new List<TransactionRecord>
{
new()
{
Id = 1,
OccurredAt = new DateTime(2024, 1, 15),
Amount = -1000m,
Type = TransactionType.Expense,
Classify = "餐饮"
},
new()
{
Id = 2,
OccurredAt = new DateTime(2024, 2, 15),
Amount = -1500m,
Type = TransactionType.Expense,
Classify = "餐饮"
},
new()
{
Id = 3,
OccurredAt = new DateTime(2024, 3, 15),
Amount = -2000m,
Type = TransactionType.Expense,
Classify = "餐饮"
}
};
_transactionRepository.QueryAsync(
Arg.Any<int>(),
Arg.Any<int>(),
startDate,
endDate,
type,
classifies,
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<int>(),
Arg.Any<int>(),
Arg.Any<bool>())
.ReturnsForAnyArgs(testData);
// Act
var result = await _service.GetFilteredTrendStatisticsAsync(startDate, endDate, type, classifies, groupByMonth: true);
// Assert
result.Should().HaveCount(3);
result.Should().ContainKey(new DateTime(2024, 1, 1));
result.Should().ContainKey(new DateTime(2024, 2, 1));
result.Should().ContainKey(new DateTime(2024, 3, 1));
result[new DateTime(2024, 1, 1)].Should().Be(1000m);
result[new DateTime(2024, 2, 1)].Should().Be(1500m);
result[new DateTime(2024, 3, 1)].Should().Be(2000m);
}
[Fact]
public async Task GetAmountGroupByClassifyAsync_基本测试()
{
// Arrange
var startTime = new DateTime(2024, 1, 1);
var endTime = new DateTime(2024, 1, 31);
var testData = new List<TransactionRecord>
{
new()
{
Id = 1,
Amount = -100m,
Type = TransactionType.Expense,
Classify = "餐饮"
},
new()
{
Id = 2,
Amount = -50m,
Type = TransactionType.Expense,
Classify = "餐饮"
},
new()
{
Id = 3,
Amount = 5000m,
Type = TransactionType.Income,
Classify = "工资"
},
new()
{
Id = 4,
Amount = -200m,
Type = TransactionType.Expense,
Classify = "交通"
}
};
_transactionRepository.QueryAsync(
Arg.Any<int>(),
Arg.Any<int>(),
startTime,
endTime,
Arg.Any<TransactionType>(),
Arg.Any<string[]>(),
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<int>(),
Arg.Any<int>(),
Arg.Any<bool>())
.ReturnsForAnyArgs(testData);
// Act
var result = await _service.GetAmountGroupByClassifyAsync(startTime, endTime);
// Assert
result.Should().HaveCount(3);
result[("餐饮", TransactionType.Expense)].Should().Be(-150m);
result[("工资", TransactionType.Income)].Should().Be(5000m);
result[("交通", TransactionType.Expense)].Should().Be(-200m);
}
[Fact]
public async Task GetAmountGroupByClassifyAsync_相同分类不同类型()
{
// Arrange
var startTime = new DateTime(2024, 1, 1);
var endTime = new DateTime(2024, 1, 31);
var testData = new List<TransactionRecord>
{
new()
{
Id = 1,
Amount = -100m,
Type = TransactionType.Expense,
Classify = "兼职"
},
new()
{
Id = 2,
Amount = 500m,
Type = TransactionType.Income,
Classify = "兼职"
}
};
_transactionRepository.QueryAsync(
Arg.Any<int>(),
Arg.Any<int>(),
startTime,
endTime,
Arg.Any<TransactionType>(),
Arg.Any<string[]>(),
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<int>(),
Arg.Any<int>(),
Arg.Any<bool>())
.ReturnsForAnyArgs(testData);
// Act
var result = await _service.GetAmountGroupByClassifyAsync(startTime, endTime);
// Assert
result.Should().HaveCount(2);
result[("兼职", TransactionType.Expense)].Should().Be(-100m);
result[("兼职", TransactionType.Income)].Should().Be(500m);
}
}