4fe0b5a963
本次提交完成了项目核心基础架构升级: 1. 新增动态API中间件与权限控制系统,支持匿名/鉴权接口分离 2. 搭建云同步服务体系,包含认证、任务同步、安全策略等核心模块 3. 实现语音控制全链路,从STT/意图解析到命令执行 4. 新增任务类型、附件实体与相关仓储接口 5. 重构前端配置与代理规则,统一后端端口为5057 6. 新增多平台测试项目与CI脚本优化 7. 完善项目文档与代码注释规范 移除了旧版迁移文件与冗余代理配置,调整项目结构适配跨平台部署需求。
493 lines
19 KiB
C#
493 lines
19 KiB
C#
using System.ComponentModel;
|
||
using System.Reflection;
|
||
using System.Text.Json;
|
||
using Microsoft.Extensions.DependencyInjection;
|
||
using ModelContextProtocol.Protocol;
|
||
using ModelContextProtocol.Server;
|
||
using Hua.Todo.HttpApi.AspNetCore.Mcp;
|
||
using Hua.Todo.HttpApi.Interfaces;
|
||
using Hua.Todo.Application.Models;
|
||
using Hua.Todo.Application.Services.Interfaces;
|
||
using Xunit;
|
||
|
||
namespace Hua.Todo.Host.Tests;
|
||
|
||
/// <summary>
|
||
/// DynamicMcpToolExtensions 单元测试。
|
||
/// 重点验证命名推导、描述生成、行为注解等纯逻辑,以及动态工具注册。
|
||
/// </summary>
|
||
public class DynamicMcpToolExtensionsTests
|
||
{
|
||
#region 命名推导
|
||
|
||
/// <summary>
|
||
/// DeriveServiceName:ITaskService → task
|
||
/// </summary>
|
||
[Fact]
|
||
public void DeriveServiceName_ITaskService_ReturnsTask()
|
||
{
|
||
var result = DynamicMcpToolExtensions.DeriveServiceName(typeof(ITaskService));
|
||
Assert.Equal("task", result);
|
||
}
|
||
|
||
/// <summary>
|
||
/// DeriveServiceName:复合接口名转 snake_case。
|
||
/// </summary>
|
||
[Fact]
|
||
public void DeriveServiceName_MultiWordInterface_ReturnsSnakeCase()
|
||
{
|
||
// 假设存在 ICloudSyncService → cloud_sync
|
||
// 用动态类型模拟
|
||
var result = DynamicMcpToolExtensions.DeriveServiceName(typeof(ITestMultiWordService));
|
||
Assert.Equal("test_multi_word", result);
|
||
}
|
||
|
||
/// <summary>
|
||
/// ToSnakeCase:PascalCase → snake_case
|
||
/// </summary>
|
||
[Fact]
|
||
public void ToSnakeCase_PascalCase_ConvertsToSnakeCase()
|
||
{
|
||
Assert.Equal("get_all", DynamicMcpToolExtensions.ToSnakeCase("GetAll"));
|
||
Assert.Equal("create_task", DynamicMcpToolExtensions.ToSnakeCase("CreateTask"));
|
||
Assert.Equal("toggle_complete", DynamicMcpToolExtensions.ToSnakeCase("ToggleComplete"));
|
||
Assert.Equal("id", DynamicMcpToolExtensions.ToSnakeCase("Id"));
|
||
Assert.Equal("", DynamicMcpToolExtensions.ToSnakeCase(""));
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region 工具名组合
|
||
|
||
/// <summary>
|
||
/// 完整工具名:prefix + 方法名 = task_get_all_tasks
|
||
/// 验证 StripAsyncSuffix 和 ToSnakeCase 的组合效果。
|
||
/// </summary>
|
||
[Fact]
|
||
public void ToolNaming_PrefixAndMethod_FormatsCorrectly()
|
||
{
|
||
var prefix = DynamicMcpToolExtensions.DeriveServiceName(typeof(ITaskService));
|
||
Assert.Equal("task", prefix);
|
||
|
||
// GetActiveTasksAsync → get_active_tasks
|
||
var methodSuffix = DynamicMcpToolExtensions.ToSnakeCase(
|
||
StripAsyncSuffix("GetActiveTasksAsync"));
|
||
Assert.Equal("get_active_tasks", methodSuffix);
|
||
}
|
||
|
||
/// <summary>
|
||
/// StripAsyncSuffix:去掉 Async 后缀。
|
||
/// </summary>
|
||
[Fact]
|
||
public void StripAsyncSuffix_RemovesAsync()
|
||
{
|
||
Assert.Equal("GetAllTasks", StripAsyncSuffix("GetAllTasksAsync"));
|
||
Assert.Equal("Create", StripAsyncSuffix("CreateAsync"));
|
||
Assert.Equal("Toggle", StripAsyncSuffix("ToggleAsync"));
|
||
Assert.Equal("Delete", StripAsyncSuffix("DeleteAsync"));
|
||
Assert.Equal("GetById", StripAsyncSuffix("GetById")); // 无 Async 后缀不修改
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region 描述生成
|
||
|
||
/// <summary>
|
||
/// GetMethodDescription:方法名 → 中文描述
|
||
/// </summary>
|
||
[Fact]
|
||
public void GetMethodDescription_GeneratesChineseDescription()
|
||
{
|
||
var getMethod = typeof(ITaskService).GetMethod(nameof(ITaskService.GetAllTasksAsync))!;
|
||
var createMethod = typeof(ITaskService).GetMethod(nameof(ITaskService.CreateTaskAsync))!;
|
||
var updateMethod = typeof(ITaskService).GetMethod(nameof(ITaskService.UpdateTaskAsync))!;
|
||
var deleteMethod = typeof(ITaskService).GetMethod(nameof(ITaskService.DeleteTaskAsync))!;
|
||
var toggleMethod = typeof(ITaskService).GetMethod(nameof(ITaskService.ToggleCompleteAsync))!;
|
||
|
||
Assert.StartsWith("获取", GetMethodDescription(getMethod));
|
||
Assert.StartsWith("创建", GetMethodDescription(createMethod));
|
||
Assert.StartsWith("更新", GetMethodDescription(updateMethod));
|
||
Assert.StartsWith("删除", GetMethodDescription(deleteMethod));
|
||
Assert.StartsWith("切换", GetMethodDescription(toggleMethod));
|
||
}
|
||
|
||
/// <summary>
|
||
/// GetMethodDescription:带 DescriptionAttribute 时优先使用。
|
||
/// </summary>
|
||
[Fact]
|
||
public void GetMethodDescription_UsesDescriptionAttribute()
|
||
{
|
||
var method = typeof(ITestAnnotatedService).GetMethod(nameof(ITestAnnotatedService.DoSomething))!;
|
||
var desc = GetMethodDescription(method);
|
||
Assert.Equal("执行自定义操作", desc);
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region 行为注解
|
||
|
||
/// <summary>
|
||
/// IsReadOnlyMethod:Get/List/Query/Search 前缀返回 true。
|
||
/// </summary>
|
||
[Fact]
|
||
public void IsReadOnlyMethod_GetPrefix_ReturnsTrue()
|
||
{
|
||
Assert.True(IsReadOnlyMethod("GetAllTasksAsync"));
|
||
Assert.True(IsReadOnlyMethod("ListSubTodosAsync"));
|
||
Assert.True(IsReadOnlyMethod("QueryByDateAsync"));
|
||
Assert.True(IsReadOnlyMethod("SearchByKeywordAsync"));
|
||
}
|
||
|
||
/// <summary>
|
||
/// IsReadOnlyMethod:非只读前缀返回 false。
|
||
/// </summary>
|
||
[Fact]
|
||
public void IsReadOnlyMethod_NonReadPrefix_ReturnsFalse()
|
||
{
|
||
Assert.False(IsReadOnlyMethod("CreateTaskAsync"));
|
||
Assert.False(IsReadOnlyMethod("UpdateTaskAsync"));
|
||
Assert.False(IsReadOnlyMethod("DeleteTaskAsync"));
|
||
Assert.False(IsReadOnlyMethod("ToggleCompleteAsync"));
|
||
}
|
||
|
||
/// <summary>
|
||
/// IsDestructiveMethod:Delete/Remove 前缀返回 true。
|
||
/// </summary>
|
||
[Fact]
|
||
public void IsDestructiveMethod_DeletePrefix_ReturnsTrue()
|
||
{
|
||
Assert.True(IsDestructiveMethod("DeleteTaskAsync"));
|
||
Assert.True(IsDestructiveMethod("RemoveItemAsync"));
|
||
}
|
||
|
||
/// <summary>
|
||
/// IsDestructiveMethod:非破坏性操作返回 false。
|
||
/// </summary>
|
||
[Fact]
|
||
public void IsDestructiveMethod_NonDestructive_ReturnsFalse()
|
||
{
|
||
Assert.False(IsDestructiveMethod("GetAllTasksAsync"));
|
||
Assert.False(IsDestructiveMethod("CreateTaskAsync"));
|
||
Assert.False(IsDestructiveMethod("UpdateTaskAsync"));
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region 动态工具注册(集成测试)
|
||
|
||
/// <summary>
|
||
/// 验证 WithDynamicApiTools 扫描 IDynamicApiService 所在程序集,能发现所有实现接口并生成工具。
|
||
/// ITaskService(9)+ IVoiceService(4)+ IMeetingService(5)+ IAttachmentService(5)= 23。
|
||
/// CloudSync 服务接口已通过 [RemoteService(IsEnabled = false)] 排除。
|
||
/// </summary>
|
||
[Fact]
|
||
public void WithDynamicApiTools_ScansAssemblyAndRegistersTools()
|
||
{
|
||
var services = new ServiceCollection();
|
||
services.AddScoped<ITaskService, MockTaskService>();
|
||
services.AddLogging();
|
||
services.AddMcpServer().WithDynamicApiTools();
|
||
|
||
var provider = services.BuildServiceProvider();
|
||
var tools = provider.GetServices<McpServerTool>().ToList();
|
||
Assert.NotEmpty(tools);
|
||
|
||
// ITaskService(9)+ IVoiceService(4)+ IMeetingService(5)+ IAttachmentService(5)= 23
|
||
Assert.Equal(23, tools.Count);
|
||
|
||
// ITaskService 工具名应与推导规则一致
|
||
var toolNames = tools.Select(t => t.ProtocolTool.Name).OrderBy(n => n).ToList();
|
||
Assert.Contains("task_get_all_tasks", toolNames);
|
||
Assert.Contains("task_get_task_by_id", toolNames);
|
||
Assert.Contains("task_get_active_tasks", toolNames);
|
||
Assert.Contains("task_get_completed_tasks", toolNames);
|
||
Assert.Contains("task_create_task", toolNames);
|
||
Assert.Contains("task_update_task", toolNames);
|
||
Assert.Contains("task_toggle_complete", toolNames);
|
||
Assert.Contains("task_delete_task", toolNames);
|
||
Assert.Contains("task_get_sub_tasks", toolNames);
|
||
|
||
// IVoiceService 工具名也应存在
|
||
Assert.Contains("voice_execute_command", toolNames);
|
||
Assert.Contains("voice_confirm_command", toolNames);
|
||
Assert.Contains("voice_get_breakdown", toolNames);
|
||
Assert.Contains("voice_confirm_breakdown", toolNames);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 验证生成的工具携带正确的行为注解(ReadOnly/Destructive)。
|
||
/// </summary>
|
||
[Fact]
|
||
public void WithDynamicApiTools_ToolsHaveCorrectBehaviorAnnotations()
|
||
{
|
||
// Arrange
|
||
var services = new ServiceCollection();
|
||
services.AddScoped<ITaskService, MockTaskService>();
|
||
|
||
// Act
|
||
services.AddMcpServer()
|
||
.WithDynamicApiTools();
|
||
|
||
var provider = services.BuildServiceProvider();
|
||
var tools = provider.GetServices<McpServerTool>().ToList();
|
||
|
||
// Assert
|
||
var getTool = tools.First(t => t.ProtocolTool.Name == "task_get_all_tasks");
|
||
Assert.True(getTool.ProtocolTool.Annotations?.ReadOnlyHint == true);
|
||
|
||
var deleteTool = tools.First(t => t.ProtocolTool.Name == "task_delete_task");
|
||
Assert.True(deleteTool.ProtocolTool.Annotations?.DestructiveHint == true);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 验证所有生成的 MCP 工具都携带有意义的中文描述。
|
||
/// 描述不得为空,且至少包含一个中文字符。
|
||
/// </summary>
|
||
[Fact]
|
||
public void WithDynamicApiTools_AllToolsHaveMeaningfulDescriptions()
|
||
{
|
||
// Arrange
|
||
var services = new ServiceCollection();
|
||
services.AddScoped<ITaskService, MockTaskService>();
|
||
|
||
// Act
|
||
services.AddMcpServer()
|
||
.WithDynamicApiTools();
|
||
|
||
var provider = services.BuildServiceProvider();
|
||
var tools = provider.GetServices<McpServerTool>().ToList();
|
||
|
||
// Assert: 每个工具的描述非空且包含中文
|
||
foreach (var tool in tools)
|
||
{
|
||
var desc = tool.ProtocolTool.Description;
|
||
Assert.False(string.IsNullOrWhiteSpace(desc),
|
||
$"工具 {tool.ProtocolTool.Name} 的描述不应为空");
|
||
|
||
// 验证包含中文字符(Unicode 范围 CJK Unified Ideographs)
|
||
var hasChinese = desc.Any(c => c >= 0x4E00 && c <= 0x9FFF);
|
||
Assert.True(hasChinese,
|
||
$"工具 {tool.ProtocolTool.Name} 的描述应包含中文,当前: {desc}");
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 验证有参数的工具(CreateTask、UpdateTask、ToggleComplete、DeleteTask、GetTaskById、GetSubTasks)
|
||
/// 生成了 InputSchema;无参数的工具(GetAllTasks、GetActiveTasks、GetCompletedTasks)
|
||
/// 的 InputSchema 为默认空结构。
|
||
/// </summary>
|
||
[Fact]
|
||
public void WithDynamicApiTools_ToolsHaveInputSchemas()
|
||
{
|
||
// Arrange
|
||
var services = new ServiceCollection();
|
||
services.AddScoped<ITaskService, MockTaskService>();
|
||
|
||
// Act
|
||
services.AddMcpServer()
|
||
.WithDynamicApiTools();
|
||
|
||
var provider = services.BuildServiceProvider();
|
||
var tools = provider.GetServices<McpServerTool>().ToList();
|
||
|
||
// 有参数的方法应生成非空 InputSchema
|
||
var toolsWithParams = new[] { "task_create_task", "task_update_task", "task_toggle_complete",
|
||
"task_delete_task", "task_get_task_by_id", "task_get_sub_tasks" };
|
||
foreach (var name in toolsWithParams)
|
||
{
|
||
var tool = tools.First(t => t.ProtocolTool.Name == name);
|
||
var schema = tool.ProtocolTool.InputSchema;
|
||
Assert.True(schema.ValueKind != JsonValueKind.Undefined,
|
||
$"工具 {name} 应有 InputSchema");
|
||
}
|
||
|
||
// 无参数的方法应有空 InputSchema(SDK 默认行为)
|
||
var toolsWithoutParams = new[] { "task_get_all_tasks", "task_get_active_tasks", "task_get_completed_tasks" };
|
||
foreach (var name in toolsWithoutParams)
|
||
{
|
||
var tool = tools.First(t => t.ProtocolTool.Name == name);
|
||
// 无参数方法:InputSchema 可能为 Undefined 或空对象
|
||
Assert.True(tool.ProtocolTool.InputSchema.ValueKind == JsonValueKind.Undefined
|
||
|| (tool.ProtocolTool.InputSchema.ValueKind == JsonValueKind.Object
|
||
&& tool.ProtocolTool.InputSchema.GetProperty("type").GetString() == "object"),
|
||
$"无参数工具 {name} 的 InputSchema 应为 Undefined 或空 object 类型");
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 通过 DI 直接解析 MockTaskService 并调用其方法,
|
||
/// 验证服务实现可正常工作(返回预期类型与值)。
|
||
/// </summary>
|
||
[Fact]
|
||
public async Task MockTaskService_Invocation_ReturnsExpectedResults()
|
||
{
|
||
// Arrange
|
||
var services = new ServiceCollection();
|
||
services.AddScoped<ITaskService, MockTaskService>();
|
||
var provider = services.BuildServiceProvider();
|
||
var service = provider.GetRequiredService<ITaskService>();
|
||
|
||
// Act & Assert: 无参查询方法
|
||
var allTasks = await service.GetAllTasksAsync();
|
||
Assert.NotNull(allTasks);
|
||
Assert.Empty(allTasks);
|
||
|
||
var activeTasks = await service.GetActiveTasksAsync();
|
||
Assert.NotNull(activeTasks);
|
||
Assert.Empty(activeTasks);
|
||
|
||
var completedTasks = await service.GetCompletedTasksAsync();
|
||
Assert.NotNull(completedTasks);
|
||
Assert.Empty(completedTasks);
|
||
|
||
// Act & Assert: 单参查询方法
|
||
var testId = Guid.NewGuid();
|
||
var taskById = await service.GetTaskByIdAsync(testId);
|
||
Assert.Null(taskById);
|
||
|
||
var subTasks = await service.GetSubTasksAsync(testId);
|
||
Assert.NotNull(subTasks);
|
||
Assert.Empty(subTasks);
|
||
|
||
// Act & Assert: 创建任务
|
||
var created = await service.CreateTaskAsync(new CreateTaskDto { Title = "测试任务" });
|
||
Assert.NotNull(created);
|
||
|
||
// Act & Assert: 更新任务
|
||
var updated = await service.UpdateTaskAsync(new UpdateTaskDto { Id = testId, Title = "已更新" });
|
||
Assert.NotNull(updated);
|
||
|
||
// Act & Assert: 切换完成状态
|
||
var toggled = await service.ToggleCompleteAsync(testId);
|
||
Assert.NotNull(toggled);
|
||
|
||
// Act & Assert: 删除任务(void 方法不抛异常即为成功)
|
||
await service.DeleteTaskAsync(testId);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 验证 MCP 工具可被调用(通过 McpServerTool.InvokeAsync)。
|
||
/// 创建一个最小化的 RequestContext 并调用 task_get_all_tasks 工具。
|
||
/// </summary>
|
||
[Fact]
|
||
public async Task WithDynamicApiTools_InvokeTaskGetAllTasks_ReturnsResult()
|
||
{
|
||
// Arrange
|
||
var services = new ServiceCollection();
|
||
services.AddScoped<ITaskService, MockTaskService>();
|
||
services.AddMcpServer()
|
||
.WithDynamicApiTools();
|
||
|
||
var provider = services.BuildServiceProvider();
|
||
var tool = provider.GetServices<McpServerTool>()
|
||
.First(t => t.ProtocolTool.Name == "task_get_all_tasks");
|
||
|
||
// 通过 RuntimeHelpers 创建未初始化的 RequestContext,
|
||
// 然后手动注入 ServiceProvider(工具调用只需 Services 属性)。
|
||
var ctxType = typeof(RequestContext<>).MakeGenericType(typeof(CallToolRequestParams));
|
||
var requestContext = System.Runtime.CompilerServices.RuntimeHelpers
|
||
.GetUninitializedObject(ctxType) as RequestContext<CallToolRequestParams>;
|
||
|
||
Assert.NotNull(requestContext);
|
||
// 注入 ServiceProvider 以便 createTargetFunc 能解析 ITaskService
|
||
typeof(MessageContext).GetProperty("Services")!.SetValue(requestContext, provider);
|
||
|
||
// Act
|
||
var result = await tool.InvokeAsync(requestContext!, CancellationToken.None);
|
||
|
||
// Assert
|
||
Assert.NotNull(result);
|
||
Assert.False(result.IsError == true, $"调用应成功");
|
||
Assert.NotNull(result.Content);
|
||
Assert.NotEmpty(result.Content);
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region 辅助方法(暴露 private 方法用于测试)
|
||
|
||
private static string StripAsyncSuffix(string name)
|
||
=> name.EndsWith("Async") ? name[..^5] : name;
|
||
|
||
private static bool IsReadOnlyMethod(string name)
|
||
=> name.StartsWith("Get") || name.StartsWith("List")
|
||
|| name.StartsWith("Query") || name.StartsWith("Search");
|
||
|
||
private static bool IsDestructiveMethod(string name)
|
||
=> name.StartsWith("Delete") || name.StartsWith("Remove");
|
||
|
||
private static string GetMethodDescription(MethodInfo method)
|
||
{
|
||
var descAttr = method.GetCustomAttribute<DescriptionAttribute>();
|
||
if (descAttr != null) return descAttr.Description;
|
||
|
||
var name = method.Name;
|
||
if (name.StartsWith("Get")) return $"获取{StripAsyncSuffix(name[3..])}";
|
||
if (name.StartsWith("Create")) return $"创建{StripAsyncSuffix(name[6..])}";
|
||
if (name.StartsWith("Update")) return $"更新{StripAsyncSuffix(name[6..])}";
|
||
if (name.StartsWith("Delete")) return $"删除{StripAsyncSuffix(name[6..])}";
|
||
if (name.StartsWith("Toggle")) return $"切换{StripAsyncSuffix(name[6..])}";
|
||
return StripAsyncSuffix(name);
|
||
}
|
||
|
||
#endregion
|
||
}
|
||
|
||
#region 测试用类型
|
||
|
||
/// <summary>
|
||
/// Mock 多词接口,用于测试 snake_case 转换。
|
||
/// </summary>
|
||
public interface ITestMultiWordService : IDynamicApiService
|
||
{
|
||
Task<int> GetValueAsync();
|
||
}
|
||
|
||
/// <summary>
|
||
/// Mock 带 DescriptionAttribute 的接口。
|
||
/// </summary>
|
||
public interface ITestAnnotatedService : IDynamicApiService
|
||
{
|
||
[Description("执行自定义操作")]
|
||
Task<int> DoSomething();
|
||
}
|
||
|
||
/// <summary>
|
||
/// Mock 动态 API 服务接口(含简单参数方法)。
|
||
/// </summary>
|
||
public interface IMockTestService : IDynamicApiService
|
||
{
|
||
[Description("获取数据")]
|
||
Task<string> GetDataAsync();
|
||
|
||
[Description("更新数据")]
|
||
Task UpdateDataAsync(int id, string value);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Mock 动态 API 服务实现。
|
||
/// </summary>
|
||
public class MockTestServiceImpl : IMockTestService
|
||
{
|
||
public Task<string> GetDataAsync() => Task.FromResult("mock data");
|
||
public Task UpdateDataAsync(int id, string value) => Task.CompletedTask;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Mock ITaskService 实现(桩实现,仅用于验证工具注册,不执行实际逻辑)。
|
||
/// </summary>
|
||
public class MockTaskService : ITaskService
|
||
{
|
||
public Task<List<TaskDto>> GetAllTasksAsync() => Task.FromResult(new List<TaskDto>());
|
||
public Task<TaskDto?> GetTaskByIdAsync(Guid id) => Task.FromResult<TaskDto?>(null);
|
||
public Task<List<TaskDto>> GetActiveTasksAsync() => Task.FromResult(new List<TaskDto>());
|
||
public Task<List<TaskDto>> GetCompletedTasksAsync() => Task.FromResult(new List<TaskDto>());
|
||
public Task<TaskDto> CreateTaskAsync(CreateTaskDto dto) => Task.FromResult(new TaskDto());
|
||
public Task<TaskDto> UpdateTaskAsync(UpdateTaskDto dto) => Task.FromResult(new TaskDto());
|
||
public Task<TaskDto> ToggleCompleteAsync(Guid id) => Task.FromResult(new TaskDto());
|
||
public Task DeleteTaskAsync(Guid id) => Task.CompletedTask;
|
||
public Task<List<TaskDto>> GetSubTasksAsync(Guid parentTaskId) => Task.FromResult(new List<TaskDto>());
|
||
}
|
||
|
||
#endregion
|