Files
MoShiro/backend/tests/MoShiro.Backend.Tests/LiveTests.cs
T
justme 2e146d4da7
ci/woodpecker/push/main-amd64-build Pipeline was successful
refactor!: rename the project from Monitor to MoShiro [CI]
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.
2026-10-07 02:03:25 +08:00

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);
}
}