fix(kpi): query service applies catalog aggregation at the bucketer — rate charts keep one unit across the raw/rollup boundary (plan R2-04 T6)

This commit is contained in:
Joseph Doherty
2026-07-13 10:10:30 -04:00
parent 54417e1ae4
commit 9a1b160e79
2 changed files with 106 additions and 2 deletions
@@ -330,4 +330,95 @@ public class KpiHistoryQueryServiceTests
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string?>(),
Arg.Any<DateTime>(), Arg.Any<DateTime>(), Arg.Any<CancellationToken>());
}
// ---- Task 6: catalog-driven aggregation at the bucketer boundary (R3) -----------
[Fact]
public async Task GetSeriesAsync_RateMetric_SumsPerBucket_OnRollupPath()
{
// (SiteCallAudit, FailedLastInterval) is a catalog Rate pair. 400 hourly points of
// value 1 over a 400 h (>168 h) window, maxPoints 200 → 2 points per 2 h bucket →
// every output value is 2.0 (the summed pair), not 1.0 (last value).
var from = new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc);
var to = from.AddHours(400);
var rollup = Enumerable.Range(0, 400)
.Select(i => new KpiSeriesPoint(from.AddHours(i), 1d))
.ToList();
var repo = Substitute.For<IKpiHistoryRepository>();
repo.GetHourlySeriesAsync(
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string?>(),
Arg.Any<DateTime>(), Arg.Any<DateTime>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<KpiSeriesPoint>>(rollup));
var sut = new KpiHistoryQueryService(repo, Options(defaultMax: 200));
var result = await sut.GetSeriesAsync(
KpiSources.SiteCallAudit, KpiMetrics.SiteCallAudit.FailedLastInterval,
"global", null, from, to);
Assert.Equal(200, result.Count);
Assert.All(result, p => Assert.Equal(2.0, p.Value));
}
[Fact]
public async Task GetSeriesAsync_RateMetric_PreservesTotal_AcrossRoutingBoundary()
{
// Same total event count (50) served as per-minute deltas on the raw path
// (window == threshold) and as hourly sums on the rollup path (window just over
// threshold): the summed chart total must be equal on both routes — the boundary
// is visually seamless for a Rate metric.
var from = new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc);
var rawDeltas = Enumerable.Range(0, 10)
.Select(i => new KpiSeriesPoint(from.AddMinutes(i * 10), 5d)).ToList(); // total 50
var hourlySums = Enumerable.Range(0, 5)
.Select(i => new KpiSeriesPoint(from.AddHours(i), 10d)).ToList(); // total 50
var repo = Substitute.For<IKpiHistoryRepository>();
repo.GetRawSeriesAsync(
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string?>(),
Arg.Any<DateTime>(), Arg.Any<DateTime>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<KpiSeriesPoint>>(rawDeltas));
repo.GetHourlySeriesAsync(
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string?>(),
Arg.Any<DateTime>(), Arg.Any<DateTime>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<KpiSeriesPoint>>(hourlySums));
var sut = new KpiHistoryQueryService(repo, Options(defaultMax: 200, rollupThresholdHours: 168));
var rawResult = await sut.GetSeriesAsync(
KpiSources.SiteCallAudit, KpiMetrics.SiteCallAudit.FailedLastInterval,
"global", null, from, from.AddHours(168)); // raw path
var rollupResult = await sut.GetSeriesAsync(
KpiSources.SiteCallAudit, KpiMetrics.SiteCallAudit.FailedLastInterval,
"global", null, from, from.AddHours(169)); // rollup path
Assert.Equal(50d, rawResult.Sum(p => p.Value));
Assert.Equal(50d, rollupResult.Sum(p => p.Value));
Assert.Equal(rawResult.Sum(p => p.Value), rollupResult.Sum(p => p.Value));
}
[Fact]
public async Task GetSeriesAsync_GaugeMetric_KeepsLastValuePerBucket()
{
// queueDepth (uncatalogued for this source pairing → Gauge) keeps the existing
// last-value-per-bucket output unchanged. 4 points, maxPoints 2, raw path.
var repo = Substitute.For<IKpiHistoryRepository>();
var raw = Series((0, 1d), (15, 2d), (25, 99d), (35, 5d));
repo.GetRawSeriesAsync(
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string?>(),
Arg.Any<DateTime>(), Arg.Any<DateTime>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult(raw));
var sut = new KpiHistoryQueryService(repo, Options(defaultMax: 2));
var result = await sut.GetSeriesAsync(
KpiSources.NotificationOutbox, KpiMetrics.NotificationOutbox.QueueDepth,
"global", null, From, To);
Assert.Equal(2, result.Count);
Assert.Equal(99d, result[0].Value); // last value in bucket 0, not a sum
Assert.Equal(5d, result[1].Value);
}
}