using Service.IconSearch; namespace WebApi.Test.Service.IconSearch; /// /// IconSearchService 单元测试 /// public class IconSearchServiceTest : BaseTest { private readonly IconSearchService _service; private readonly ISearchKeywordGeneratorService _keywordGeneratorService; private readonly IIconifyApiService _iconifyApiService; private readonly ITransactionCategoryRepository _categoryRepository; private readonly ILogger _logger; public IconSearchServiceTest() { _keywordGeneratorService = Substitute.For(); _iconifyApiService = Substitute.For(); _categoryRepository = Substitute.For(); _logger = Substitute.For>(); _service = new IconSearchService( _keywordGeneratorService, _iconifyApiService, _categoryRepository, _logger ); } #region GenerateSearchKeywordsAsync Tests [Fact] public async Task GenerateSearchKeywordsAsync_应该委托给KeywordGeneratorService() { // Arrange const string categoryName = "餐饮"; var expectedKeywords = new List { "food", "restaurant", "dining" }; _keywordGeneratorService.GenerateKeywordsAsync(categoryName) .Returns(Task.FromResult(expectedKeywords)); // Act var keywords = await _service.GenerateSearchKeywordsAsync(categoryName); // Assert keywords.Should().NotBeNull(); keywords.Count.Should().Be(3); keywords.Should().BeEquivalentTo(expectedKeywords); await _keywordGeneratorService.Received(1).GenerateKeywordsAsync(categoryName); } [Fact] public async Task GenerateSearchKeywordsAsync_应该返回空列表当关键字生成失败() { // Arrange const string categoryName = "测试"; _keywordGeneratorService.GenerateKeywordsAsync(categoryName) .Returns(Task.FromResult(new List())); // Act var keywords = await _service.GenerateSearchKeywordsAsync(categoryName); // Assert keywords.Should().NotBeNull(); keywords.Should().BeEmpty(); } #endregion #region SearchIconsAsync Tests [Fact] public async Task SearchIconsAsync_应该返回图标候选列表() { // Arrange var keywords = new List { "food", "restaurant" }; var expectedIcons = new List { new() { CollectionName = "mdi", IconName = "food" }, new() { CollectionName = "mdi", IconName = "restaurant" } }; _iconifyApiService.SearchIconsAsync(keywords, 20) .Returns(Task.FromResult(expectedIcons)); // Act var icons = await _service.SearchIconsAsync(keywords, 20); // Assert icons.Should().NotBeNull(); icons.Count.Should().Be(2); icons[0].IconIdentifier.Should().Be("mdi:food"); icons[1].IconIdentifier.Should().Be("mdi:restaurant"); await _iconifyApiService.Received(1).SearchIconsAsync(keywords, 20); } [Fact] public async Task SearchIconsAsync_应该处理空关键字列表() { // Arrange var keywords = new List(); // Act var icons = await _service.SearchIconsAsync(keywords, 20); // Assert icons.Should().NotBeNull(); icons.Should().BeEmpty(); // 验证没有调用 Iconify API await _iconifyApiService.DidNotReceive().SearchIconsAsync(Arg.Any>(), Arg.Any()); } [Fact] public async Task SearchIconsAsync_应该处理null关键字列表() { // Arrange List? keywords = null; // Act var icons = await _service.SearchIconsAsync(keywords!, 20); // Assert icons.Should().NotBeNull(); icons.Should().BeEmpty(); // 验证没有调用 Iconify API await _iconifyApiService.DidNotReceive().SearchIconsAsync(Arg.Any>(), Arg.Any()); } [Fact] public async Task SearchIconsAsync_应该使用指定的Limit() { // Arrange var keywords = new List { "food" }; var icons = new List { new() { CollectionName = "mdi", IconName = "food" } }; _iconifyApiService.SearchIconsAsync(keywords, 50) .Returns(Task.FromResult(icons)); // Act await _service.SearchIconsAsync(keywords, 50); // Assert await _iconifyApiService.Received(1).SearchIconsAsync(keywords, 50); } [Fact] public async Task SearchIconsAsync_应该处理API返回空结果() { // Arrange var keywords = new List { "nonexistent" }; _iconifyApiService.SearchIconsAsync(keywords, 20) .Returns(Task.FromResult(new List())); // Act var icons = await _service.SearchIconsAsync(keywords, 20); // Assert icons.Should().NotBeNull(); icons.Should().BeEmpty(); } #endregion #region UpdateCategoryIconAsync Tests [Fact] public async Task UpdateCategoryIconAsync_应该更新分类图标() { // Arrange const long categoryId = 12345L; const string iconIdentifier = "mdi:food"; var category = new TransactionCategory { Id = categoryId, Name = "餐饮", Type = TransactionType.Expense, Icon = null, IconKeywords = "[\"food\"]" }; _categoryRepository.GetByIdAsync(categoryId) .Returns(Task.FromResult(category)); // Act await _service.UpdateCategoryIconAsync(categoryId, iconIdentifier); // Assert category.Icon.Should().Be(iconIdentifier); category.IconKeywords.Should().BeNull(); // IconKeywords 应该被清空 await _categoryRepository.Received(1).GetByIdAsync(categoryId); await _categoryRepository.Received(1).UpdateAsync(category); } [Fact] public async Task UpdateCategoryIconAsync_应该抛出异常当图标标识符为空() { // Arrange const long categoryId = 12345L; const string iconIdentifier = ""; // Act & Assert await FluentActions.Invoking(() => _service.UpdateCategoryIconAsync(categoryId, iconIdentifier)) .Should().ThrowAsync() .WithMessage("*图标标识符不能为空*"); // 验证没有调用 repository await _categoryRepository.DidNotReceive().GetByIdAsync(Arg.Any()); await _categoryRepository.DidNotReceive().UpdateAsync(Arg.Any()); } [Theory] [InlineData("")] [InlineData(" ")] [InlineData(null)] public async Task UpdateCategoryIconAsync_应该抛出异常当图标标识符无效(string iconIdentifier) { // Arrange const long categoryId = 12345L; // Act & Assert await FluentActions.Invoking(() => _service.UpdateCategoryIconAsync(categoryId, iconIdentifier)) .Should().ThrowAsync(); } [Fact] public async Task UpdateCategoryIconAsync_应该抛出异常当分类不存在() { // Arrange const long categoryId = 99999L; const string iconIdentifier = "mdi:food"; _categoryRepository.GetByIdAsync(categoryId) .Returns(Task.FromResult(null)); // Act & Assert await FluentActions.Invoking(() => _service.UpdateCategoryIconAsync(categoryId, iconIdentifier)) .Should().ThrowAsync() .WithMessage($"*分类不存在,ID:{categoryId}*"); // 验证没有调用 Update await _categoryRepository.DidNotReceive().UpdateAsync(Arg.Any()); } [Fact] public async Task UpdateCategoryIconAsync_应该清空IconKeywords字段() { // Arrange const long categoryId = 12345L; const string iconIdentifier = "mdi:restaurant"; var category = new TransactionCategory { Id = categoryId, Name = "餐饮", Type = TransactionType.Expense, Icon = "mdi:food", IconKeywords = "[\"food\", \"restaurant\"]" }; _categoryRepository.GetByIdAsync(categoryId) .Returns(Task.FromResult(category)); // Act await _service.UpdateCategoryIconAsync(categoryId, iconIdentifier); // Assert category.IconKeywords.Should().BeNull(); } #endregion #region End-to-End Tests [Fact] public async Task EndToEnd_完整流程_生成关键字到搜索图标到更新分类() { // Arrange - Step 1: 生成搜索关键字 const string categoryName = "餐饮"; var keywords = new List { "food", "restaurant", "dining" }; _keywordGeneratorService.GenerateKeywordsAsync(categoryName) .Returns(Task.FromResult(keywords)); // Arrange - Step 2: 搜索图标 var icons = new List { new() { CollectionName = "mdi", IconName = "food" }, new() { CollectionName = "mdi", IconName = "restaurant" }, new() { CollectionName = "fa", IconName = "utensils" } }; _iconifyApiService.SearchIconsAsync(keywords, 20) .Returns(Task.FromResult(icons)); // Arrange - Step 3: 更新分类图标 const long categoryId = 12345L; const string selectedIconIdentifier = "mdi:food"; var category = new TransactionCategory { Id = categoryId, Name = categoryName, Type = TransactionType.Expense, Icon = null, IconKeywords = null }; _categoryRepository.GetByIdAsync(categoryId) .Returns(Task.FromResult(category)); // Act - Step 1: 生成关键字 var generatedKeywords = await _service.GenerateSearchKeywordsAsync(categoryName); // Assert - Step 1 generatedKeywords.Should().BeEquivalentTo(keywords); // Act - Step 2: 搜索图标 var searchedIcons = await _service.SearchIconsAsync(generatedKeywords, 20); // Assert - Step 2 searchedIcons.Should().HaveCount(3); searchedIcons[0].IconIdentifier.Should().Be("mdi:food"); searchedIcons[1].IconIdentifier.Should().Be("mdi:restaurant"); searchedIcons[2].IconIdentifier.Should().Be("fa:utensils"); // Act - Step 3: 更新分类图标 await _service.UpdateCategoryIconAsync(categoryId, selectedIconIdentifier); // Assert - Step 3 category.Icon.Should().Be(selectedIconIdentifier); category.IconKeywords.Should().BeNull(); // 验证所有服务都被调用 await _keywordGeneratorService.Received(1).GenerateKeywordsAsync(categoryName); await _iconifyApiService.Received(1).SearchIconsAsync(keywords, 20); await _categoryRepository.Received(1).GetByIdAsync(categoryId); await _categoryRepository.Received(1).UpdateAsync(category); } [Fact] public async Task EndToEnd_关键字生成失败_应该返回空图标列表() { // Arrange const string categoryName = "未知分类"; _keywordGeneratorService.GenerateKeywordsAsync(categoryName) .Returns(Task.FromResult(new List())); // Act - Step 1: 生成关键字 var keywords = await _service.GenerateSearchKeywordsAsync(categoryName); // Act - Step 2: 搜索图标(使用空关键字) var icons = await _service.SearchIconsAsync(keywords, 20); // Assert keywords.Should().BeEmpty(); icons.Should().BeEmpty(); // 验证 Iconify API 没有被调用 await _iconifyApiService.DidNotReceive().SearchIconsAsync(Arg.Any>(), Arg.Any()); } [Fact] public async Task EndToEnd_图标搜索返回空_应该处理正常() { // Arrange const string categoryName = "测试分类"; var keywords = new List { "test" }; _keywordGeneratorService.GenerateKeywordsAsync(categoryName) .Returns(Task.FromResult(keywords)); _iconifyApiService.SearchIconsAsync(keywords, 20) .Returns(Task.FromResult(new List())); // Act var generatedKeywords = await _service.GenerateSearchKeywordsAsync(categoryName); var icons = await _service.SearchIconsAsync(generatedKeywords, 20); // Assert generatedKeywords.Should().NotBeEmpty(); icons.Should().BeEmpty(); } [Fact] public async Task EndToEnd_更新不存在的分类_应该抛出异常() { // Arrange const string categoryName = "餐饮"; var keywords = new List { "food" }; var icons = new List { new() { CollectionName = "mdi", IconName = "food" } }; _keywordGeneratorService.GenerateKeywordsAsync(categoryName) .Returns(Task.FromResult(keywords)); _iconifyApiService.SearchIconsAsync(keywords, 20) .Returns(Task.FromResult(icons)); _categoryRepository.GetByIdAsync(Arg.Any()) .Returns(Task.FromResult(null)); // Act var generatedKeywords = await _service.GenerateSearchKeywordsAsync(categoryName); var searchedIcons = await _service.SearchIconsAsync(generatedKeywords, 20); // Assert - 前两步成功 generatedKeywords.Should().NotBeEmpty(); searchedIcons.Should().NotBeEmpty(); // Act & Assert - 第三步失败 await FluentActions.Invoking(() => _service.UpdateCategoryIconAsync(99999L, "mdi:food")) .Should().ThrowAsync() .WithMessage("*分类不存在*"); } #endregion #region Edge Cases [Fact] public async Task SearchIconsAsync_应该处理大量关键字() { // Arrange var keywords = Enumerable.Range(1, 100).Select(i => $"keyword{i}").ToList(); var icons = new List { new() { CollectionName = "mdi", IconName = "test" } }; _iconifyApiService.SearchIconsAsync(keywords, 20) .Returns(Task.FromResult(icons)); // Act var result = await _service.SearchIconsAsync(keywords, 20); // Assert result.Should().NotBeNull(); await _iconifyApiService.Received(1).SearchIconsAsync(keywords, 20); } [Fact] public async Task UpdateCategoryIconAsync_应该处理超长图标标识符() { // Arrange const long categoryId = 12345L; var longIconIdentifier = new string('a', 100); // 超过50字符的限制 var category = new TransactionCategory { Id = categoryId, Name = "测试", Type = TransactionType.Expense }; _categoryRepository.GetByIdAsync(categoryId) .Returns(Task.FromResult(category)); // Act await _service.UpdateCategoryIconAsync(categoryId, longIconIdentifier); // Assert // 服务层不验证长度,由数据库层处理 category.Icon.Should().Be(longIconIdentifier); await _categoryRepository.Received(1).UpdateAsync(category); } #endregion }