Files
EmailBill/WebApi.Test/Application/TransactionApplicationTest.cs
SunCheng 51172e8c5a
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 4m27s
Docker Build & Deploy / Deploy to Production (push) Successful in 7s
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 1s
Docker Build & Deploy / WeChat Notification (push) Successful in 1s
fix
2026-02-11 13:00:01 +08:00

771 lines
22 KiB
C#

using Application.Dto.Transaction;
using Service.AI;
namespace WebApi.Test.Application;
/// <summary>
/// TransactionApplication 单元测试
/// </summary>
public class TransactionApplicationTest : BaseApplicationTest
{
private readonly ITransactionRecordRepository _transactionRepository;
private readonly ISmartHandleService _smartHandleService;
private readonly TransactionApplication _application;
public TransactionApplicationTest()
{
_transactionRepository = Substitute.For<ITransactionRecordRepository>();
_smartHandleService = Substitute.For<ISmartHandleService>();
_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<NotFoundException>(() => _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<TransactionRecord>()).Returns(true);
// Act
await _application.CreateAsync(request);
// Assert
await _transactionRepository.Received(1).AddAsync(Arg.Is<TransactionRecord>(
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<ValidationException>(() => _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<TransactionRecord>()).Returns(false);
// Act & Assert
await Assert.ThrowsAsync<BusinessException>(() => _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<TransactionRecord>()).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<TransactionRecord>(
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<NotFoundException>(() => _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<BusinessException>(() => _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<TransactionRecord>
{
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<TransactionRecord>
{
new() { Id = 1, Reason = "午餐", Amount = 50, Type = TransactionType.Expense, Classify = "餐饮" }
};
_transactionRepository.QueryAsync(
classifies: Arg.Is<string[]>(c => c != null && c.Contains("餐饮")),
pageIndex: 1,
pageSize: 10).Returns(transactions);
_transactionRepository.CountAsync(
classifies: Arg.Is<string[]>(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<TransactionRecord>
{
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<TransactionRecord>
{
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<TransactionRecord>());
// 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<TransactionRecord>
{
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<TransactionRecord>
{
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<TransactionRecord>
{
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<TransactionRecord>
{
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<long>();
// Act & Assert
await Assert.ThrowsAsync<ValidationException>(() =>
_application.ConfirmAllUnconfirmedAsync(emptyIds));
}
[Fact]
public async Task ConfirmAllUnconfirmedAsync_NullID列表_应抛出ValidationException()
{
// Act & Assert
await Assert.ThrowsAsync<ValidationException>(() =>
_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<long>();
Action<(string, string)> onChunk = _ => { };
// Act & Assert
await Assert.ThrowsAsync<ValidationException>(() =>
_application.SmartClassifyAsync(emptyIds, onChunk));
}
[Fact]
public async Task SmartClassifyAsync_NullID列表_应抛出ValidationException()
{
// Arrange
Action<(string, string)> onChunk = _ => { };
// Act & Assert
await Assert.ThrowsAsync<ValidationException>(() =>
_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<ValidationException>(() =>
_application.ParseOneLineAsync(string.Empty));
}
[Fact]
public async Task ParseOneLineAsync_空白文本_应抛出ValidationException()
{
// Act & Assert
await Assert.ThrowsAsync<ValidationException>(() =>
_application.ParseOneLineAsync(" "));
}
[Fact]
public async Task ParseOneLineAsync_解析失败返回null_应抛出BusinessException()
{
// Arrange
_smartHandleService.ParseOneLineBillAsync(Arg.Any<string>()).Returns((TransactionParseResult?)null);
// Act & Assert
await Assert.ThrowsAsync<BusinessException>(() =>
_application.ParseOneLineAsync("测试文本"));
}
#endregion
#region AnalyzeBillAsync Tests
[Fact]
public async Task AnalyzeBillAsync_有效输入_应调用Service()
{
// Arrange
var userInput = "本月支出分析";
var chunkReceived = false;
Action<string> 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<string> onChunk = _ => { };
// Act & Assert
await Assert.ThrowsAsync<ValidationException>(() =>
_application.AnalyzeBillAsync(string.Empty, onChunk));
}
[Fact]
public async Task AnalyzeBillAsync_空白输入_应抛出ValidationException()
{
// Arrange
Action<string> onChunk = _ => { };
// Act & Assert
await Assert.ThrowsAsync<ValidationException>(() =>
_application.AnalyzeBillAsync(" ", onChunk));
}
#endregion
#region BatchUpdateClassifyAsync Tests
[Fact]
public async Task BatchUpdateClassifyAsync_有效项目列表_应返回成功数量()
{
// Arrange
var items = new List<BatchUpdateClassifyItem>
{
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<TransactionRecord>()).Returns(true);
// Act
var result = await _application.BatchUpdateClassifyAsync(items);
// Assert
result.Should().Be(2);
await _transactionRepository.Received(2).UpdateAsync(Arg.Any<TransactionRecord>());
}
[Fact]
public async Task BatchUpdateClassifyAsync_部分记录不存在_应只更新存在的记录()
{
// Arrange
var items = new List<BatchUpdateClassifyItem>
{
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<TransactionRecord>()).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<BatchUpdateClassifyItem>();
// Act & Assert
await Assert.ThrowsAsync<ValidationException>(() =>
_application.BatchUpdateClassifyAsync(emptyList));
}
[Fact]
public async Task BatchUpdateClassifyAsync_Null列表_应抛出ValidationException()
{
// Act & Assert
await Assert.ThrowsAsync<ValidationException>(() =>
_application.BatchUpdateClassifyAsync(null!));
}
[Fact]
public async Task BatchUpdateClassifyAsync_更新应清除待确认状态()
{
// Arrange
var items = new List<BatchUpdateClassifyItem>
{
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<TransactionRecord>()).Returns(true);
// Act
await _application.BatchUpdateClassifyAsync(items);
// Assert
await _transactionRepository.Received(1).UpdateAsync(Arg.Is<TransactionRecord>(
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<ValidationException>(() =>
_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<ValidationException>(() =>
_application.BatchUpdateByReasonAsync(request));
}
[Fact]
public async Task BatchUpdateByReasonAsync_空白摘要_应抛出ValidationException()
{
// Arrange
var request = new BatchUpdateByReasonRequest
{
Reason = " ",
Type = TransactionType.Expense,
Classify = "餐饮"
};
// Act & Assert
await Assert.ThrowsAsync<ValidationException>(() =>
_application.BatchUpdateByReasonAsync(request));
}
#endregion
}