节点在线、应用在线、配置在线使用令牌查询
大石头 authored at 2021-12-16 19:49:30
16.59 KiB
Stardust
using System.Text.Json;
using System.Text.Json.Nodes;
using NewLife.Data;
using Stardust.Data;
using Stardust.Data.Deployment;
using Stardust.Data.Monitors;
using Stardust.Mcp;
using Stardust.Mcp.Tools.Registry;
using Stardust.Models;
using Xunit;

namespace ServerTest.Mcp;

/// <summary>
/// 应用读工具复用 MVC/API 同一套查询:
/// App.Search、AppOnline.FindAllByProjectId、FindAllByAppId、FindAllByServiceName、App.QueryHealth。
/// 连真实 SQLite,自建数据用独立项目号并在 finally 清理。
/// </summary>
public class AppToolsReuseTests
{
    private const String Category = "mrx-cat";

    private static JsonElement Args(Object value) => JsonSerializer.SerializeToElement(value);

    private static McpContext Ctx(Int32 projectId) => new()
    {
        TokenId = 919221,
        ProjectId = projectId,
        UserId = 0,
        Scopes = new HashSet<String>(StringComparer.OrdinalIgnoreCase) { "apps:read" },
    };

    private static String Token() => "mrx" + Guid.NewGuid().ToString("n")[..6];

    private static void Cleanup(Int32 projectId)
    {
        foreach (var app in App.FindAllByProjectId(projectId).ToList())
            DeleteApp(app);
    }

    private static void DeleteApp(App app)
    {
        foreach (var online in AppOnline.FindAllByAppId(app.Id).ToList()) online.Delete();
        foreach (var svc in AppService.FindAllByAppId(app.Id).ToList()) svc.Delete();
        foreach (var consume in AppConsume.FindAllByAppId(app.Id).ToList()) consume.Delete();
        foreach (var deploy in AppDeploy.FindAllByAppId(app.Id).ToList())
        {
            foreach (var pipeline in AppPipeline.FindAllByDeployId(deploy.Id).ToList())
            {
                foreach (var run in AppPipelineRun.FindAllByPipelineId(pipeline.Id).ToList()) run.Delete();
                pipeline.Delete();
            }
            deploy.Delete();
        }
        foreach (var stat in AppMinuteStat.FindAllByAppId(app.Id).ToList()) stat.Delete();
        app.Delete();
    }

    [Fact(DisplayName = "app_list 走 App.Search,在线数按项目在线记录汇总")]
    public async Task AppList_UsesAppSearch_AndProjectOnlineCount()
    {
        const Int32 projectId = 910221;
        const Int32 otherProjectId = 910229;
        Cleanup(projectId);
        Cleanup(otherProjectId);

        var token = Token();
        App appA = null;
        App appB = null;
        App byDisplay = null;
        App wrongCategory = null;
        App otherProject = null;
        try
        {
            appA = InsertApp(projectId, token + "a", Category, true, "1.0");
            appB = InsertApp(projectId, token + "b", Category, false, "2.0");
            byDisplay = InsertApp(projectId, "zz" + Guid.NewGuid().ToString("n")[..8], Category, true, null, token);
            wrongCategory = InsertApp(projectId, token + "c", "other", true, null);
            otherProject = InsertApp(otherProjectId, token + "d", Category, true, null);

            InsertOnline(appA, projectId);
            InsertOnline(appA, otherProjectId);
            InsertOnline(appB, projectId);
            InsertOnline(appB, projectId);

            var tool = new AppListTool();
            var page = new PageParameter
            {
                PageIndex = 1,
                PageSize = 2,
                Sort = App.__.Id,
                RetrieveTotalCount = true,
            };
            var expected = App.Search(projectId, Category, null, DateTime.MinValue, DateTime.MinValue, token, page);
            var result = await tool.InvokeAsync(Args(new Dictionary<String, Object>
            {
                ["keyword"] = token,
                ["category"] = Category,
                ["page"] = 1,
                ["page_size"] = 2,
            }), Ctx(projectId));
            var node = JsonSerializer.SerializeToNode(result)!;
            var records = node["data"]!["records"]!.AsArray();

            Assert.Equal(expected.Select(e => e.Id).ToArray(), records.Select(e => e!["id"]!.GetValue<Int32>()).ToArray());
            Assert.Equal((Int32)page.TotalCount, node["pagination"]!["total"]!.GetValue<Int32>());
            Assert.Equal(3, node["pagination"]!["total"]!.GetValue<Int32>());
            var rowA = records.First(e => e!["id"]!.GetValue<Int32>() == appA.Id);
            Assert.Equal(1, rowA!["online"]!.GetValue<Int32>());
            Assert.DoesNotContain(records, e => e!["id"]!.GetValue<Int32>() == wrongCategory.Id);
            Assert.DoesNotContain(records, e => e!["id"]!.GetValue<Int32>() == otherProject.Id);

            var onlineByApp = AppOnline.FindAllByProjectId(projectId).GroupBy(e => e.AppId).ToDictionary(g => g.Key, g => g.Count());
            foreach (var row in records)
            {
                var id = row!["id"]!.GetValue<Int32>();
                var online = onlineByApp.TryGetValue(id, out var n) ? n : 0;
                Assert.Equal(online, row["online"]!.GetValue<Int32>());
            }

            var page2 = await tool.InvokeAsync(Args(new Dictionary<String, Object>
            {
                ["keyword"] = token,
                ["category"] = Category,
                ["page"] = 2,
                ["page_size"] = 2,
            }), Ctx(projectId));
            var node2 = JsonSerializer.SerializeToNode(page2)!;
            var pageParam = new PageParameter
            {
                PageIndex = 2,
                PageSize = 2,
                Sort = App.__.Id,
                RetrieveTotalCount = true,
            };
            var expected2 = App.Search(projectId, Category, null, DateTime.MinValue, DateTime.MinValue, token, pageParam);
            Assert.Equal(expected2.Select(e => e.Id).ToArray(), node2["data"]!["records"]!.AsArray().Select(e => e!["id"]!.GetValue<Int32>()).ToArray());
            Assert.Single(node2["data"]!["records"]!.AsArray());
        }
        finally
        {
            Cleanup(projectId);
            Cleanup(otherProjectId);
        }
    }

