ci/woodpecker/push/main-amd64-build Pipeline was successful
Namespaces, assemblies, solution, binaries, images, systemd units, config paths, environment variables, protocol methods and the Linux user are all renamed. Breaking: existing databases, node identities and installed units must be recreated.
137 lines
7.2 KiB
C#
137 lines
7.2 KiB
C#
using System.Net;
|
|
using System.Text.Json.Nodes;
|
|
using Fleet.Core;
|
|
using MoShiro.Protocol;
|
|
using MoShiro.TestSupport;
|
|
|
|
namespace MoShiro.Backend.Tests;
|
|
|
|
[Collection("backend")]
|
|
public sealed class LiveTests
|
|
{
|
|
private readonly BackendFixture _backend;
|
|
|
|
public LiveTests(BackendFixture backend)
|
|
{
|
|
_backend = backend;
|
|
}
|
|
|
|
private static long Now() => TimeProvider.System.GetUtcNow().ToUnixTimeMilliseconds();
|
|
|
|
[Fact]
|
|
public async Task Stream_StartsWithASnapshot_ThenPushesNewBucketsAndNodeChanges()
|
|
{
|
|
await _backend.SiteMustBePublicAsync();
|
|
AgentSimulator agent = await _backend.ConnectAgentAsync();
|
|
string id = agent.NodeId.Value;
|
|
|
|
await using SseClient sse = await SseClient.ConnectAsync(_backend.Anonymous, $"/api/public/live?topics=nodes,series:{_backend.Line(id)}:1s:udp,tcp,overview:15m:udp");
|
|
Assert.Equal("text/event-stream", sse.ContentType);
|
|
|
|
// 快照:当前在线状态、汇总排行、该线路的序列
|
|
SseEvent node = await sse.WaitForAsync(e => e.Name == "node" && e.Data!["id"]!.GetValue<string>() == id);
|
|
Assert.True(node.Data!["online"]!.GetValue<bool>());
|
|
await sse.WaitForAsync(e => e.Name == "overview");
|
|
await sse.WaitForAsync(e => e.Name == "series" && e.Data!["agent_id"]!.GetValue<string>() == id);
|
|
|
|
// 新块到达之后:推送新的 1s 桶(列式)和最新的汇总
|
|
RawChunk chunk = ChunkFactory.Echo(agent.NodeId, "udp_echo", Now() - 10_000, 100, 100, i => new EchoPacket(false, i % 10 == 0, 20_000, 20_000));
|
|
Assert.True(await agent.SendChunkAsync(chunk));
|
|
SseEvent series = await sse.WaitForAsync(e => e.Name == "series" && e.Data!["summary"]!["samples"]!["udp_echo"]?.GetValue<long>() >= 100);
|
|
Assert.True(series.Data!["live"]!.GetValue<bool>());
|
|
Assert.Equal("1s", series.Data["step"]!.GetValue<string>());
|
|
Assert.Equal(series.Data["t"]!.AsArray().Count, series.Data["values"]!["loss.total"]!.AsArray().Count);
|
|
Assert.Equal(10.0, series.Data["summary"]!["metrics"]!["loss.total"]!.GetValue<double>(), 6);
|
|
|
|
// 掉线:推送 node 事件
|
|
await agent.DisposeAsync();
|
|
await sse.WaitForAsync(e => e.Name == "node" && e.Data!["id"]!.GetValue<string>() == id && !e.Data["online"]!.GetValue<bool>());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Stream_PushesTestStatusChanges()
|
|
{
|
|
await _backend.SiteMustBePublicAsync();
|
|
await using AgentSimulator agent = await _backend.ConnectAgentAsync();
|
|
await agent.SendEventAsync(AgentEventKinds.ProbeConnected, _backend.DefaultProbeId);
|
|
await BackendFixture.EventuallyAsync(async () => (await _backend.GetJsonAsync(_backend.Admin, "/api/admin/agents/" + agent.NodeId.Value))["probe_connected"]!.GetValue<bool>());
|
|
using HttpResponseMessage accepted = await _backend.Admin.PostAsync(
|
|
$"/api/admin/lines/{_backend.Line(agent.NodeId.Value)}/tests", BackendFixture.Json(new JsonObject { ["plan"] = "reality_session" }));
|
|
string testId = JsonNode.Parse(await accepted.Content.ReadAsStringAsync())!["id"]!.GetValue<string>();
|
|
|
|
await using SseClient sse = await SseClient.ConnectAsync(_backend.Anonymous, "/api/public/live?topics=test:" + testId);
|
|
SseEvent initial = await sse.WaitForAsync(e => e.Name == "test");
|
|
Assert.Equal("queued", initial.Data!["status"]!.GetValue<string>());
|
|
Assert.Equal(3, initial.Data["phases"]!.GetValue<int>());
|
|
|
|
await agent.SendTestStatusAsync(new TestId(testId), TestStatuses.Running, phase: 1);
|
|
SseEvent running = await sse.WaitForAsync(e => e.Name == "test" && e.Data!["status"]!.GetValue<string>() == "running");
|
|
Assert.Equal(1, running.Data!["phase"]!.GetValue<int>());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Stream_RejectsBadTopics_UnknownLines_AndTooManyConnections()
|
|
{
|
|
await _backend.SiteMustBePublicAsync();
|
|
await using AgentSimulator agent = await _backend.ConnectAgentAsync();
|
|
|
|
async Task<(HttpStatusCode, string)> Probe(string query)
|
|
{
|
|
using HttpResponseMessage response = await _backend.Anonymous.GetAsync("/api/public/live?" + query, HttpCompletionOption.ResponseHeadersRead);
|
|
string code = response.IsSuccessStatusCode ? "" : JsonNode.Parse(await response.Content.ReadAsStringAsync())!["code"]!.GetValue<string>();
|
|
return (response.StatusCode, code);
|
|
}
|
|
|
|
Assert.Equal((HttpStatusCode.BadRequest, "validation_failed"), await Probe(""));
|
|
Assert.Equal((HttpStatusCode.BadRequest, "validation_failed"), await Probe("topics=bogus"));
|
|
Assert.Equal((HttpStatusCode.UnprocessableEntity, "invalid_step"), await Probe($"topics=series:{_backend.Line(agent.NodeId.Value)}:5m:udp"));
|
|
Assert.Equal((HttpStatusCode.NotFound, "agent_not_found"), await Probe("topics=series:agt_nope:1s:udp"));
|
|
Assert.Equal((HttpStatusCode.UnprocessableEntity, "unknown_preset"), await Probe($"topics=series:{_backend.Line(agent.NodeId.Value)}:1s:zzz"));
|
|
string many = string.Join(',', Enumerable.Range(1, 40).Select(i => $"test:tst_{i}"));
|
|
Assert.Equal((HttpStatusCode.TooManyRequests, "too_many_streams"), await Probe("topics=" + many));
|
|
|
|
// 每个客户端最多 4 个 SSE 连接
|
|
var open = new List<SseClient>();
|
|
try
|
|
{
|
|
for (int i = 0; i < 4; i++)
|
|
{
|
|
open.Add(await SseClient.ConnectAsync(_backend.Anonymous, "/api/public/live?topics=nodes"));
|
|
}
|
|
|
|
Assert.Equal((HttpStatusCode.TooManyRequests, "too_many_streams"), await Probe("topics=nodes"));
|
|
}
|
|
finally
|
|
{
|
|
foreach (SseClient client in open)
|
|
{
|
|
await client.DisposeAsync();
|
|
}
|
|
}
|
|
|
|
// 连接关闭之后配额释放
|
|
await BackendFixture.EventuallyAsync(async () => (await Probe("topics=nodes")).Item1 == HttpStatusCode.OK);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Stream_FollowsTheSameVisibilityRulesAsRest()
|
|
{
|
|
await _backend.SiteMustBePublicAsync();
|
|
await using AgentSimulator agent = await _backend.ConnectAgentAsync();
|
|
using HttpResponseMessage hide = await _backend.Admin.PatchAsync(
|
|
"/api/admin/agents/" + agent.NodeId.Value, BackendFixture.Json(new JsonObject { ["public_visible"] = false }, "application/merge-patch+json"));
|
|
Assert.Equal(HttpStatusCode.OK, hide.StatusCode);
|
|
|
|
using HttpResponseMessage anonymous = await _backend.Anonymous.GetAsync($"/api/public/live?topics=series:{_backend.Line(agent.NodeId.Value)}:1s:udp", HttpCompletionOption.ResponseHeadersRead);
|
|
Assert.Equal(HttpStatusCode.NotFound, anonymous.StatusCode);
|
|
|
|
await using SseClient admin = await SseClient.ConnectAsync(_backend.Admin, $"/api/public/live?topics=series:{_backend.Line(agent.NodeId.Value)}:1s:udp");
|
|
await admin.WaitForAsync(e => e.Name == "series");
|
|
|
|
// 匿名的 nodes 订阅看不到被隐藏线路的上下线
|
|
await using SseClient anonymousNodes = await SseClient.ConnectAsync(_backend.Anonymous, "/api/public/live?topics=nodes");
|
|
await Task.Delay(300);
|
|
Assert.DoesNotContain(anonymousNodes.Events, e => e.Data?["id"]?.GetValue<string>() == agent.NodeId.Value);
|
|
}
|
|
}
|