节点在线、应用在线、配置在线使用令牌查询
大石头 authored at 2021-12-16 19:49:30
4.19 KiB
Stardust
using System.Text.Json;
using NewLife.Cube;
using Stardust.Data.Nodes;
using Stardust.WebApiTests.Fixtures;
using Xunit;
using Xunit.Abstractions;

namespace Stardust.WebApiTests.Areas;

/// <summary>
/// B1 节点命令下发(POST /Nodes/NodeCommand/Send)契约测试(CARD-NODE-06 定稿 §3.0 载体)。
/// TDD 先红:Send 路由未实现时全部用例 404(红),实现后逐条转绿。
/// 错误载体:HTTP 200 + 信封 Code!=0(Cube 仓库惯例:校验类 Code=400,投递失败 Code=500),
/// 前端 api-core 拦截器对 Code!=0 拒绝并给出可见提示(lead 裁定 2026-11-09 00:50)。
/// </summary>
[Collection(WebApiCollection.Name)]
public class NodeCommandSendTests
{
    private static readonly JsonSerializerOptions _json = new(JsonSerializerDefaults.Web);

    private readonly ApiClient _api;

    public NodeCommandSendTests(WebApiTestFactory factory, ITestOutputHelper output) => _api = new(factory, output);

    /// <summary>POST body {nodeId,command},断言 HTTP 200 + 信封 Code=400 + 消息非空。</summary>
    private async Task<ApiResponse<JsonElement>> SendAndExpectBadRequestAsync(Object payload)
    {
        var resp = await _api.PostAsJsonAsync("Nodes/NodeCommand/Send", payload);
        var raw = await resp.Content.ReadAsStringAsync();

        Assert.True(resp.IsSuccessStatusCode, $"Send 应答非 2xx:{(Int32)resp.StatusCode} {raw}");

        var api = JsonSerializer.Deserialize<ApiResponse<JsonElement>>(raw, _json);
        Assert.NotNull(api);
        Assert.True(api!.Code == 400, $"信封 Code 应为 400,实际 {api.Code}:{raw}");
        Assert.False(String.IsNullOrEmpty(api.Message), "错误信封必须携带可见提示消息");

        return api;
    }

    [Fact]
    public async Task Send_NodeIdInvalid_Returns400()
    {
        await SendAndExpectBadRequestAsync(new { nodeId = 0, command = "restart" });
        await SendAndExpectBadRequestAsync(new { nodeId = -1, command = "restart" });
    }

    [Fact]
    public async Task Send_CommandEmpty_Returns400()
    {
        await SendAndExpectBadRequestAsync(new { nodeId = 1, command = "" });
        await SendAndExpectBadRequestAsync(new { nodeId = 1, command = (String?)null });
    }

    [Fact]
    public async Task Send_NodeNotFound_Returns400()
    {
        // 临时 SQLite 库节点极少,该编号必然不存在
        var api = await SendAndExpectBadRequestAsync(new { nodeId = 999_999_999, command = "restart" });

        Assert.Contains("节点不存在", api.Message);
    }

    [Fact]
    public async Task Send_WithoutToken_Returns401()
    {
        var resp = await _api.PostAsJsonAsync("Nodes/NodeCommand/Send", new { nodeId = 1, command = "restart" }, auth: false);
        var raw = await resp.Content.ReadAsStringAsync();

        // 匿名必须被拒:HTTP 401,或 200 + 信封 Code=401(两种拒绝形态之一)
        var bodyCode = 0;
        if (resp.IsSuccessStatusCode)
        {
            var api = JsonSerializer.Deserialize<ApiResponse<JsonElement>>(raw, _json);
            bodyCode = api!.Code;
        }
        var status = (Int32)resp.StatusCode;

        Assert.True(status == 401 || bodyCode == 401, $"匿名访问未被 401 拒绝:HTTP {status},body={raw}");
    }

    [Fact]
    public async Task Send_OfflineNode_ReturnsFailureMessage()
    {
        // 临时 SQLite 造节点(数据环境铁律:仅本地栈);宿主 StarServer 指向不可达 → 确定性失败
        var node = new Node { Name = "B1测试节点", Code = "b1-send-test-node" };
        node.Insert();

        var resp = await _api.PostAsJsonAsync("Nodes/NodeCommand/Send", new { nodeId = node.ID, command = "restart" });
        var raw = await resp.Content.ReadAsStringAsync();

        Assert.True(resp.IsSuccessStatusCode, $"Send 应答非 2xx:{(Int32)resp.StatusCode} {raw}");
        var api = JsonSerializer.Deserialize<ApiResponse<JsonElement>>(raw, _json);
        Assert.NotNull(api);
        Assert.NotEqual(0, api!.Code);   // 失败必须非 0 码(超时/连接不可用 → 500),前端拦截器据此给可见提示
        Assert.False(String.IsNullOrEmpty(api.Message), "失败信封必须携带原因,旧壳将显示「指令下发失败:{原因}」");
    }
}