Files
MoShiro/backend/tests/MoShiro.Backend.Tests/QueryPlanningTests.cs
T
justme e014a19554
ci/woodpecker/push/main-amd64-build Pipeline was successful
docs: reorganize into design, security, api, ops and dev; drop stale designs; refresh the diagrams [CI]
- Group docs by category with an index (docs/README.md); fix every link,
  code comment and the protocol conformance test that read docs/protocol.md.
- access.md and security.md rewritten to what is implemented: removed the
  unadopted member-proof chain, roles and recovery key that do not exist,
  the old implementation order and the budgets; gaps now carry a status.
- enrollment, deployment, api: the install flow is the static, hash-checked
  script with environment variables; no default admin.
- README and architecture: new topology and update-flow diagrams (operator
  passkey, CI signing, registry feed, auth and trust modules).
2026-10-08 19:37:56 +08:00

242 lines
11 KiB
C#

using Fleet.Core;
using Microsoft.Extensions.Time.Testing;
using MoShiro.Backend.Analysis;
using MoShiro.Backend.Application;
using MoShiro.Protocol;
namespace MoShiro.Backend.Tests;
/// <summary>时间段、步长与数据源的规划:与 docs/design/query.md 里的表格逐行对应。</summary>
public sealed class QueryPlanningTests
{
private const long Second = 1000;
private const long Minute = 60 * Second;
private const long Hour = 60 * Minute;
private const long Day = 24 * Hour;
private static readonly long s_now = new DateTimeOffset(2026, 10, 3, 12, 34, 56, TimeSpan.Zero).ToUnixTimeMilliseconds();
private readonly BackendOptions _options = new();
private RangeResolver Resolver => new(_options);
private SourcePlanner Planner => new(_options);
private QueryRange Resolve(string range, string? step = null)
{
Result<QueryRange> result = Resolver.Resolve(new RangeRequest(range, null, null, step, null), s_now);
Assert.True(result.IsSuccess, result.IsSuccess ? "" : result.Error.Message);
return result.Value;
}
[Theory]
[InlineData("5m", "1s", 300)]
[InlineData("15m", "2s", 450)]
[InlineData("30m", "5s", 360)]
[InlineData("1h", "5s", 720)]
[InlineData("6h", "30s", 720)]
[InlineData("24h", "2m", 720)]
[InlineData("7d", "15m", 672)]
[InlineData("30d", "1h", 720)]
[InlineData("90d", "3h", 720)]
public void AutoStep_MatchesTheDocumentedTable(string range, string step, int points)
{
QueryRange resolved = Resolve(range);
Assert.Equal(step, Steps.Format(resolved.StepMs));
Assert.InRange(resolved.Points - points, 0, 1); // 对齐到步长的整数倍,可能多出一个不完整的桶
}
[Theory]
[InlineData("5m", "hot")]
[InlineData("15m", "hot")]
[InlineData("30m", "hot")]
[InlineData("1h", "hot")]
[InlineData("6h", "10s")]
[InlineData("24h", "1m")]
[InlineData("7d", "5m")]
[InlineData("30d", "1h")]
[InlineData("90d", "1h")]
public void SourceSelection_MatchesTheDocumentedTable(string range, string source)
{
QueryRange resolved = Resolve(range);
SourcePlan plan = Planner.Plan(resolved, s_now, hotReady: true);
Assert.Equal(source, plan.Name);
Assert.False(plan.Degraded);
Assert.Equal(resolved.StepMs, plan.StepMs);
}
[Fact]
public void WhenTheHotLayerIsNotReady_FineStepsOverRecentDataComeFromRawChunks()
{
SourcePlan plan = Planner.Plan(Resolve("5m"), s_now, hotReady: false);
Assert.Equal("raw", plan.Name);
// 区间超过 15 分钟就不现算,降级到 10 s 层
SourcePlan degraded = Planner.Plan(Resolve("1h", step: "5s"), s_now, hotReady: false);
Assert.Equal("10s", degraded.Name);
Assert.True(degraded.Degraded);
Assert.Equal(10 * Second, degraded.StepMs);
}
[Fact]
public void StepsThatNoLayerCanServe_AreDegradedAndSnappedToACommonStep()
{
// 15 s 不能整除 10 s 层的宽度,而且已经超出热层
QueryRange old = Resolver.Resolve(new RangeRequest(null, DateTimeOffset.FromUnixTimeMilliseconds(s_now - (3 * Hour)), DateTimeOffset.FromUnixTimeMilliseconds(s_now - (2 * Hour)), "15s", null), s_now).Value;
SourcePlan plan = Planner.Plan(old, s_now, hotReady: true);
Assert.Equal(("10s", true, "30s"), (plan.Name, plan.Degraded, Steps.Format(plan.StepMs)));
Assert.Equal(30 * Second, old.WithStep(plan.StepMs).StepMs);
Assert.Equal(0, old.WithStep(plan.StepMs).FromMs % plan.StepMs);
// 超出 10 s 层的保留期(48 小时),1 分钟步长改用 1 分钟层(不降级:1m 层能整除)
QueryRange week = Resolver.Resolve(new RangeRequest(null, DateTimeOffset.FromUnixTimeMilliseconds(s_now - (3 * Day)), DateTimeOffset.FromUnixTimeMilliseconds(s_now - (3 * Day) + Hour), "10s", null), s_now).Value;
SourcePlan expired = Planner.Plan(week, s_now, hotReady: true);
Assert.True(expired.Degraded, "the 10 s layer only keeps 48 hours");
Assert.Equal("1m", expired.Name);
Assert.Equal(Minute, expired.StepMs);
}
[Fact]
public void RangeResolver_AlignsBucketsAndMarksPartialEdges()
{
long from = s_now - (s_now % Minute) + (13 * Second);
long to = from + (4 * Minute) + (7 * Second);
QueryRange range = Resolver.Resolve(new RangeRequest(null, DateTimeOffset.FromUnixTimeMilliseconds(from), DateTimeOffset.FromUnixTimeMilliseconds(to), "1m", null), s_now).Value;
Assert.Equal(0, range.FromMs % Minute);
Assert.Equal(0, range.ToMs % Minute);
Assert.Equal(5, range.Points);
Assert.True(range.FirstPartial);
Assert.True(range.LastPartial);
Assert.False(range.IsRelative);
QueryRange aligned = Resolver.Resolve(new RangeRequest(null, DateTimeOffset.FromUnixTimeMilliseconds(range.FromMs), DateTimeOffset.FromUnixTimeMilliseconds(range.ToMs), "1m", null), s_now).Value;
Assert.False(aligned.FirstPartial);
Assert.False(aligned.LastPartial);
}
[Theory]
[InlineData("0m")]
[InlineData("-5m")]
[InlineData("5")]
[InlineData("5x")]
[InlineData("m")]
[InlineData("99999999d")]
[InlineData("1000d")]
public void RangeResolver_RejectsMalformedAndAbsurdRanges(string range)
{
Result<QueryRange> result = Resolver.Resolve(new RangeRequest(range, null, null, null, null), s_now);
Assert.False(result.IsSuccess);
Assert.Contains(result.Error.Code, new[] { MoShiroErrors.InvalidRange, MoShiroErrors.RangeTooLarge });
}
[Fact]
public void RangeResolver_EnforcesThePointLimit_AndSuggestsAStep()
{
Result<QueryRange> result = Resolver.Resolve(new RangeRequest("30d", null, null, "1m", null), s_now);
Assert.Equal(MoShiroErrors.RangeTooLarge, result.Error.Code);
Assert.Contains("step=", result.Error.Message);
Assert.Equal(MoShiroErrors.InvalidStep, Resolver.Resolve(new RangeRequest("1h", null, null, "7s", null), s_now).Error.Code);
Assert.Equal(MoShiroErrors.InvalidRange, Resolver.Resolve(new RangeRequest(null, DateTimeOffset.FromUnixTimeMilliseconds(s_now), DateTimeOffset.FromUnixTimeMilliseconds(s_now), null, null), s_now).Error.Code);
Assert.Equal(MoShiroErrors.ValidationFailed, Resolver.Resolve(new RangeRequest("1h", DateTimeOffset.FromUnixTimeMilliseconds(s_now - Hour), null, null, null), s_now).Error.Code);
}
[Fact]
public void OnlyUtcIsSupported_OtherTimeZonesAreRejectedInsteadOfSilentlyIgnored()
{
Assert.True(Resolver.Resolve(new RangeRequest("1h", null, null, null, "UTC"), s_now).IsSuccess);
Assert.True(Resolver.Resolve(new RangeRequest("1h", null, null, null, null), s_now).IsSuccess);
Result<QueryRange> shanghai = Resolver.Resolve(new RangeRequest("7d", null, null, "1d", "Asia/Shanghai"), s_now);
Assert.Equal(MoShiroErrors.ValidationFailed, shanghai.Error.Code);
Assert.IsType<ValidationError>(shanghai.Error);
}
[Fact]
public void RelativeRanges_AreLive_AbsoluteOnesAreNot()
{
Assert.True(Resolve("15m").IsRelative);
QueryRange fromOnly = Resolver.Resolve(new RangeRequest(null, DateTimeOffset.FromUnixTimeMilliseconds(s_now - Hour), null, null, null), s_now).Value;
Assert.True(fromOnly.IsRelative); // 没有 to:to = now,可以跟随实时数据
}
[Fact]
public void Steps_ParseFormatAndSnap()
{
foreach (long step in Steps.Common)
{
Assert.True(Steps.TryParse(Steps.Format(step), out long parsed));
Assert.Equal(step, parsed);
}
Assert.False(Steps.TryParse("3s", out _));
Assert.Equal(30 * Second, Steps.Snap(15 * Second, 10 * Second));
Assert.Equal(5 * Minute, Steps.Snap(Minute, 5 * Minute));
Assert.Equal(-60_000, Steps.FloorTo(-1, Minute));
Assert.Equal(0, Steps.CeilTo(0, Minute));
Assert.Equal(Minute, Steps.CeilTo(1, Minute));
}
[Fact]
public void BucketedRows_MergeExactly_AndDoNotMutateTheirSources()
{
QueryRange range = Resolve("5m", "1m");
var first = new MetricSet();
first.GetOrAdd("c", AccKind.Counter).Add(3);
((HistogramAcc)first.GetOrAdd("h", AccKind.Histogram)).Add(10);
var second = new MetricSet();
second.GetOrAdd("c", AccKind.Counter).Add(4);
((HistogramAcc)second.GetOrAdd("h", AccKind.Histogram)).Add(20);
BucketedRows rows = BucketedRows.Create(
[new SourceRow(range.FromMs, first, false), new SourceRow(range.FromMs + (10 * Second), second, false), new SourceRow(range.FromMs - Minute, first, false), new SourceRow(range.ToMs, first, false)], range);
Assert.Equal(range.Points, rows.T.Length);
Assert.Equal(2, rows.RowCounts[0]);
Assert.Equal(7UL, ((CounterAcc)rows.Sets[0]!.Items["c"]).Value);
Assert.Equal(2UL, ((HistogramAcc)rows.Sets[0]!.Items["h"]).Count);
Assert.Equal(3UL, ((CounterAcc)first.Items["c"]).Value); // 源行没有被改动
Assert.Equal(2, rows.TotalRows); // 区间之外的行被忽略
Assert.Null(rows.Sets[1]);
}
[Fact]
public void MetricSelection_ResolvesPresetsAndScores_AndRejectsUnknownIds()
{
MetricSelection selection = MetricSelection.Resolve("rtt.p95,score.mc", "udp,scores", 100).Value;
Assert.Contains("loss.total", selection.ExportIds);
Assert.Contains("mc", selection.ScoreModelIds);
Assert.Equal(4, selection.ScoreModelIds.Count);
Assert.Equal(selection.ExportIds.Count, selection.ExportIds.Distinct().Count());
Assert.True(selection.WantsScores);
Assert.Equal(MoShiroErrors.UnknownMetric, MetricSelection.Resolve("nope", null, 100).Error.Code);
Assert.Equal(MoShiroErrors.UnknownMetric, MetricSelection.Resolve("score.nope", null, 100).Error.Code);
Assert.Equal(MoShiroErrors.UnknownPreset, MetricSelection.Resolve(null, "nope", 100).Error.Code);
Assert.Equal(MoShiroErrors.TooManyMetrics, MetricSelection.Resolve(null, "udp", 3).Error.Code);
}
[Fact]
public void LoginRateLimiter_AllowsABurst_ThenBlocksUntilTheWindowSlides()
{
var time = new FakeTimeProvider(DateTimeOffset.UnixEpoch);
var limiter = new LoginRateLimiter(new BackendOptions { LoginAttemptsPerMinute = 3 }, time);
for (int i = 0; i < 3; i++)
{
Assert.True(limiter.TryAcquire("1.2.3.4", out _));
time.Advance(TimeSpan.FromSeconds(10));
}
Assert.False(limiter.TryAcquire("1.2.3.4", out int retry)); // 已过 30 秒,最早的一次还要 30 秒才滑出窗口
Assert.InRange(retry, 29, 31);
Assert.True(limiter.TryAcquire("5.6.7.8", out _), "another source is independent");
time.Advance(TimeSpan.FromSeconds(31));
Assert.True(limiter.TryAcquire("1.2.3.4", out _));
limiter.Prune();
time.Advance(TimeSpan.FromMinutes(2));
limiter.Prune();
Assert.True(limiter.TryAcquire("1.2.3.4", out _));
}
}