Files
EmailBill/WebApi.Test/Service/IconSearch/IconSearchServiceTest.cs

464 lines
15 KiB
C#
Raw Normal View History

using Service.IconSearch;
namespace WebApi.Test.Service.IconSearch;
/// <summary>
/// IconSearchService 单元测试
/// </summary>
public class IconSearchServiceTest : BaseTest
{
private readonly IconSearchService _service;
private readonly ISearchKeywordGeneratorService _keywordGeneratorService;
private readonly IIconifyApiService _iconifyApiService;
private readonly ITransactionCategoryRepository _categoryRepository;
private readonly ILogger<IconSearchService> _logger;
public IconSearchServiceTest()
{
_keywordGeneratorService = Substitute.For<ISearchKeywordGeneratorService>();
_iconifyApiService = Substitute.For<IIconifyApiService>();
_categoryRepository = Substitute.For<ITransactionCategoryRepository>();
_logger = Substitute.For<ILogger<IconSearchService>>();
_service = new IconSearchService(
_keywordGeneratorService,
_iconifyApiService,
_categoryRepository,
_logger
);
}
#region GenerateSearchKeywordsAsync Tests
[Fact]
public async Task GenerateSearchKeywordsAsync_应该委托给KeywordGeneratorService()
{
// Arrange
const string categoryName = "餐饮";
var expectedKeywords = new List<string> { "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<string>()));
// 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<string> { "food", "restaurant" };
var expectedIcons = new List<IconCandidate>
{
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<string>();
// Act
var icons = await _service.SearchIconsAsync(keywords, 20);
// Assert
icons.Should().NotBeNull();
icons.Should().BeEmpty();
// 验证没有调用 Iconify API
await _iconifyApiService.DidNotReceive().SearchIconsAsync(Arg.Any<List<string>>(), Arg.Any<int>());
}
[Fact]
public async Task SearchIconsAsync_应该处理null关键字列表()
{
// Arrange
List<string>? 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<List<string>>(), Arg.Any<int>());
}
[Fact]
public async Task SearchIconsAsync_应该使用指定的Limit()
{
// Arrange
var keywords = new List<string> { "food" };
var icons = new List<IconCandidate> { 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<string> { "nonexistent" };
_iconifyApiService.SearchIconsAsync(keywords, 20)
.Returns(Task.FromResult(new List<IconCandidate>()));
// 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<TransactionCategory?>(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<ArgumentException>()
.WithMessage("*图标标识符不能为空*");
// 验证没有调用 repository
await _categoryRepository.DidNotReceive().GetByIdAsync(Arg.Any<long>());
await _categoryRepository.DidNotReceive().UpdateAsync(Arg.Any<TransactionCategory>());
}
[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<ArgumentException>();
}
[Fact]
public async Task UpdateCategoryIconAsync_应该抛出异常当分类不存在()
{
// Arrange
const long categoryId = 99999L;
const string iconIdentifier = "mdi:food";
_categoryRepository.GetByIdAsync(categoryId)
.Returns(Task.FromResult<TransactionCategory?>(null));
// Act & Assert
await FluentActions.Invoking(() => _service.UpdateCategoryIconAsync(categoryId, iconIdentifier))
.Should().ThrowAsync<Exception>()
.WithMessage($"*分类不存在ID{categoryId}*");
// 验证没有调用 Update
await _categoryRepository.DidNotReceive().UpdateAsync(Arg.Any<TransactionCategory>());
}
[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<TransactionCategory?>(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<string> { "food", "restaurant", "dining" };
_keywordGeneratorService.GenerateKeywordsAsync(categoryName)
.Returns(Task.FromResult(keywords));
// Arrange - Step 2: 搜索图标
var icons = new List<IconCandidate>
{
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<TransactionCategory?>(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<string>()));
// 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<List<string>>(), Arg.Any<int>());
}
[Fact]
public async Task EndToEnd_图标搜索返回空_应该处理正常()
{
// Arrange
const string categoryName = "测试分类";
var keywords = new List<string> { "test" };
_keywordGeneratorService.GenerateKeywordsAsync(categoryName)
.Returns(Task.FromResult(keywords));
_iconifyApiService.SearchIconsAsync(keywords, 20)
.Returns(Task.FromResult(new List<IconCandidate>()));
// 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<string> { "food" };
var icons = new List<IconCandidate> { new() { CollectionName = "mdi", IconName = "food" } };
_keywordGeneratorService.GenerateKeywordsAsync(categoryName)
.Returns(Task.FromResult(keywords));
_iconifyApiService.SearchIconsAsync(keywords, 20)
.Returns(Task.FromResult(icons));
_categoryRepository.GetByIdAsync(Arg.Any<long>())
.Returns(Task.FromResult<TransactionCategory?>(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<Exception>()
.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<IconCandidate> { 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<TransactionCategory?>(category));
// Act
await _service.UpdateCategoryIconAsync(categoryId, longIconIdentifier);
// Assert
// 服务层不验证长度,由数据库层处理
category.Icon.Should().Be(longIconIdentifier);
await _categoryRepository.Received(1).UpdateAsync(category);
}
#endregion
}