    [Fact(DisplayName = "app_topology 走 FindAllByAppId")]
    public async Task AppTopology_UsesFindAllByAppId()
    {
        const Int32 projectId = 910222;
        Cleanup(projectId);
        var token = Token();
        try
        {
            var app = InsertApp(projectId, token + "a", Category, true, null);
            var other = InsertApp(projectId, token + "b", Category, true, null);
            var provide = new AppService { AppId = app.Id, ServiceName = token + "-api", Address = "10.0.0.1:80", Healthy = true, Enable = true };
            var consume = new AppConsume { AppId = app.Id, ServiceName = token + "-db", Address = "10.0.0.2:3306", Enable = false };
            var noise = new AppService { AppId = other.Id, ServiceName = token + "-noise", Address = "10.0.0.3:80", Healthy = false, Enable = true };
            provide.Insert();
            consume.Insert();
            noise.Insert();

            var result = await new AppTopologyTool().InvokeAsync(Args(new Dictionary<String, Object> { ["app_id"] = app.Id }), Ctx(projectId));
            var node = JsonSerializer.SerializeToNode(result)!;
            var provides = node["data"]!["provides"]!.AsArray().Select(e => e!["ServiceName"]!.GetValue<String>()).ToArray();
            var consumes = node["data"]!["consumes"]!.AsArray().Select(e => e!["ServiceName"]!.GetValue<String>()).ToArray();

            Assert.Equal(AppService.FindAllByAppId(app.Id).Select(e => e.ServiceName).ToArray(), provides);
            Assert.Equal(AppConsume.FindAllByAppId(app.Id).Select(e => e.ServiceName).ToArray(), consumes);
            Assert.DoesNotContain(noise.ServiceName, provides);
        }
        finally
        {
            Cleanup(projectId);
        }
    }

