chore: initial project import
This commit is contained in:
commit
3fc4e5ca53
347 files changed
+56172
No files matched your search
@@ -0,0 +1,277 @@
|
||||
using System.Net;
|
||||
using System.Text.Json.Nodes;
|
||||
using Fleet.Core;
|
||||
using Monitor.Protocol;
|
||||
using Monitor.TestSupport;
|
||||
|
||||
namespace Monitor.Backend.Tests;
|
||||
|
||||
[Collection("backend")]
|
||||
public sealed class OnDemandTestsTests
|
||||
{
|
||||
private readonly BackendFixture _backend;
|
||||
|
||||
public OnDemandTestsTests(BackendFixture backend)
|
||||
{
|
||||
_backend = backend;
|
||||
}
|
||||
|
||||
private Task<HttpResponseMessage> 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<AgentSimulator> 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<bool>());
|
||||
return agent;
|
||||
}
|
||||
|
||||
private async Task<JsonNode> 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<string>());
|
||||
|
||||
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 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<string>());
|
||||
|
||||
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<string>());
|
||||
|
||||
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<string>();
|
||||
Assert.Equal("/api/public/tests/" + testId, accepted.Headers.Location!.ToString());
|
||||
Assert.Equal("queued", started["status"]!.GetValue<string>());
|
||||
|
||||
// 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<string>());
|
||||
|
||||
await agent.SendTestStatusAsync(run.TestId, TestStatuses.Running, phase: 0);
|
||||
await BackendFixture.EventuallyAsync(async () => (await TestAsync(testId))["status"]!.GetValue<string>() == "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<string>());
|
||||
JsonNode metrics = done["result"]!["metrics"]!;
|
||||
Assert.Equal(25, metrics["hy2.sustainable_mbps"]!.GetValue<double>());
|
||||
Assert.Equal(50, metrics["hy2.qos_onset_mbps"]!.GetValue<double>());
|
||||
Assert.Equal(4, done["result"]!["steps"]!.AsArray().Count);
|
||||
Assert.InRange(done["result"]!["steps"]![2]!["loss_pct"]!.GetValue<double>(), 19, 21);
|
||||
Assert.Equal(new double[] { 10, 25, 50, 100 }, done["result"]!["series"]!["step_mbps"]!.AsArray().Select(v => v!.GetValue<double>()).ToArray());
|
||||
JsonNode hy2 = done["result"]!["scores"]!["hy2"]!;
|
||||
Assert.False(hy2["estimated"]!.GetValue<bool>());
|
||||
Assert.InRange(hy2["score"]!.GetValue<double>(), 20, 90);
|
||||
Assert.Contains(hy2["caveats"]!.AsArray(), c => c!.GetValue<string>() == "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<string>() == _backend.Line(agent.NodeId.Value))!;
|
||||
Assert.NotNull(line["summary"]!["metrics"]!["score.hy2"]);
|
||||
Assert.Equal(testId, line["last_test"]!["id"]!.GetValue<string>());
|
||||
|
||||
// 测试用的原始块不进入持续线路的指标
|
||||
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<string>());
|
||||
|
||||
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<string>());
|
||||
Assert.Equal("test_busy", latest["error_code"]!.GetValue<string>());
|
||||
|
||||
// 失败的测试不再占用线路
|
||||
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<string>();
|
||||
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<string>());
|
||||
|
||||
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<string>() == "aborted");
|
||||
|
||||
// 终态之后的状态汇报被忽略
|
||||
await agent.SendTestStatusAsync(new TestId(testId), TestStatuses.Succeeded);
|
||||
await Task.Delay(200);
|
||||
Assert.Equal("aborted", (await TestAsync(testId))["status"]!.GetValue<string>());
|
||||
|
||||
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<string>();
|
||||
await agent.DisposeAsync();
|
||||
await BackendFixture.EventuallyAsync(async () => !(await _backend.GetJsonAsync(_backend.Admin, "/api/admin/agents/" + agent.NodeId.Value))["online"]!.GetValue<bool>());
|
||||
|
||||
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<string>());
|
||||
}
|
||||
|
||||
[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<string>();
|
||||
|
||||
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<string>());
|
||||
Assert.Equal("line:" + _backend.DefaultProbeId, item["series"]!.GetValue<string>());
|
||||
|
||||
using HttpResponseMessage download = await _backend.Admin.GetAsync("/api/admin/raw/chunks/" + chunk.Header.ChunkId);
|
||||
Assert.Equal("application/vnd.monitor.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<string>());
|
||||
}
|
||||
|
||||
[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>();
|
||||
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<string>()));
|
||||
cursor = page["next_cursor"]?.GetValue<string>();
|
||||
pages++;
|
||||
}
|
||||
while (cursor is not null && pages < 10);
|
||||
|
||||
Assert.Equal(5, seen.Count);
|
||||
Assert.Equal(5, seen.Distinct().Count());
|
||||
Assert.Equal(3, pages);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user