节点在线、应用在线、配置在线使用令牌查询
大石头 authored at 2021-12-16 19:49:30
8.54 KiB
Stardust
extern alias web;
using System.Reflection;
using System.Runtime.Serialization;
using System.Text.Json;
using NewLife;
using web::Stardust.Web.Mcp;
using Xunit;
using Stardust.Data.Platform;

namespace ServerTest.Mcp;

/// <summary>MCP Action Schema 单元测试。通过反射扫描所有 IMcpAction 实现,
/// 验证 27 个 action 的元数据完整性和 RequiredResource 声明正确性</summary>
public class McpActionSchemaTests
{
    /// <summary>获取所有 IMcpAction 实现类型</summary>
    private static List<Type> GetActionTypes()
    {
        var assembly = typeof(IMcpAction).Assembly;
        return assembly.GetTypes()
            .Where(t => t is { IsClass: true, IsAbstract: false } && typeof(IMcpAction).IsAssignableFrom(t))
            .ToList();
    }

    /// <summary>使用 FormatterServices 创建未初始化实例(绕过构造函数依赖),
    /// 因为 action 的 Name/Description/Module/InputSchema/RequiredResource 都是硬编码返回值,不依赖实例字段</summary>
    private static IMcpAction CreateActionInstance(Type type)
    {
        var obj = FormatterServices.GetUninitializedObject(type);
        return (IMcpAction)obj;
    }

    [Fact]
    public void Total_ActionCount_Is27()
    {
        var types = GetActionTypes();

        Assert.True(types.Count >= 27, $"Expected at least 27 actions, found {types.Count}: {String.Join(", ", types.Select(t => t.Name))}");
    }

    [Fact]
    public void AllActions_HaveNonEmptyName()
    {
        var types = GetActionTypes();
        var emptyNames = new List<String>();

        foreach (var type in types)
        {
            var action = CreateActionInstance(type);
            if (action.Name.IsNullOrEmpty()) emptyNames.Add(type.Name);
        }

        Assert.Empty(emptyNames);
    }

    [Fact]
    public void AllActions_HaveNonEmptyDescription()
    {
        var types = GetActionTypes();
        var emptyDescs = new List<String>();

        foreach (var type in types)
        {
            var action = CreateActionInstance(type);
            if (action.Description.IsNullOrEmpty()) emptyDescs.Add(action.Name ?? type.Name);
        }

        Assert.Empty(emptyDescs);
    }

    [Fact]
    public void AllActions_HaveValidModule()
    {
        var validModules = new HashSet<McpModuleType>(McpModuleTypeExtensions.AllModules);
        var types = GetActionTypes();
        var invalidModules = new List<(String Action, String Module)>();

        foreach (var type in types)
        {
            var action = CreateActionInstance(type);
            if (!validModules.Contains(action.Module))
            {
                invalidModules.Add((action.Name ?? type.Name, action.Module.ToWireName()));
            }
        }

        Assert.Empty(invalidModules);
    }

    [Fact]
    public void AllActionNames_AreSnakeCase()
    {
        var types = GetActionTypes();
        var invalidNames = new List<String>();

        foreach (var type in types)
        {
            var action = CreateActionInstance(type);
            var name = action.Name;
            // snake_case: 只允许小写字母、数字、下划线
            if (name.IsNullOrEmpty() || !name.All(c => Char.IsLower(c) || Char.IsDigit(c) || c == '_'))
            {
                invalidNames.Add(name ?? type.Name);
            }
        }

        Assert.Empty(invalidNames);
    }

    [Fact]
    public void AllActionNames_AreUnique()
    {
        var types = GetActionTypes();
        var names = new List<String>();

        foreach (var type in types)
        {
            var action = CreateActionInstance(type);
            names.Add(action.Name);
        }

        var duplicates = names.GroupBy(n => n).Where(g => g.Count() > 1).ToList();
        Assert.Empty(duplicates);
    }

