Files
MoShiro/backend/tests/MoShiro.Backend.Tests/QueryPlanningTests.cs
T
justme 83e5120377 refactor!: rename the project from Monitor to MoShiro
Namespaces, assemblies, projects and directories, the solution, binaries,
images, systemd units, config paths, environment variables, protocol method
names, the media type and the raw chunk magic all move to MoShiro/moshiro.
Existing data and deployments are not compatible and must be recreated.
Also fix the architecture tests after the solution layout change and make
the update tests independent of their order.
2026-10-07 01:49:04 +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/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 _));
}
}