    [Fact(DisplayName = "service_lookup 走项目内服务名包含匹配,精确匹配保持不变")]
    public async Task ServiceLookup_UsesContainsWithinProject()
    {
        const Int32 projectId = 910223;
        const Int32 otherProjectId = 910228;
        Cleanup(projectId);
        Cleanup(otherProjectId);
        var token = Token();
        try
        {
            var app = InsertApp(projectId, token + "a", Category, true, null);
            var other = InsertApp(otherProjectId, token + "b", Category, true, null);
            var hit = new AppService { AppId = app.Id, ServiceName = token + "-api", Address = "10.1.0.1:80", Healthy = true };
            var miss = new AppService { AppId = app.Id, ServiceName = "pay-" + token[..4], Address = "10.1.0.2:80", Healthy = false };
            var outside = new AppService { AppId = other.Id, ServiceName = token + "-ext", Address = "10.1.0.3:80", Healthy = true };
            var consume = new AppConsume { AppId = app.Id, ServiceName = token + "-client", Address = "10.1.0.4:80", Enable = true };
            hit.Insert();
            miss.Insert();
            outside.Insert();
            consume.Insert();

            Assert.DoesNotContain(AppService.FindAllByService(token), e => e.Id == hit.Id);

            var result = await new ServiceLookupTool().InvokeAsync(Args(new Dictionary<String, Object> { ["service_name"] = token }), Ctx(projectId));
            var node = JsonSerializer.SerializeToNode(result)!;
            var providers = node["data"]!["providers"]!.AsArray().Select(e => e!["ServiceName"]!.GetValue<String>()).OrderBy(e => e).ToArray();
            var consumers = node["data"]!["consumers"]!.AsArray().Select(e => e!["ServiceName"]!.GetValue<String>()).OrderBy(e => e).ToArray();

            Assert.Equal(AppService.FindAllByServiceName(token, projectId).Select(e => e.ServiceName).OrderBy(e => e).ToArray(), providers);
            Assert.Equal(AppConsume.FindAllByServiceName(token, projectId).Select(e => e.ServiceName).OrderBy(e => e).ToArray(), consumers);
            Assert.Contains(hit.ServiceName, providers);
            Assert.DoesNotContain(miss.ServiceName, providers);
            Assert.DoesNotContain(outside.ServiceName, providers);
            Assert.Contains(consume.ServiceName, consumers);

            var folded = token.ToUpperInvariant();
            var foldedResult = await new ServiceLookupTool().InvokeAsync(Args(new Dictionary<String, Object> { ["service_name"] = folded }), Ctx(projectId));
            var foldedNode = JsonSerializer.SerializeToNode(foldedResult)!;
            var foldedConsumers = foldedNode["data"]!["consumers"]!.AsArray().Select(e => e!["ServiceName"]!.GetValue<String>()).OrderBy(e => e).ToArray();
            Assert.Equal(AppConsume.FindAllByServiceName(folded, projectId).Select(e => e.ServiceName).OrderBy(e => e).ToArray(), foldedConsumers);
            Assert.Contains(consume.ServiceName, foldedConsumers);
            Assert.DoesNotContain(outside.ServiceName, foldedConsumers);
        }
        finally
        {
            Cleanup(projectId);
            Cleanup(otherProjectId);
        }
    }

    [Fact(DisplayName = "service_lookup 缺少 service_name 时返回参数错误")]
    public async Task ServiceLookup_MissingName_Throws()
    {
        var ex = await Assert.ThrowsAsync<McpException>(() => new ServiceLookupTool().InvokeAsync(Args(new { }), Ctx(910223)));
        Assert.Equal(-32602, ex.Code);
    }