    [Theory]
    // System
    [InlineData("get_audit_log", "system")]
    // Node
    [InlineData("node_list_online", "node")]
    [InlineData("node_search", "node")]
    [InlineData("node_send_command", "node")]
    [InlineData("node_upgrade", "node")]
    // App
    [InlineData("app_list_online", "app")]
    [InlineData("app_send_command", "app")]
    [InlineData("app_resolve_service", "app")]
    [InlineData("app_search_service", "app")]
    [InlineData("app_restart", "app")]
    [InlineData("app_stop", "app")]
    [InlineData("app_start", "app")]
    // Config
    [InlineData("config_get", "config")]
    [InlineData("config_set", "config")]
    // Deploy
    [InlineData("deploy_list", "deploy")]
    [InlineData("deploy_list_versions", "deploy")]
    [InlineData("deploy_list_history", "deploy")]
    [InlineData("deploy_list_nodes", "deploy")]
    [InlineData("deploy_compile", "deploy")]
    [InlineData("deploy_install", "deploy")]
    [InlineData("pipeline_trigger", "deploy")]
    [InlineData("pipeline_get_run", "deploy")]
    [InlineData("pipeline_cancel", "deploy")]
    // Gateway
    [InlineData("gateway_list_routes", "gateway")]
    [InlineData("gateway_list_clusters", "gateway")]
    // Monitor
    [InlineData("monitor_trace_search", "monitor")]
    [InlineData("monitor_alarm_list", "monitor")]
    public void ExpectedAction_Exists_WithCorrectModule(String expectedName, String expectedModule)
    {
        var types = GetActionTypes();
        var found = false;

        foreach (var type in types)
        {
            var action = CreateActionInstance(type);
            if (action.Name == expectedName)
            {
                Assert.Equal(expectedModule, action.Module.ToWireName());
                found = true;
                break;
            }
        }

        Assert.True(found, $"Action '{expectedName}' not found. Available: {String.Join(", ", types.Select(t => CreateActionInstance(t).Name))}");
    }

    [Fact]
    public void RequiredResource_HasValidType_WhenNotNull()
    {
        var validTypes = new HashSet<String>
        {
            McpResourceType.Project.ToWireName(),
            McpResourceType.Node.ToWireName(),
            McpResourceType.App.ToWireName(),
            McpResourceType.Deploy.ToWireName(),
            McpResourceType.Pipeline.ToWireName(),
        };
        var types = GetActionTypes();
        var invalid = new List<(String Action, String Type)>();

        foreach (var type in types)
        {
            var action = CreateActionInstance(type);
            var req = action.RequiredResource;
            if (req != null && !req.Type.IsNullOrEmpty() && !validTypes.Contains(req.Type))
            {
                invalid.Add((action.Name, req.Type));
            }
        }

        Assert.Empty(invalid);
    }

    [Fact]
    public void RequiredResource_HasField_WhenTypeSpecified()
    {
        var types = GetActionTypes();
        var invalid = new List<String>();

        foreach (var type in types)
        {
            var action = CreateActionInstance(type);
            var req = action.RequiredResource;
            if (req != null && !req.Type.IsNullOrEmpty() && req.Field.IsNullOrEmpty())
            {
                invalid.Add(action.Name);
            }
        }

        Assert.Empty(invalid);
    }

    [Fact]
    public void InputSchema_IsValidJson_WhenNonDefault()
    {
        var types = GetActionTypes();
        var invalid = new List<String>();

        foreach (var type in types)
        {
            var action = CreateActionInstance(type);
            var schema = action.InputSchema;

            // default(JsonElement) 的 ValueKind 是 Undefined
            if (schema.ValueKind == JsonValueKind.Undefined) continue;

            // 非默认值应该是 Object 类型
            if (schema.ValueKind != JsonValueKind.Object)
            {
                invalid.Add(action.Name);
                continue;
            }

            // 如果是 Object,应该有 type 属性
            if (!schema.TryGetProperty("type", out _))
            {
                invalid.Add(action.Name);
            }
        }

        Assert.Empty(invalid);
    }

    /// <summary>列出所有 action 及其元数据(用于调试和文档生成)</summary>
    [Fact(Skip = "调试用:列出所有 action 元数据")]
    public void ListAllActions()
    {
        var types = GetActionTypes();

        foreach (var type in types)
        {
            var action = CreateActionInstance(type);
            var req = action.RequiredResource;
            var reqStr = req != null
                ? $"type={req.Type}, field={req.Field}, indirect={req.Indirect}, optional={req.Optional}"
                : "null";

            Console.WriteLine($"[{action.Module.ToWireName()}] {action.Name}: {action.Description} | RequiredResource: {reqStr}");
        }

        Assert.NotEmpty(types);
    }
}