464 lines
15 KiB
C#
464 lines
15 KiB
C#
|
|
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
|
|||
|
|
}
|