444 lines
15 KiB
C#
444 lines
15 KiB
C#
|
|
using Application.Dto.Icon;
|
||
|
|
using Service.IconSearch;
|
||
|
|
using WebApi.Controllers;
|
||
|
|
|
||
|
|
namespace WebApi.Test.Controllers;
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// IconController 集成测试
|
||
|
|
/// </summary>
|
||
|
|
public class IconControllerTest : BaseTest
|
||
|
|
{
|
||
|
|
private readonly IconController _controller;
|
||
|
|
private readonly IIconSearchService _iconSearchService;
|
||
|
|
private readonly ILogger<IconController> _logger;
|
||
|
|
|
||
|
|
public IconControllerTest()
|
||
|
|
{
|
||
|
|
_iconSearchService = Substitute.For<IIconSearchService>();
|
||
|
|
_logger = Substitute.For<ILogger<IconController>>();
|
||
|
|
_controller = new IconController(_iconSearchService, _logger);
|
||
|
|
}
|
||
|
|
|
||
|
|
#region POST /api/icons/search-keywords Tests
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public async Task GenerateSearchKeywords_应该返回成功响应()
|
||
|
|
{
|
||
|
|
// Arrange
|
||
|
|
var request = new SearchKeywordsRequest { CategoryName = "餐饮" };
|
||
|
|
var expectedKeywords = new List<string> { "food", "restaurant", "dining" };
|
||
|
|
|
||
|
|
_iconSearchService.GenerateSearchKeywordsAsync(request.CategoryName)
|
||
|
|
.Returns(Task.FromResult(expectedKeywords));
|
||
|
|
|
||
|
|
// Act
|
||
|
|
var response = await _controller.GenerateSearchKeywordsAsync(request);
|
||
|
|
|
||
|
|
// Assert
|
||
|
|
response.Should().NotBeNull();
|
||
|
|
response.Success.Should().BeTrue();
|
||
|
|
response.Data.Should().NotBeNull();
|
||
|
|
response.Data!.Keywords.Should().BeEquivalentTo(expectedKeywords);
|
||
|
|
|
||
|
|
await _iconSearchService.Received(1).GenerateSearchKeywordsAsync(request.CategoryName);
|
||
|
|
}
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public async Task GenerateSearchKeywords_应该处理空关键字列表()
|
||
|
|
{
|
||
|
|
// Arrange
|
||
|
|
var request = new SearchKeywordsRequest { CategoryName = "未知分类" };
|
||
|
|
_iconSearchService.GenerateSearchKeywordsAsync(request.CategoryName)
|
||
|
|
.Returns(Task.FromResult(new List<string>()));
|
||
|
|
|
||
|
|
// Act
|
||
|
|
var response = await _controller.GenerateSearchKeywordsAsync(request);
|
||
|
|
|
||
|
|
// Assert
|
||
|
|
response.Should().NotBeNull();
|
||
|
|
response.Success.Should().BeTrue();
|
||
|
|
response.Data.Should().NotBeNull();
|
||
|
|
response.Data!.Keywords.Should().BeEmpty();
|
||
|
|
}
|
||
|
|
|
||
|
|
[Theory]
|
||
|
|
[InlineData("餐饮")]
|
||
|
|
[InlineData("交通")]
|
||
|
|
[InlineData("购物")]
|
||
|
|
[InlineData("医疗")]
|
||
|
|
public async Task GenerateSearchKeywords_应该处理不同的分类名称(string categoryName)
|
||
|
|
{
|
||
|
|
// Arrange
|
||
|
|
var request = new SearchKeywordsRequest { CategoryName = categoryName };
|
||
|
|
var keywords = new List<string> { "test1", "test2" };
|
||
|
|
|
||
|
|
_iconSearchService.GenerateSearchKeywordsAsync(categoryName)
|
||
|
|
.Returns(Task.FromResult(keywords));
|
||
|
|
|
||
|
|
// Act
|
||
|
|
var response = await _controller.GenerateSearchKeywordsAsync(request);
|
||
|
|
|
||
|
|
// Assert
|
||
|
|
response.Should().NotBeNull();
|
||
|
|
response.Success.Should().BeTrue();
|
||
|
|
response.Data!.Keywords.Should().HaveCount(2);
|
||
|
|
}
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public async Task GenerateSearchKeywords_应该处理服务层异常()
|
||
|
|
{
|
||
|
|
// Arrange
|
||
|
|
var request = new SearchKeywordsRequest { CategoryName = "测试" };
|
||
|
|
_iconSearchService.GenerateSearchKeywordsAsync(request.CategoryName)
|
||
|
|
.Returns<List<string>>(_ => throw new Exception("服务异常"));
|
||
|
|
|
||
|
|
// Act & Assert
|
||
|
|
await FluentActions.Invoking(() => _controller.GenerateSearchKeywordsAsync(request))
|
||
|
|
.Should().ThrowAsync<Exception>()
|
||
|
|
.WithMessage("服务异常");
|
||
|
|
}
|
||
|
|
|
||
|
|
#endregion
|
||
|
|
|
||
|
|
#region POST /api/icons/search Tests
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public async Task SearchIcons_应该返回图标候选列表()
|
||
|
|
{
|
||
|
|
// Arrange
|
||
|
|
var request = new SearchIconsRequest { Keywords = new List<string> { "food", "restaurant" } };
|
||
|
|
var icons = new List<IconCandidate>
|
||
|
|
{
|
||
|
|
new() { CollectionName = "mdi", IconName = "food" },
|
||
|
|
new() { CollectionName = "mdi", IconName = "restaurant" },
|
||
|
|
new() { CollectionName = "fa", IconName = "utensils" }
|
||
|
|
};
|
||
|
|
|
||
|
|
_iconSearchService.SearchIconsAsync(request.Keywords, 20)
|
||
|
|
.Returns(Task.FromResult(icons));
|
||
|
|
|
||
|
|
// Act
|
||
|
|
var response = await _controller.SearchIconsAsync(request);
|
||
|
|
|
||
|
|
// Assert
|
||
|
|
response.Should().NotBeNull();
|
||
|
|
response.Success.Should().BeTrue();
|
||
|
|
response.Data.Should().NotBeNull();
|
||
|
|
response.Data!.Count.Should().Be(3);
|
||
|
|
response.Data[0].IconIdentifier.Should().Be("mdi:food");
|
||
|
|
response.Data[1].IconIdentifier.Should().Be("mdi:restaurant");
|
||
|
|
response.Data[2].IconIdentifier.Should().Be("fa:utensils");
|
||
|
|
|
||
|
|
await _iconSearchService.Received(1).SearchIconsAsync(request.Keywords, 20);
|
||
|
|
}
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public async Task SearchIcons_应该处理空结果()
|
||
|
|
{
|
||
|
|
// Arrange
|
||
|
|
var request = new SearchIconsRequest { Keywords = new List<string> { "nonexistent" } };
|
||
|
|
_iconSearchService.SearchIconsAsync(request.Keywords, 20)
|
||
|
|
.Returns(Task.FromResult(new List<IconCandidate>()));
|
||
|
|
|
||
|
|
// Act
|
||
|
|
var response = await _controller.SearchIconsAsync(request);
|
||
|
|
|
||
|
|
// Assert
|
||
|
|
response.Should().NotBeNull();
|
||
|
|
response.Success.Should().BeTrue();
|
||
|
|
response.Data.Should().NotBeNull();
|
||
|
|
response.Data!.Should().BeEmpty();
|
||
|
|
}
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public async Task SearchIcons_应该正确映射IconCandidate到IconCandidateDto()
|
||
|
|
{
|
||
|
|
// Arrange
|
||
|
|
var request = new SearchIconsRequest { Keywords = new List<string> { "home" } };
|
||
|
|
var icons = new List<IconCandidate>
|
||
|
|
{
|
||
|
|
new() { CollectionName = "mdi", IconName = "home" }
|
||
|
|
};
|
||
|
|
|
||
|
|
_iconSearchService.SearchIconsAsync(request.Keywords, 20)
|
||
|
|
.Returns(Task.FromResult(icons));
|
||
|
|
|
||
|
|
// Act
|
||
|
|
var response = await _controller.SearchIconsAsync(request);
|
||
|
|
|
||
|
|
// Assert
|
||
|
|
response.Data!.Count.Should().Be(1);
|
||
|
|
var dto = response.Data[0];
|
||
|
|
dto.CollectionName.Should().Be("mdi");
|
||
|
|
dto.IconName.Should().Be("home");
|
||
|
|
dto.IconIdentifier.Should().Be("mdi:home");
|
||
|
|
}
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public async Task SearchIcons_应该处理多个关键字()
|
||
|
|
{
|
||
|
|
// Arrange
|
||
|
|
var keywords = new List<string> { "food", "restaurant", "dining", "eat", "meal" };
|
||
|
|
var request = new SearchIconsRequest { Keywords = keywords };
|
||
|
|
var icons = new List<IconCandidate>
|
||
|
|
{
|
||
|
|
new() { CollectionName = "mdi", IconName = "food" }
|
||
|
|
};
|
||
|
|
|
||
|
|
_iconSearchService.SearchIconsAsync(keywords, 20)
|
||
|
|
.Returns(Task.FromResult(icons));
|
||
|
|
|
||
|
|
// Act
|
||
|
|
var response = await _controller.SearchIconsAsync(request);
|
||
|
|
|
||
|
|
// Assert
|
||
|
|
response.Should().NotBeNull();
|
||
|
|
response.Success.Should().BeTrue();
|
||
|
|
await _iconSearchService.Received(1).SearchIconsAsync(keywords, 20);
|
||
|
|
}
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public async Task SearchIcons_应该使用固定的Limit值20()
|
||
|
|
{
|
||
|
|
// Arrange
|
||
|
|
var request = new SearchIconsRequest { Keywords = new List<string> { "test" } };
|
||
|
|
_iconSearchService.SearchIconsAsync(request.Keywords, 20)
|
||
|
|
.Returns(Task.FromResult(new List<IconCandidate>()));
|
||
|
|
|
||
|
|
// Act
|
||
|
|
await _controller.SearchIconsAsync(request);
|
||
|
|
|
||
|
|
// Assert
|
||
|
|
// 验证使用的是固定的 limit=20
|
||
|
|
await _iconSearchService.Received(1).SearchIconsAsync(request.Keywords, 20);
|
||
|
|
}
|
||
|
|
|
||
|
|
#endregion
|
||
|
|
|
||
|
|
#region PUT /api/icons/categories/{categoryId}/icon Tests
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public async Task UpdateCategoryIcon_应该返回成功响应()
|
||
|
|
{
|
||
|
|
// Arrange
|
||
|
|
const long categoryId = 12345L;
|
||
|
|
var request = new UpdateCategoryIconRequest { IconIdentifier = "mdi:food" };
|
||
|
|
|
||
|
|
_iconSearchService.UpdateCategoryIconAsync(categoryId, request.IconIdentifier)
|
||
|
|
.Returns(Task.CompletedTask);
|
||
|
|
|
||
|
|
// Act
|
||
|
|
var response = await _controller.UpdateCategoryIconAsync(categoryId, request);
|
||
|
|
|
||
|
|
// Assert
|
||
|
|
response.Should().NotBeNull();
|
||
|
|
response.Success.Should().BeTrue();
|
||
|
|
response.Message.Should().Be("更新分类图标成功");
|
||
|
|
|
||
|
|
await _iconSearchService.Received(1).UpdateCategoryIconAsync(categoryId, request.IconIdentifier);
|
||
|
|
}
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public async Task UpdateCategoryIcon_应该处理不同的分类ID()
|
||
|
|
{
|
||
|
|
// Arrange
|
||
|
|
const long categoryId = 99999L;
|
||
|
|
var request = new UpdateCategoryIconRequest { IconIdentifier = "fa:shopping-cart" };
|
||
|
|
|
||
|
|
_iconSearchService.UpdateCategoryIconAsync(categoryId, request.IconIdentifier)
|
||
|
|
.Returns(Task.CompletedTask);
|
||
|
|
|
||
|
|
// Act
|
||
|
|
var response = await _controller.UpdateCategoryIconAsync(categoryId, request);
|
||
|
|
|
||
|
|
// Assert
|
||
|
|
response.Success.Should().BeTrue();
|
||
|
|
await _iconSearchService.Received(1).UpdateCategoryIconAsync(categoryId, request.IconIdentifier);
|
||
|
|
}
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public async Task UpdateCategoryIcon_应该处理不同的图标标识符()
|
||
|
|
{
|
||
|
|
// Arrange
|
||
|
|
const long categoryId = 12345L;
|
||
|
|
var request = new UpdateCategoryIconRequest { IconIdentifier = "tabler:airplane" };
|
||
|
|
|
||
|
|
_iconSearchService.UpdateCategoryIconAsync(categoryId, request.IconIdentifier)
|
||
|
|
.Returns(Task.CompletedTask);
|
||
|
|
|
||
|
|
// Act
|
||
|
|
var response = await _controller.UpdateCategoryIconAsync(categoryId, request);
|
||
|
|
|
||
|
|
// Assert
|
||
|
|
response.Success.Should().BeTrue();
|
||
|
|
await _iconSearchService.Received(1).UpdateCategoryIconAsync(categoryId, request.IconIdentifier);
|
||
|
|
}
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public async Task UpdateCategoryIcon_应该处理分类不存在异常()
|
||
|
|
{
|
||
|
|
// Arrange
|
||
|
|
const long categoryId = 99999L;
|
||
|
|
var request = new UpdateCategoryIconRequest { IconIdentifier = "mdi:food" };
|
||
|
|
|
||
|
|
_iconSearchService.UpdateCategoryIconAsync(categoryId, request.IconIdentifier)
|
||
|
|
.Returns<Task>(_ => throw new Exception("分类不存在"));
|
||
|
|
|
||
|
|
// Act & Assert
|
||
|
|
await FluentActions.Invoking(() => _controller.UpdateCategoryIconAsync(categoryId, request))
|
||
|
|
.Should().ThrowAsync<Exception>()
|
||
|
|
.WithMessage("分类不存在");
|
||
|
|
}
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public async Task UpdateCategoryIcon_应该处理无效图标标识符异常()
|
||
|
|
{
|
||
|
|
// Arrange
|
||
|
|
const long categoryId = 12345L;
|
||
|
|
var request = new UpdateCategoryIconRequest { IconIdentifier = "" };
|
||
|
|
|
||
|
|
_iconSearchService.UpdateCategoryIconAsync(categoryId, request.IconIdentifier)
|
||
|
|
.Returns<Task>(_ => throw new ArgumentException("图标标识符不能为空"));
|
||
|
|
|
||
|
|
// Act & Assert
|
||
|
|
await FluentActions.Invoking(() => _controller.UpdateCategoryIconAsync(categoryId, request))
|
||
|
|
.Should().ThrowAsync<ArgumentException>()
|
||
|
|
.WithMessage("*图标标识符不能为空*");
|
||
|
|
}
|
||
|
|
|
||
|
|
#endregion
|
||
|
|
|
||
|
|
#region Integration Flow Tests
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public async Task IntegrationFlow_完整流程_生成关键字到搜索到更新()
|
||
|
|
{
|
||
|
|
// Arrange - Step 1: 生成搜索关键字
|
||
|
|
var keywordsRequest = new SearchKeywordsRequest { CategoryName = "餐饮" };
|
||
|
|
var keywords = new List<string> { "food", "restaurant" };
|
||
|
|
_iconSearchService.GenerateSearchKeywordsAsync(keywordsRequest.CategoryName)
|
||
|
|
.Returns(Task.FromResult(keywords));
|
||
|
|
|
||
|
|
// Arrange - Step 2: 搜索图标
|
||
|
|
var searchRequest = new SearchIconsRequest { Keywords = keywords };
|
||
|
|
var icons = new List<IconCandidate>
|
||
|
|
{
|
||
|
|
new() { CollectionName = "mdi", IconName = "food" },
|
||
|
|
new() { CollectionName = "mdi", IconName = "restaurant" }
|
||
|
|
};
|
||
|
|
_iconSearchService.SearchIconsAsync(keywords, 20)
|
||
|
|
.Returns(Task.FromResult(icons));
|
||
|
|
|
||
|
|
// Arrange - Step 3: 更新分类图标
|
||
|
|
const long categoryId = 12345L;
|
||
|
|
var updateRequest = new UpdateCategoryIconRequest { IconIdentifier = "mdi:food" };
|
||
|
|
_iconSearchService.UpdateCategoryIconAsync(categoryId, updateRequest.IconIdentifier)
|
||
|
|
.Returns(Task.CompletedTask);
|
||
|
|
|
||
|
|
// Act - Step 1: 生成关键字
|
||
|
|
var keywordsResponse = await _controller.GenerateSearchKeywordsAsync(keywordsRequest);
|
||
|
|
|
||
|
|
// Assert - Step 1
|
||
|
|
keywordsResponse.Success.Should().BeTrue();
|
||
|
|
keywordsResponse.Data!.Keywords.Should().BeEquivalentTo(keywords);
|
||
|
|
|
||
|
|
// Act - Step 2: 搜索图标
|
||
|
|
var searchResponse = await _controller.SearchIconsAsync(searchRequest);
|
||
|
|
|
||
|
|
// Assert - Step 2
|
||
|
|
searchResponse.Success.Should().BeTrue();
|
||
|
|
searchResponse.Data!.Count.Should().Be(2);
|
||
|
|
|
||
|
|
// Act - Step 3: 更新分类图标
|
||
|
|
var updateResponse = await _controller.UpdateCategoryIconAsync(categoryId, updateRequest);
|
||
|
|
|
||
|
|
// Assert - Step 3
|
||
|
|
updateResponse.Success.Should().BeTrue();
|
||
|
|
|
||
|
|
// 验证所有服务调用
|
||
|
|
await _iconSearchService.Received(1).GenerateSearchKeywordsAsync(keywordsRequest.CategoryName);
|
||
|
|
await _iconSearchService.Received(1).SearchIconsAsync(keywords, 20);
|
||
|
|
await _iconSearchService.Received(1).UpdateCategoryIconAsync(categoryId, updateRequest.IconIdentifier);
|
||
|
|
}
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public async Task IntegrationFlow_部分失败_关键字生成返回空()
|
||
|
|
{
|
||
|
|
// Arrange
|
||
|
|
var keywordsRequest = new SearchKeywordsRequest { CategoryName = "未知" };
|
||
|
|
_iconSearchService.GenerateSearchKeywordsAsync(keywordsRequest.CategoryName)
|
||
|
|
.Returns(Task.FromResult(new List<string>()));
|
||
|
|
|
||
|
|
// Act - Step 1: 生成关键字
|
||
|
|
var keywordsResponse = await _controller.GenerateSearchKeywordsAsync(keywordsRequest);
|
||
|
|
|
||
|
|
// Assert
|
||
|
|
keywordsResponse.Success.Should().BeTrue();
|
||
|
|
keywordsResponse.Data!.Keywords.Should().BeEmpty();
|
||
|
|
|
||
|
|
// 空关键字列表仍然可以传递给搜索接口,但通常会返回空结果
|
||
|
|
var searchRequest = new SearchIconsRequest { Keywords = keywordsResponse.Data.Keywords };
|
||
|
|
_iconSearchService.SearchIconsAsync(searchRequest.Keywords, 20)
|
||
|
|
.Returns(Task.FromResult(new List<IconCandidate>()));
|
||
|
|
|
||
|
|
var searchResponse = await _controller.SearchIconsAsync(searchRequest);
|
||
|
|
searchResponse.Success.Should().BeTrue();
|
||
|
|
searchResponse.Data!.Should().BeEmpty();
|
||
|
|
}
|
||
|
|
|
||
|
|
#endregion
|
||
|
|
|
||
|
|
#region DTO Validation Tests
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public async Task SearchKeywordsRequest_应该包含CategoryName字段()
|
||
|
|
{
|
||
|
|
// Arrange
|
||
|
|
var request = new SearchKeywordsRequest { CategoryName = "测试" };
|
||
|
|
|
||
|
|
// Assert
|
||
|
|
request.CategoryName.Should().Be("测试");
|
||
|
|
}
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public async Task SearchIconsRequest_应该包含Keywords字段()
|
||
|
|
{
|
||
|
|
// Arrange
|
||
|
|
var keywords = new List<string> { "test1", "test2" };
|
||
|
|
var request = new SearchIconsRequest { Keywords = keywords };
|
||
|
|
|
||
|
|
// Assert
|
||
|
|
request.Keywords.Should().BeEquivalentTo(keywords);
|
||
|
|
}
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public async Task UpdateCategoryIconRequest_应该包含IconIdentifier字段()
|
||
|
|
{
|
||
|
|
// Arrange
|
||
|
|
var request = new UpdateCategoryIconRequest { IconIdentifier = "mdi:test" };
|
||
|
|
|
||
|
|
// Assert
|
||
|
|
request.IconIdentifier.Should().Be("mdi:test");
|
||
|
|
}
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public async Task IconCandidateDto_应该包含所有必需字段()
|
||
|
|
{
|
||
|
|
// Arrange
|
||
|
|
var dto = new IconCandidateDto
|
||
|
|
{
|
||
|
|
CollectionName = "mdi",
|
||
|
|
IconName = "food",
|
||
|
|
IconIdentifier = "mdi:food"
|
||
|
|
};
|
||
|
|
|
||
|
|
// Assert
|
||
|
|
dto.CollectionName.Should().Be("mdi");
|
||
|
|
dto.IconName.Should().Be("food");
|
||
|
|
dto.IconIdentifier.Should().Be("mdi:food");
|
||
|
|
}
|
||
|
|
|
||
|
|
#endregion
|
||
|
|
}
|