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() == id); Assert.True(node.Data!["online"]!.GetValue()); await sse.WaitForAsync(e => e.Name == "overview"); await sse.WaitForAsync(e => e.Name == "series" && e.Data!["agent_id"]!.GetValue() == 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() >= 100); Assert.True(series.Data!["live"]!.GetValue()); Assert.Equal("1s", series.Data["step"]!.GetValue()); 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(), 6); // 掉线:推送 node 事件 await agent.DisposeAsync(); await sse.WaitForAsync(e => e.Name == "node" && e.Data!["id"]!.GetValue() == id && !e.Data["online"]!.GetValue()); } [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()); 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(); 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()); Assert.Equal(3, initial.Data["phases"]!.GetValue()); await agent.SendTestStatusAsync(new TestId(testId), TestStatuses.Running, phase: 1); SseEvent running = await sse.WaitForAsync(e => e.Name == "test" && e.Data!["status"]!.GetValue() == "running"); Assert.Equal(1, running.Data!["phase"]!.GetValue()); } [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(); 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(); 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() == agent.NodeId.Value); } }