Files
MoShiro/backend/tests/MoShiro.Backend.Tests/LiveTests.cs
T
justme ad5efc62ff agent: unify listening and dialing with directed mutual measurements
Replace Probe with Agent across node runtime, Fleet contracts, backend state, API and storage. Report incoming sessions and outgoing link health through each Agent uplink, and retain directed link controls and retry.

Update protocol fixtures and end-to-end coverage for the unified model; accept requested cancellation during the Fleet timeout test cleanup.
2026-10-21 11:55:36 +08:00

136 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;
public sealed class LiveTests : IClassFixture<BackendFixture>
{
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.DialerConnected, _backend.DefaultDialerId);
await BackendFixture.EventuallyAsync(async () => (await _backend.GetJsonAsync(_backend.Admin, "/api/admin/agents/" + agent.NodeId.Value))["dialer_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);
}
}