    [Fact(DisplayName = "app_health 走 QueryHealth,本项目多条部署取编号更大的那条,错误日志只计错误级别")]
    public async Task AppHealth_UsesQueryHealth()
    {
        const Int32 projectId = 910224;
        const Int32 otherProjectId = 910227;
        Cleanup(projectId);
        var token = Token();
        AppClientLog infoLog = null;
        AppClientLog warnLog = null;
        AppClientLog errorLog = null;
        try
        {
            var app = InsertApp(projectId, token, Category, true, "3.0");
            InsertOnline(app, projectId);
            InsertOnline(app, projectId);

            var olderStat = new AppMinuteStat { AppId = app.Id, StatTime = new DateTime(2026, 11, 5, 8, 0, 0), Total = 10, Errors = 10 };
            var newerStat = new AppMinuteStat { AppId = app.Id, StatTime = new DateTime(2026, 11, 5, 8, 10, 0), Total = 200, Errors = 4 };
            olderStat.Insert();
            newerStat.Insert();

            var olderDeploy = new AppDeploy { Name = "c" + token, AppId = app.Id, ProjectId = projectId, Version = "0.9.0", Enable = true };
            var deploy = new AppDeploy { Name = "d" + token, AppId = app.Id, ProjectId = projectId, Version = "1.2.3", Enable = true };
            var otherDeploy = new AppDeploy { Name = "e" + token, AppId = app.Id, ProjectId = otherProjectId, Version = "9.9.9", Enable = true };
            olderDeploy.Insert();
            deploy.Insert();
            otherDeploy.Insert();
            Assert.True(deploy.Id > olderDeploy.Id);

            var olderPipeline = new AppPipeline { Name = "o" + token, DeployId = deploy.Id, ProjectId = projectId, Enable = true };
            var pipeline = new AppPipeline { Name = "p" + token, DeployId = deploy.Id, ProjectId = projectId, Enable = true };
            olderPipeline.Insert();
            pipeline.Insert();
            Assert.True(pipeline.Id > olderPipeline.Id);
            var seed = Math.Abs(Guid.NewGuid().GetHashCode() % 1_000_000_000) + 3;
            var trapRun = new AppPipelineRun { Id = seed, PipelineId = olderPipeline.Id, Status = PipelineStatus.Failed, CreateTime = DateTime.Now };
            var olderRun = new AppPipelineRun { Id = seed + 1, PipelineId = pipeline.Id, Status = PipelineStatus.Building, CreateTime = DateTime.Now };
            var newerRun = new AppPipelineRun { Id = seed + 2, PipelineId = pipeline.Id, Status = PipelineStatus.Success, CreateTime = DateTime.Now };
            trapRun.Insert();
            olderRun.Insert();
            newerRun.Insert();
            Assert.True(newerRun.Id > olderRun.Id);

            infoLog = new AppClientLog { AppId = app.Id, ClientId = "c" + token, Kind = "Info", Message = token + "-info" };
            warnLog = new AppClientLog { AppId = app.Id, ClientId = "c" + token, Kind = "Warn", Message = token + "-warn" };
            errorLog = new AppClientLog { AppId = app.Id, ClientId = "c" + token, Kind = "Error", Message = token + "-error" };
            infoLog.Insert();
            warnLog.Insert();
            errorLog.Insert();

            var snap = app.QueryHealth(projectId);
            var statPage = new PageParameter { PageIndex = 1, PageSize = 1, Sort = AppMinuteStat.__.StatTime, Desc = true };
            var latest = AppMinuteStat.Search(app.Id, 0, DateTime.MinValue, DateTime.MinValue, null, statPage).First();
            Assert.Equal(AppOnline.FindAllByAppId(app.Id).Count, snap.Online);
            Assert.Equal(2, snap.Online);
            Assert.Equal(Math.Round(4d / 200d, 4), latest.ErrorRate, 4);
            Assert.Equal(latest.ErrorRate, snap.ErrorRate!.Value);
            Assert.Equal("1.2.3", snap.DeployVersion);
            Assert.Equal(PipelineStatus.Success.ToString(), snap.PipelineStatus);
            Assert.Contains(errorLog.Message, snap.RecentErrors);
            Assert.DoesNotContain(infoLog.Message, snap.RecentErrors);
            Assert.DoesNotContain(warnLog.Message, snap.RecentErrors);

            var result = await new AppHealthTool().InvokeAsync(Args(new Dictionary<String, Object> { ["app_id"] = app.Id }), Ctx(projectId));
            var data = JsonSerializer.SerializeToNode(result)!["data"]!;
            Assert.Equal(snap.Online, data["online"]!.GetValue<Int32>());
            Assert.Equal(snap.ErrorRate!.Value, data["error_rate"]!.GetValue<Double>());
            Assert.Equal(snap.DeployVersion, data["deploy_version"]!.GetValue<String>());
            Assert.Equal(snap.PipelineStatus, data["pipeline_status"]!.GetValue<String>());
            Assert.Equal(snap.RecentErrors.Length, data["recent_errors"]!.AsArray().Count);
            Assert.Contains(errorLog.Message, data["recent_errors"]!.AsArray().Select(e => e!.GetValue<String>()));
        }
        finally
        {
            if (infoLog != null && infoLog.Id > 0) infoLog.Delete();
            if (warnLog != null && warnLog.Id > 0) warnLog.Delete();
            if (errorLog != null && errorLog.Id > 0) errorLog.Delete();
            Cleanup(projectId);
        }
    }

    private static App InsertApp(Int32 projectId, String name, String category, Boolean enable, String version, String displayName = null)
    {
        var app = new App
        {
            ProjectId = projectId,
            Name = name,
            DisplayName = displayName,
            Category = category,
            Enable = enable,
            Version = version,
        };
        app.Insert();
        return app;
    }

    private static void InsertOnline(App app, Int32 projectId)
    {
        var online = new AppOnline
        {
            AppId = app.Id,
            ProjectId = projectId,
            Client = "c" + Guid.NewGuid().ToString("n")[..12],
        };
        online.Insert();
    }
}