using Application.Dto.Transaction; using Service.AI; namespace WebApi.Test.Application; /// /// TransactionApplication 单元测试 /// public class TransactionApplicationTest : BaseApplicationTest { private readonly ITransactionRecordRepository _transactionRepository; private readonly ISmartHandleService _smartHandleService; private readonly TransactionApplication _application; public TransactionApplicationTest() { _transactionRepository = Substitute.For(); _smartHandleService = Substitute.For(); _application = new TransactionApplication(_transactionRepository, _smartHandleService); } #region GetByIdAsync Tests [Fact] public async Task GetByIdAsync_存在的记录_应返回交易详情() { // Arrange var record = new TransactionRecord { Id = 1, Reason = "测试交易", Amount = 100, Type = TransactionType.Expense }; _transactionRepository.GetByIdAsync(1).Returns(record); // Act var result = await _application.GetByIdAsync(1); // Assert result.Should().NotBeNull(); result.Id.Should().Be(1); result.Reason.Should().Be("测试交易"); } [Fact] public async Task GetByIdAsync_不存在的记录_应抛出NotFoundException() { // Arrange _transactionRepository.GetByIdAsync(999).Returns((TransactionRecord?)null); // Act & Assert await Assert.ThrowsAsync(() => _application.GetByIdAsync(999)); } #endregion #region CreateAsync Tests [Fact] public async Task CreateAsync_有效请求_应成功创建() { // Arrange var request = new CreateTransactionRequest { OccurredAt = "2026-02-10", Reason = "测试支出", Amount = 100, Type = TransactionType.Expense, Classify = "餐饮" }; _transactionRepository.AddAsync(Arg.Any()).Returns(true); // Act await _application.CreateAsync(request); // Assert await _transactionRepository.Received(1).AddAsync(Arg.Is( t => t.Reason == "测试支出" && t.Amount == 100 )); } [Fact] public async Task CreateAsync_无效日期格式_应抛出ValidationException() { // Arrange var request = new CreateTransactionRequest { OccurredAt = "invalid-date", Reason = "测试", Amount = 100, Type = TransactionType.Expense }; // Act & Assert await Assert.ThrowsAsync(() => _application.CreateAsync(request)); } [Fact] public async Task CreateAsync_Repository添加失败_应抛出BusinessException() { // Arrange var request = new CreateTransactionRequest { OccurredAt = "2026-02-10", Reason = "测试", Amount = 100, Type = TransactionType.Expense }; _transactionRepository.AddAsync(Arg.Any()).Returns(false); // Act & Assert await Assert.ThrowsAsync(() => _application.CreateAsync(request)); } #endregion #region UpdateAsync Tests [Fact] public async Task UpdateAsync_有效请求_应成功更新() { // Arrange var existingRecord = new TransactionRecord { Id = 1, Reason = "旧原因", Amount = 50 }; _transactionRepository.GetByIdAsync(1).Returns(existingRecord); _transactionRepository.UpdateAsync(Arg.Any()).Returns(true); var request = new UpdateTransactionRequest { Id = 1, Reason = "新原因", Amount = 100, Balance = 0, Type = TransactionType.Expense, Classify = "餐饮" }; // Act await _application.UpdateAsync(request); // Assert await _transactionRepository.Received(1).UpdateAsync(Arg.Is( t => t.Id == 1 && t.Reason == "新原因" && t.Amount == 100 )); } [Fact] public async Task UpdateAsync_记录不存在_应抛出NotFoundException() { // Arrange _transactionRepository.GetByIdAsync(999).Returns((TransactionRecord?)null); var request = new UpdateTransactionRequest { Id = 999, Amount = 100, Balance = 0, Type = TransactionType.Expense }; // Act & Assert await Assert.ThrowsAsync(() => _application.UpdateAsync(request)); } #endregion #region DeleteByIdAsync Tests [Fact] public async Task DeleteByIdAsync_成功删除_不应抛出异常() { // Arrange _transactionRepository.DeleteAsync(1).Returns(true); // Act await _application.DeleteByIdAsync(1); // Assert await _transactionRepository.Received(1).DeleteAsync(1); } [Fact] public async Task DeleteByIdAsync_删除失败_应抛出BusinessException() { // Arrange _transactionRepository.DeleteAsync(999).Returns(false); // Act & Assert await Assert.ThrowsAsync(() => _application.DeleteByIdAsync(999)); } #endregion #region GetListAsync Tests [Fact] public async Task GetListAsync_基本查询_应返回分页结果() { // Arrange var request = new TransactionQueryRequest { PageIndex = 1, PageSize = 10 }; var transactions = new List { new() { Id = 1, Reason = "测试1", Amount = 100, Type = TransactionType.Expense }, new() { Id = 2, Reason = "测试2", Amount = 200, Type = TransactionType.Income } }; _transactionRepository.QueryAsync( pageIndex: 1, pageSize: 10).Returns(transactions); _transactionRepository.CountAsync().Returns(2); // Act var result = await _application.GetListAsync(request); // Assert result.Data.Should().HaveCount(2); result.Total.Should().Be(2); } [Fact] public async Task GetListAsync_按分类筛选_应返回过滤结果() { // Arrange var request = new TransactionQueryRequest { Classify = "餐饮,交通", PageIndex = 1, PageSize = 10 }; var transactions = new List { new() { Id = 1, Reason = "午餐", Amount = 50, Type = TransactionType.Expense, Classify = "餐饮" } }; _transactionRepository.QueryAsync( classifies: Arg.Is(c => c != null && c.Contains("餐饮")), pageIndex: 1, pageSize: 10).Returns(transactions); _transactionRepository.CountAsync( classifies: Arg.Is(c => c != null && c.Contains("餐饮"))).Returns(1); // Act var result = await _application.GetListAsync(request); // Assert result.Data.Should().HaveCount(1); result.Data[0].Classify.Should().Be("餐饮"); } [Fact] public async Task GetListAsync_按类型筛选_应返回对应类型() { // Arrange var request = new TransactionQueryRequest { Type = (int)TransactionType.Expense, PageIndex = 1, PageSize = 10 }; var transactions = new List { new() { Id = 1, Amount = 100, Type = TransactionType.Expense } }; _transactionRepository.QueryAsync( type: TransactionType.Expense, pageIndex: 1, pageSize: 10).Returns(transactions); _transactionRepository.CountAsync( type: TransactionType.Expense).Returns(1); // Act var result = await _application.GetListAsync(request); // Assert result.Data.Should().HaveCount(1); result.Data[0].Type.Should().Be(TransactionType.Expense); } #endregion #region GetByEmailIdAsync Tests [Fact] public async Task GetByEmailIdAsync_有关联记录_应返回列表() { // Arrange var emailId = 100L; var transactions = new List { new() { Id = 1, EmailMessageId = emailId, Amount = 100 }, new() { Id = 2, EmailMessageId = emailId, Amount = 200 } }; _transactionRepository.GetByEmailIdAsync(emailId).Returns(transactions); // Act var result = await _application.GetByEmailIdAsync(emailId); // Assert result.Should().HaveCount(2); await _transactionRepository.Received(1).GetByEmailIdAsync(emailId); } [Fact] public async Task GetByEmailIdAsync_无关联记录_应返回空列表() { // Arrange _transactionRepository.GetByEmailIdAsync(999).Returns(new List()); // Act var result = await _application.GetByEmailIdAsync(999); // Assert result.Should().BeEmpty(); } #endregion #region GetByDateAsync Tests [Fact] public async Task GetByDateAsync_指定日期_应返回当天记录() { // Arrange var date = new DateTime(2026, 2, 10); var expectedStart = date.Date; var expectedEnd = expectedStart.AddDays(1); var transactions = new List { new() { Id = 1, OccurredAt = date, Amount = 100 } }; _transactionRepository.QueryAsync( startDate: expectedStart, endDate: expectedEnd).Returns(transactions); // Act var result = await _application.GetByDateAsync(date); // Assert result.Should().HaveCount(1); result[0].OccurredAt.Date.Should().Be(date.Date); } #endregion #region GetUnconfirmedListAsync and GetUnconfirmedCountAsync Tests [Fact] public async Task GetUnconfirmedListAsync_有未确认记录_应返回列表() { // Arrange var unconfirmedRecords = new List { new() { Id = 1, Amount = 100, UnconfirmedClassify = "待确认分类" }, new() { Id = 2, Amount = 200, UnconfirmedType = TransactionType.Expense } }; _transactionRepository.GetUnconfirmedRecordsAsync().Returns(unconfirmedRecords); // Act var result = await _application.GetUnconfirmedListAsync(); // Assert result.Should().HaveCount(2); } [Fact] public async Task GetUnconfirmedCountAsync_应返回未确认记录数量() { // Arrange var unconfirmedRecords = new List { new() { Id = 1, UnconfirmedClassify = "待确认" }, new() { Id = 2, UnconfirmedClassify = "待确认" } }; _transactionRepository.GetUnconfirmedRecordsAsync().Returns(unconfirmedRecords); // Act var result = await _application.GetUnconfirmedCountAsync(); // Assert result.Should().Be(2); } #endregion #region GetUnclassifiedCountAsync and GetUnclassifiedAsync Tests [Fact] public async Task GetUnclassifiedCountAsync_应返回未分类数量() { // Arrange _transactionRepository.CountAsync().Returns(5); // Act var result = await _application.GetUnclassifiedCountAsync(); // Assert result.Should().Be(5); } [Fact] public async Task GetUnclassifiedAsync_指定页大小_应返回未分类记录() { // Arrange var pageSize = 10; var unclassifiedRecords = new List { new() { Id = 1, Amount = 100, Classify = string.Empty }, new() { Id = 2, Amount = 200, Classify = string.Empty } }; _transactionRepository.GetUnclassifiedAsync(pageSize).Returns(unclassifiedRecords); // Act var result = await _application.GetUnclassifiedAsync(pageSize); // Assert result.Should().HaveCount(2); } #endregion #region ConfirmAllUnconfirmedAsync Tests [Fact] public async Task ConfirmAllUnconfirmedAsync_有效ID列表_应返回确认数量() { // Arrange var ids = new long[] { 1, 2, 3 }; _transactionRepository.ConfirmAllUnconfirmedAsync(ids).Returns(3); // Act var result = await _application.ConfirmAllUnconfirmedAsync(ids); // Assert result.Should().Be(3); await _transactionRepository.Received(1).ConfirmAllUnconfirmedAsync(ids); } [Fact] public async Task ConfirmAllUnconfirmedAsync_空ID列表_应抛出ValidationException() { // Arrange var emptyIds = Array.Empty(); // Act & Assert await Assert.ThrowsAsync(() => _application.ConfirmAllUnconfirmedAsync(emptyIds)); } [Fact] public async Task ConfirmAllUnconfirmedAsync_NullID列表_应抛出ValidationException() { // Act & Assert await Assert.ThrowsAsync(() => _application.ConfirmAllUnconfirmedAsync(null!)); } #endregion #region SmartClassifyAsync Tests [Fact] public async Task SmartClassifyAsync_有效ID列表_应调用Service() { // Arrange var ids = new long[] { 1, 2 }; var chunkReceived = false; Action<(string, string)> onChunk = chunk => { chunkReceived = true; }; // Act await _application.SmartClassifyAsync(ids, onChunk); // Assert await _smartHandleService.Received(1).SmartClassifyAsync(ids, onChunk); } [Fact] public async Task SmartClassifyAsync_空ID列表_应抛出ValidationException() { // Arrange var emptyIds = Array.Empty(); Action<(string, string)> onChunk = _ => { }; // Act & Assert await Assert.ThrowsAsync(() => _application.SmartClassifyAsync(emptyIds, onChunk)); } [Fact] public async Task SmartClassifyAsync_NullID列表_应抛出ValidationException() { // Arrange Action<(string, string)> onChunk = _ => { }; // Act & Assert await Assert.ThrowsAsync(() => _application.SmartClassifyAsync(null!, onChunk)); } #endregion #region ParseOneLineAsync Tests [Fact] public async Task ParseOneLineAsync_有效文本_应返回解析结果() { // Arrange var text = "午餐花了50块"; var parseResult = new TransactionParseResult( OccurredAt: DateTime.Now.ToString("yyyy-MM-dd"), Classify: "餐饮", Amount: 50, Reason: "午餐", Type: TransactionType.Expense ); _smartHandleService.ParseOneLineBillAsync(text).Returns(parseResult); // Act var result = await _application.ParseOneLineAsync(text); // Assert result.Should().NotBeNull(); result!.Amount.Should().Be(50); result.Classify.Should().Be("餐饮"); } [Fact] public async Task ParseOneLineAsync_空文本_应抛出ValidationException() { // Act & Assert await Assert.ThrowsAsync(() => _application.ParseOneLineAsync(string.Empty)); } [Fact] public async Task ParseOneLineAsync_空白文本_应抛出ValidationException() { // Act & Assert await Assert.ThrowsAsync(() => _application.ParseOneLineAsync(" ")); } [Fact] public async Task ParseOneLineAsync_解析失败返回null_应抛出BusinessException() { // Arrange _smartHandleService.ParseOneLineBillAsync(Arg.Any()).Returns((TransactionParseResult?)null); // Act & Assert await Assert.ThrowsAsync(() => _application.ParseOneLineAsync("测试文本")); } #endregion #region AnalyzeBillAsync Tests [Fact] public async Task AnalyzeBillAsync_有效输入_应调用Service() { // Arrange var userInput = "本月支出分析"; var chunkReceived = false; Action onChunk = chunk => { chunkReceived = true; }; // Act await _application.AnalyzeBillAsync(userInput, onChunk); // Assert await _smartHandleService.Received(1).AnalyzeBillAsync(userInput, onChunk); } [Fact] public async Task AnalyzeBillAsync_空输入_应抛出ValidationException() { // Arrange Action onChunk = _ => { }; // Act & Assert await Assert.ThrowsAsync(() => _application.AnalyzeBillAsync(string.Empty, onChunk)); } [Fact] public async Task AnalyzeBillAsync_空白输入_应抛出ValidationException() { // Arrange Action onChunk = _ => { }; // Act & Assert await Assert.ThrowsAsync(() => _application.AnalyzeBillAsync(" ", onChunk)); } #endregion #region BatchUpdateClassifyAsync Tests [Fact] public async Task BatchUpdateClassifyAsync_有效项目列表_应返回成功数量() { // Arrange var items = new List { new() { Id = 1, Classify = "餐饮", Type = TransactionType.Expense }, new() { Id = 2, Classify = "交通", Type = TransactionType.Expense } }; var record1 = new TransactionRecord { Id = 1, Amount = 100 }; var record2 = new TransactionRecord { Id = 2, Amount = 200 }; _transactionRepository.GetByIdAsync(1).Returns(record1); _transactionRepository.GetByIdAsync(2).Returns(record2); _transactionRepository.UpdateAsync(Arg.Any()).Returns(true); // Act var result = await _application.BatchUpdateClassifyAsync(items); // Assert result.Should().Be(2); await _transactionRepository.Received(2).UpdateAsync(Arg.Any()); } [Fact] public async Task BatchUpdateClassifyAsync_部分记录不存在_应只更新存在的记录() { // Arrange var items = new List { new() { Id = 1, Classify = "餐饮" }, new() { Id = 999, Classify = "交通" } }; var record1 = new TransactionRecord { Id = 1, Amount = 100 }; _transactionRepository.GetByIdAsync(1).Returns(record1); _transactionRepository.GetByIdAsync(999).Returns((TransactionRecord?)null); _transactionRepository.UpdateAsync(Arg.Any()).Returns(true); // Act var result = await _application.BatchUpdateClassifyAsync(items); // Assert result.Should().Be(1); } [Fact] public async Task BatchUpdateClassifyAsync_空列表_应抛出ValidationException() { // Arrange var emptyList = new List(); // Act & Assert await Assert.ThrowsAsync(() => _application.BatchUpdateClassifyAsync(emptyList)); } [Fact] public async Task BatchUpdateClassifyAsync_Null列表_应抛出ValidationException() { // Act & Assert await Assert.ThrowsAsync(() => _application.BatchUpdateClassifyAsync(null!)); } [Fact] public async Task BatchUpdateClassifyAsync_更新应清除待确认状态() { // Arrange var items = new List { new() { Id = 1, Classify = "餐饮", Type = TransactionType.Expense } }; var record = new TransactionRecord { Id = 1, Amount = 100, UnconfirmedClassify = "待确认", UnconfirmedType = TransactionType.Income }; _transactionRepository.GetByIdAsync(1).Returns(record); _transactionRepository.UpdateAsync(Arg.Any()).Returns(true); // Act await _application.BatchUpdateClassifyAsync(items); // Assert await _transactionRepository.Received(1).UpdateAsync(Arg.Is( r => r.UnconfirmedClassify == null && r.UnconfirmedType == null )); } #endregion #region BatchUpdateByReasonAsync Tests [Fact] public async Task BatchUpdateByReasonAsync_有效请求_应返回更新数量() { // Arrange var request = new BatchUpdateByReasonRequest { Reason = "午餐", Type = TransactionType.Expense, Classify = "餐饮" }; _transactionRepository.BatchUpdateByReasonAsync("午餐", TransactionType.Expense, "餐饮") .Returns(5); // Act var result = await _application.BatchUpdateByReasonAsync(request); // Assert result.Should().Be(5); } [Fact] public async Task BatchUpdateByReasonAsync_空摘要_应抛出ValidationException() { // Arrange var request = new BatchUpdateByReasonRequest { Reason = string.Empty, Type = TransactionType.Expense, Classify = "餐饮" }; // Act & Assert await Assert.ThrowsAsync(() => _application.BatchUpdateByReasonAsync(request)); } [Fact] public async Task BatchUpdateByReasonAsync_空分类_应抛出ValidationException() { // Arrange var request = new BatchUpdateByReasonRequest { Reason = "午餐", Type = TransactionType.Expense, Classify = string.Empty }; // Act & Assert await Assert.ThrowsAsync(() => _application.BatchUpdateByReasonAsync(request)); } [Fact] public async Task BatchUpdateByReasonAsync_空白摘要_应抛出ValidationException() { // Arrange var request = new BatchUpdateByReasonRequest { Reason = " ", Type = TransactionType.Expense, Classify = "餐饮" }; // Act & Assert await Assert.ThrowsAsync(() => _application.BatchUpdateByReasonAsync(request)); } #endregion }