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 OnDemandTestsTests { private readonly BackendFixture _backend; public OnDemandTestsTests(BackendFixture backend) { _backend = backend; } private Task StartAsync(AgentSimulator agent, JsonObject body) => _backend.Admin.PostAsync($"/api/admin/lines/{_backend.Line(agent.NodeId.Value)}/tests", BackendFixture.Json(body)); private static JsonObject Plan(string plan, JsonObject? parameters = null) => parameters is null ? new JsonObject { ["plan"] = plan } : new JsonObject { ["plan"] = plan, ["params"] = parameters }; private async Task AgentWithProbeAsync() { AgentSimulator agent = await _backend.ConnectAgentAsync(); await agent.WaitForTaskSetAsync(s => s.Tasks.Count == 2); 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()); return agent; } private async Task TestAsync(string id) => await _backend.GetJsonAsync(_backend.Admin, "/api/public/tests/" + id); [Fact] public async Task Start_RequiresAProbeSession_APlanThatExists_AndValidParams() { await using AgentSimulator agent = await _backend.ConnectAgentAsync(); await agent.WaitForTaskSetAsync(s => s.Tasks.Count == 2); using HttpResponseMessage offline = await StartAsync(agent, Plan("hy2_ladder")); Assert.Equal(HttpStatusCode.Conflict, offline.StatusCode); Assert.Equal("probe_offline", JsonNode.Parse(await offline.Content.ReadAsStringAsync())!["code"]!.GetValue()); 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 unknownPlan = await StartAsync(agent, Plan("warp_ladder")); Assert.Equal(HttpStatusCode.NotFound, unknownPlan.StatusCode); Assert.Equal("unknown_plan", JsonNode.Parse(await unknownPlan.Content.ReadAsStringAsync())!["code"]!.GetValue()); using HttpResponseMessage badParam = await StartAsync(agent, Plan("hy2_ladder", new JsonObject { ["nope"] = 1 })); Assert.Equal(HttpStatusCode.BadRequest, badParam.StatusCode); Assert.NotNull(JsonNode.Parse(await badParam.Content.ReadAsStringAsync())!["errors"]!["params.nope"]); using HttpResponseMessage outOfRange = await StartAsync(agent, Plan("hy2_ladder", new JsonObject { ["size"] = 9000 })); Assert.Equal(HttpStatusCode.BadRequest, outOfRange.StatusCode); // 超过 Agent 本地声明的码率上限(hello 里上报 200 Mbit/s) using HttpResponseMessage overBudget = await StartAsync(agent, Plan("hy2_ladder", new JsonObject { ["mbps"] = 500 })); Assert.Equal(HttpStatusCode.BadRequest, overBudget.StatusCode); Assert.Equal("budget_exceeded", JsonNode.Parse(await overBudget.Content.ReadAsStringAsync())!["code"]!.GetValue()); using HttpResponseMessage missingAgent = await _backend.Admin.PostAsync("/api/admin/lines/agt_nope/tests", BackendFixture.Json(Plan("hy2_ladder"))); Assert.Equal(HttpStatusCode.NotFound, missingAgent.StatusCode); } [Fact] public async Task Hy2Ladder_RunsEndToEnd_AndProducesAResultWithScores() { await using AgentSimulator agent = await AgentWithProbeAsync(); using HttpResponseMessage accepted = await StartAsync(agent, Plan("hy2_ladder")); Assert.Equal(HttpStatusCode.Accepted, accepted.StatusCode); JsonNode started = JsonNode.Parse(await accepted.Content.ReadAsStringAsync())!; string testId = started["id"]!.GetValue(); Assert.Equal("/api/public/tests/" + testId, accepted.Headers.Location!.ToString()); Assert.Equal("queued", started["status"]!.GetValue()); // Agent 收到按需测试:四个阶段,码率阶梯,参数已补全默认值 await agent.WaitUntilAsync(() => !agent.TestRuns.IsEmpty); TestRunParams run = agent.TestRuns.Single(); Assert.Equal(testId, run.TestId.Value); Assert.Equal([10d, 25d, 50d, 100d], run.Phases.Select(p => p.Params["mbps"].GetDouble()).ToArray()); Assert.All(run.Phases, p => Assert.Equal("udp_train", p.Kind)); Assert.All(run.Phases, p => Assert.Equal(1250, p.Params["size"].GetInt64())); // 同一条线路同时只能跑一个测试 using HttpResponseMessage busy = await StartAsync(agent, Plan("hy2_ladder")); Assert.Equal(HttpStatusCode.Conflict, busy.StatusCode); Assert.Equal("test_busy", JsonNode.Parse(await busy.Content.ReadAsStringAsync())!["code"]!.GetValue()); await agent.SendTestStatusAsync(run.TestId, TestStatuses.Running, phase: 0); await BackendFixture.EventuallyAsync(async () => (await TestAsync(testId))["status"]!.GetValue() == "running"); long now = TimeProvider.System.GetUtcNow().ToUnixTimeMilliseconds(); double[] mbps = [10, 25, 50, 100]; int[] lossEvery = [0, 0, 5, 2]; // 50 Mbit/s 起被限速 for (int phase = 0; phase < 4; phase++) { RawChunk chunk = ChunkFactory.Train(agent.NodeId, now - 60_000 + (phase * 2_000), mbps[phase], phase, seconds: 2, lossEvery: lossEvery[phase]); chunk.Header.TestId = run.TestId; chunk.Header.Phase = phase; Assert.True(await agent.SendChunkAsync(chunk)); await agent.SendTestStatusAsync(run.TestId, TestStatuses.PhaseDone, phase); } await agent.SendTestStatusAsync(run.TestId, TestStatuses.Succeeded); JsonNode done = null!; await BackendFixture.EventuallyAsync(async () => { done = await TestAsync(testId); return done["result"] is not null; }, because: "the finalizer computes the result once every phase's chunks have arrived"); Assert.Equal("succeeded", done["status"]!.GetValue()); JsonNode metrics = done["result"]!["metrics"]!; Assert.Equal(25, metrics["hy2.sustainable_mbps"]!.GetValue()); Assert.Equal(50, metrics["hy2.qos_onset_mbps"]!.GetValue()); Assert.Equal(4, done["result"]!["steps"]!.AsArray().Count); Assert.InRange(done["result"]!["steps"]![2]!["loss_pct"]!.GetValue(), 19, 21); Assert.Equal(new double[] { 10, 25, 50, 100 }, done["result"]!["series"]!["step_mbps"]!.AsArray().Select(v => v!.GetValue()).ToArray()); JsonNode hy2 = done["result"]!["scores"]!["hy2"]!; Assert.False(hy2["estimated"]!.GetValue()); Assert.InRange(hy2["score"]!.GetValue(), 20, 90); Assert.Contains(hy2["caveats"]!.AsArray(), c => c!.GetValue() == "traffic_shape_only"); // 测试结果会进入线路的 hy2 评分(持续数据里没有这些指标) await _backend.SiteMustBePublicAsync(); JsonNode overview = await _backend.GetJsonAsync(_backend.Anonymous, "/api/public/overview?range=15m&metrics=score.hy2"); JsonNode line = overview["lines"]!.AsArray().Single(l => l!["id"]!.GetValue() == _backend.Line(agent.NodeId.Value))!; Assert.NotNull(line["summary"]!["metrics"]!["score.hy2"]); Assert.Equal(testId, line["last_test"]!["id"]!.GetValue()); // 测试用的原始块不进入持续线路的指标 JsonNode series = await _backend.GetJsonAsync(_backend.Admin, $"/api/public/lines/{_backend.Line(agent.NodeId.Value)}/series?range=15m&metrics=train.loss"); Assert.Null(series["summary"]!["metrics"]!["train.loss"]); } [Fact] public async Task AgentRefusal_IsSurfacedAndMarksTheTestFailed() { await using AgentSimulator agent = await AgentWithProbeAsync(); agent.TestRunHandler = _ => (false, "test_busy"); using HttpResponseMessage response = await StartAsync(agent, Plan("hy2_ladder")); Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); Assert.Equal("test_busy", JsonNode.Parse(await response.Content.ReadAsStringAsync())!["code"]!.GetValue()); JsonNode tests = await _backend.GetJsonAsync(_backend.Admin, $"/api/admin/lines/{_backend.Line(agent.NodeId.Value)}/tests"); JsonNode latest = tests["items"]![0]!; Assert.Equal("failed", latest["status"]!.GetValue()); Assert.Equal("test_busy", latest["error_code"]!.GetValue()); // 失败的测试不再占用线路 agent.TestRunHandler = _ => (true, null); using HttpResponseMessage retry = await StartAsync(agent, Plan("hy2_ladder")); Assert.Equal(HttpStatusCode.Accepted, retry.StatusCode); } [Fact] public async Task Cancel_ReachesTheAgent_AndOnlyTheOwningAgentCanReportStatus() { await using AgentSimulator agent = await AgentWithProbeAsync(); await using AgentSimulator other = await _backend.ConnectAgentAsync(); using HttpResponseMessage accepted = await StartAsync(agent, Plan("reality_session")); Assert.Equal(HttpStatusCode.Accepted, accepted.StatusCode); string testId = JsonNode.Parse(await accepted.Content.ReadAsStringAsync())!["id"]!.GetValue(); await agent.WaitUntilAsync(() => !agent.TestRuns.IsEmpty); // 别的 Agent 不能汇报这个测试的状态 await other.SendTestStatusAsync(new TestId(testId), TestStatuses.Succeeded); await Task.Delay(200); Assert.Equal("queued", (await TestAsync(testId))["status"]!.GetValue()); using HttpResponseMessage cancel = await _backend.Admin.PostAsync($"/api/admin/tests/{testId}/cancel", null); Assert.Equal(HttpStatusCode.OK, cancel.StatusCode); await agent.WaitUntilAsync(() => agent.TestCancels.Any(t => t.Value == testId)); await agent.SendTestStatusAsync(new TestId(testId), TestStatuses.Aborted); await BackendFixture.EventuallyAsync(async () => (await TestAsync(testId))["status"]!.GetValue() == "aborted"); // 终态之后的状态汇报被忽略 await agent.SendTestStatusAsync(new TestId(testId), TestStatuses.Succeeded); await Task.Delay(200); Assert.Equal("aborted", (await TestAsync(testId))["status"]!.GetValue()); using HttpResponseMessage unknown = await _backend.Admin.PostAsync("/api/admin/tests/tst_nope/cancel", null); Assert.Equal(HttpStatusCode.NotFound, unknown.StatusCode); } [Fact] public async Task CancellingATestOfAnOfflineAgent_AbortsItImmediately() { AgentSimulator agent = await AgentWithProbeAsync(); using HttpResponseMessage accepted = await StartAsync(agent, Plan("reality_session")); string testId = JsonNode.Parse(await accepted.Content.ReadAsStringAsync())!["id"]!.GetValue(); await agent.DisposeAsync(); await BackendFixture.EventuallyAsync(async () => !(await _backend.GetJsonAsync(_backend.Admin, "/api/admin/agents/" + agent.NodeId.Value))["online"]!.GetValue()); using HttpResponseMessage cancel = await _backend.Admin.PostAsync($"/api/admin/tests/{testId}/cancel", null); Assert.Equal(HttpStatusCode.OK, cancel.StatusCode); Assert.Equal("aborted", JsonNode.Parse(await cancel.Content.ReadAsStringAsync())!["status"]!.GetValue()); } [Fact] public async Task TestsOfHiddenLines_AreNotFoundForAnonymousVisitors() { await _backend.SiteMustBePublicAsync(); await using AgentSimulator agent = await AgentWithProbeAsync(); using HttpResponseMessage accepted = await StartAsync(agent, Plan("reality_session")); string testId = JsonNode.Parse(await accepted.Content.ReadAsStringAsync())!["id"]!.GetValue(); using HttpResponseMessage visible = await _backend.Anonymous.GetAsync("/api/public/tests/" + testId); Assert.Equal(HttpStatusCode.OK, visible.StatusCode); 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 hidden = await _backend.Anonymous.GetAsync("/api/public/tests/" + testId); Assert.Equal(HttpStatusCode.NotFound, hidden.StatusCode); } [Fact] public async Task RawChunks_AreListedAndDownloadable_ExpiredOnesReportGone() { await using AgentSimulator agent = await _backend.ConnectAgentAsync(); long now = TimeProvider.System.GetUtcNow().ToUnixTimeMilliseconds(); RawChunk chunk = ChunkFactory.UdpEchoClean(agent.NodeId, now - 120_000); Assert.True(await agent.SendChunkAsync(chunk)); JsonNode list = await _backend.GetJsonAsync(_backend.Admin, $"/api/admin/raw/chunks?agent_id={agent.NodeId.Value}&kind=udp_echo"); JsonNode item = list["items"]!.AsArray().Single()!; Assert.Equal(chunk.Header.ChunkId.ToString(), item["chunk_id"]!.GetValue()); Assert.Equal("line:" + _backend.DefaultProbeId, item["series"]!.GetValue()); using HttpResponseMessage download = await _backend.Admin.GetAsync("/api/admin/raw/chunks/" + chunk.Header.ChunkId); Assert.Equal("application/vnd.moshiro.rawchunk", download.Content.Headers.ContentType!.MediaType); RawChunk decoded = RawChunkCodec.Decode(await download.Content.ReadAsByteArrayAsync()).Value; Assert.Equal(chunk.Header.ChunkId, decoded.Header.ChunkId); Assert.Equal(100, decoded.Find("msg")!.Count); using HttpResponseMessage missing = await _backend.Admin.GetAsync("/api/admin/raw/chunks/" + Guid.CreateVersion7()); Assert.Equal(HttpStatusCode.NotFound, missing.StatusCode); // UUIDv7 里的时间戳早于保留期:索引已经清理,所以是 410 而不是 404 var longAgo = Guid.CreateVersion7(TimeProvider.System.GetUtcNow().AddDays(-400)); using HttpResponseMessage gone = await _backend.Admin.GetAsync("/api/admin/raw/chunks/" + longAgo); Assert.Equal(HttpStatusCode.Gone, gone.StatusCode); Assert.Equal("raw_expired", JsonNode.Parse(await gone.Content.ReadAsStringAsync())!["code"]!.GetValue()); } [Fact] public async Task RawChunkList_PagesWithAStableCursor() { await using AgentSimulator agent = await _backend.ConnectAgentAsync(); long baseMs = TimeProvider.System.GetUtcNow().ToUnixTimeMilliseconds() - 300_000; for (int k = 0; k < 5; k++) { Assert.True(await agent.SendChunkAsync(ChunkFactory.UdpEchoClean(agent.NodeId, baseMs + (k * 10_000L)))); } var seen = new List(); string? cursor = null; int pages = 0; do { JsonNode page = await _backend.GetJsonAsync(_backend.Admin, $"/api/admin/raw/chunks?agent_id={agent.NodeId.Value}&limit=2" + (cursor is null ? "" : "&cursor=" + cursor)); seen.AddRange(page["items"]!.AsArray().Select(i => i!["chunk_id"]!.GetValue())); cursor = page["next_cursor"]?.GetValue(); pages++; } while (cursor is not null && pages < 10); Assert.Equal(5, seen.Count); Assert.Equal(5, seen.Distinct().Count()); Assert.Equal(3, pages); } }