using System.Text.Json.Nodes; using MoShiro.Protocol; using MoShiro.TestSupport; namespace MoShiro.Backend.Tests; /// 重启后端:数据、身份和令牌都必须还在;热层从原始块回填;段文件尾部没有索引的数据被截断。 public sealed class PersistenceTests : IAsyncLifetime { private readonly BackendFixture _backend = new(); public ValueTask InitializeAsync() => _backend.InitializeAsync(); public ValueTask DisposeAsync() => _backend.DisposeAsync(); [Fact] public async Task EverythingSurvivesARestart_AndTheHotLayerIsRebuiltFromRawChunks() { long minute = 60_000; long baseMs = ((TimeProvider.System.GetUtcNow().ToUnixTimeMilliseconds() / minute) - 3) * minute; AgentSimulator agent = await _backend.ConnectAgentAsync(); string id = agent.NodeId.Value; for (int k = 0; k < 12; k++) { Assert.True(await agent.SendChunkAsync(ChunkFactory.Echo(agent.NodeId, "udp_echo", baseMs + (k * 10_000L), 100, 100, i => new EchoPacket(false, i % 4 == 0, 20_000, 20_000)))); } string from = DateTimeOffset.FromUnixTimeMilliseconds(baseMs).UtcDateTime.ToString("yyyy-MM-dd'T'HH:mm:ss.fff'Z'"); string to = DateTimeOffset.FromUnixTimeMilliseconds(baseMs + (2 * minute)).UtcDateTime.ToString("yyyy-MM-dd'T'HH:mm:ss.fff'Z'"); string url = $"/api/public/lines/{_backend.Line(id)}/series?from={from}&to={to}&metrics=loss.total,rtt.p95"; JsonNode before = await _backend.GetJsonAsync(_backend.Admin, url + "&step=1m"); await BackendFixture.EventuallyAsync(async () => (await _backend.GetJsonAsync(_backend.Admin, url + "&step=1m"))["summary"]!["samples"]!["udp_echo"]?.GetValue() == 1200); before = await _backend.GetJsonAsync(_backend.Admin, url + "&step=1m"); await agent.DisposeAsync(); await _backend.RestartAsync(); HttpClient admin = _backend.Admin; // 重启之后用同一个令牌,证明令牌已经落盘 // 身份与令牌:Agent 记录在,管理员令牌仍然有效 JsonNode agents = await _backend.GetJsonAsync(admin, "/api/admin/agents"); JsonNode restored = agents["items"]!.AsArray().Single(a => a!["id"]!.GetValue() == id)!; Assert.Equal("active", restored["status"]!.GetValue()); Assert.False(restored["online"]!.GetValue()); // 持久层:重启前算好的分钟窗口原样还在 JsonNode after = await _backend.GetJsonAsync(admin, url + "&step=1m"); Assert.Equal(before["summary"]!["metrics"]!["loss.total"]!.GetValue(), after["summary"]!["metrics"]!["loss.total"]!.GetValue(), 9); Assert.Equal(25.0, after["summary"]!["metrics"]!["loss.total"]!.GetValue(), 6); // 热层:重启后为空,回填之后才会被查询选用;回填完成后 1s 步长能读到同样的数据 JsonNode hot = null!; await BackendFixture.EventuallyAsync(async () => { hot = await _backend.GetJsonAsync(admin, url + "&step=1s"); return hot["source"]!.GetValue() == "hot" && hot["summary"]!["samples"]!["udp_echo"]?.GetValue() == 1200; }); Assert.Equal(25.0, hot["summary"]!["metrics"]!["loss.total"]!.GetValue(), 6); // 同一个身份可以重新连接,并拿回当前版本的期望状态 await using AgentSimulator again = await _backend.ReconnectAsync(agent); Assert.NotNull(await again.WaitForTaskSetAsync(s => s.Tasks.Count == Defaults.TaskCount)); // 已经落盘的块在重启后仍然可以按 ID 下载 JsonNode chunks = await _backend.GetJsonAsync(admin, $"/api/admin/raw/chunks?agent_id={id}&limit=1"); string chunkId = chunks["items"]![0]!["chunk_id"]!.GetValue(); using HttpResponseMessage download = await admin.GetAsync("/api/admin/raw/chunks/" + chunkId); Assert.True(RawChunkCodec.Decode(await download.Content.ReadAsByteArrayAsync()).IsSuccess); } }