Move FeatureUsage code into MicCheck.Api.Features.Usage namespace

Groups usage-tracking (metrics, background flush, query service,
controller, entity) under its own namespace/folder, updates all
consumers and the EF migration snapshot's CLR type strings to match.
This commit is contained in:
2026-07-05 22:00:13 -07:00
parent bdc85a1f3a
commit 27497e31a9
19 changed files with 22 additions and 16 deletions

View File

@@ -0,0 +1,93 @@
using MicCheck.Api.Data;
using MicCheck.Api.Features.Usage;
using MicCheck.Api.Tests.Unit.TestSupport;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Caching.Memory;
using Moq;
using NUnit.Framework;
namespace MicCheck.Api.Tests.Unit.Features.Usage;
[TestFixture]
public class FeatureUsageControllerTests
{
private Mock<IMicCheckDbContext> _db = null!;
private List<FeatureUsageDaily> _usage = null!;
private IMemoryCache _cache = null!;
private FeatureUsageController _controller = null!;
private const int EnvironmentId = 1;
[SetUp]
public void SetUp()
{
_db = new Mock<IMicCheckDbContext>();
_usage = [];
_db.SetupDbSet(c => c.FeatureUsageDaily, _usage);
_cache = new MemoryCache(new MemoryCacheOptions());
_controller = new FeatureUsageController(new FeatureUsageQueryService(_db.Object), _cache);
}
[Test]
public async Task WhenGettingDashboardUsageWithNoData_ThenEmptyResultsAreReturned()
{
var result = await _controller.GetDashboardUsage(EnvironmentId);
var ok = result.Result as OkObjectResult;
var response = (DashboardUsageResponse)ok!.Value!;
Assert.That(response.TopFeaturesLastDay, Is.Empty);
}
[Test]
public async Task WhenGettingDashboardUsageASecondTime_ThenTheCachedResultIsReturnedWithoutQueryingAgain()
{
var first = await _controller.GetDashboardUsage(EnvironmentId);
_usage.Add(new FeatureUsageDaily
{
EnvironmentId = EnvironmentId,
FeatureId = 1,
FeatureName = "flag",
UsageDate = DateOnly.FromDateTime(DateTime.UtcNow),
Count = 100,
UpdatedAt = DateTimeOffset.UtcNow
});
var second = await _controller.GetDashboardUsage(EnvironmentId);
var firstOk = (DashboardUsageResponse)((OkObjectResult)first.Result!).Value!;
var secondOk = (DashboardUsageResponse)((OkObjectResult)second.Result!).Value!;
Assert.That(secondOk, Is.SameAs(firstOk));
}
[Test]
public async Task WhenGettingDashboardUsageForDifferentDayRanges_ThenEachRangeIsCachedSeparately()
{
var sevenDays = await _controller.GetDashboardUsage(EnvironmentId, days: 7);
var fourteenDays = await _controller.GetDashboardUsage(EnvironmentId, days: 14);
var sevenOk = (DashboardUsageResponse)((OkObjectResult)sevenDays.Result!).Value!;
var fourteenOk = (DashboardUsageResponse)((OkObjectResult)fourteenDays.Result!).Value!;
Assert.That(sevenOk, Is.Not.SameAs(fourteenOk));
}
[Test]
public async Task WhenDaysExceedsTheMaximum_ThenItIsClampedTo90()
{
_usage.Add(new FeatureUsageDaily
{
EnvironmentId = EnvironmentId,
FeatureId = 1,
FeatureName = "flag",
UsageDate = DateOnly.FromDateTime(DateTime.UtcNow),
Count = 5,
UpdatedAt = DateTimeOffset.UtcNow
});
var clamped = await _controller.GetDashboardUsage(EnvironmentId, days: 1000);
var explicitNinety = await _controller.GetDashboardUsage(EnvironmentId, days: 90);
var clampedOk = (DashboardUsageResponse)((OkObjectResult)clamped.Result!).Value!;
var explicitOk = (DashboardUsageResponse)((OkObjectResult)explicitNinety.Result!).Value!;
Assert.That(clampedOk.DailyUsage.Count, Is.EqualTo(explicitOk.DailyUsage.Count));
}
}

View File

@@ -0,0 +1,80 @@
using System.Diagnostics.Metrics;
using MicCheck.Api.Features.Usage;
using Moq;
using NUnit.Framework;
namespace MicCheck.Api.Tests.Unit.Features.Usage;
[TestFixture]
public class FeatureUsageMetricsTests
{
private FeatureUsageMetrics _metrics = null!;
[SetUp]
public void SetUp()
{
var meterFactory = new Mock<IMeterFactory>();
meterFactory.Setup(f => f.Create(It.IsAny<MeterOptions>())).Returns(new Meter("test"));
_metrics = new FeatureUsageMetrics(meterFactory.Object);
}
[TearDown]
public void TearDown() => _metrics.Dispose();
[Test]
public void WhenRecordingEvaluations_ThenAccumulatorIncrementsByFeatureAndEnvironment()
{
_metrics.RecordEvaluation(environmentId: 1, featureId: 10, featureName: "dark_mode");
_metrics.RecordEvaluation(environmentId: 1, featureId: 10, featureName: "dark_mode");
_metrics.RecordEvaluation(environmentId: 1, featureId: 20, featureName: "beta");
_metrics.RecordEvaluation(environmentId: 2, featureId: 10, featureName: "dark_mode");
var drained = _metrics.DrainAccumulated();
Assert.That(drained, Has.Count.EqualTo(3));
var today = DateOnly.FromDateTime(DateTime.UtcNow);
Assert.That(drained[new FeatureUsageBucketKey(1, 10, "dark_mode", today)], Is.EqualTo(2));
Assert.That(drained[new FeatureUsageBucketKey(1, 20, "beta", today)], Is.EqualTo(1));
Assert.That(drained[new FeatureUsageBucketKey(2, 10, "dark_mode", today)], Is.EqualTo(1));
}
[Test]
public void WhenDrainCalled_ThenAccumulatorIsResetToEmpty()
{
_metrics.RecordEvaluation(environmentId: 1, featureId: 10, featureName: "dark_mode");
_metrics.DrainAccumulated();
var secondDrain = _metrics.DrainAccumulated();
Assert.That(secondDrain, Is.Empty);
}
[Test]
public void WhenNothingRecorded_ThenDrainReturnsEmptyDictionary()
{
var drained = _metrics.DrainAccumulated();
Assert.That(drained, Is.Empty);
}
[Test]
public void WhenRecordingConcurrently_ThenCountsAreAccurateAndNoDataLost()
{
const int threads = 8;
const int recordsPerThread = 100;
var tasks = Enumerable.Range(0, threads).Select(_ =>
Task.Run(() =>
{
for (var i = 0; i < recordsPerThread; i++)
_metrics.RecordEvaluation(environmentId: 1, featureId: 1, featureName: "concurrent_flag");
})).ToArray();
Task.WaitAll(tasks);
var drained = _metrics.DrainAccumulated();
var today = DateOnly.FromDateTime(DateTime.UtcNow);
Assert.That(drained[new FeatureUsageBucketKey(1, 1, "concurrent_flag", today)], Is.EqualTo(threads * recordsPerThread));
}
}

View File

@@ -0,0 +1,135 @@
using MicCheck.Api.Data;
using MicCheck.Api.Features.Usage;
using MicCheck.Api.Tests.Unit.TestSupport;
using Moq;
using NUnit.Framework;
namespace MicCheck.Api.Tests.Unit.Features.Usage;
[TestFixture]
public class FeatureUsageQueryServiceTests
{
private Mock<IMicCheckDbContext> _db = null!;
private List<FeatureUsageDaily> _usage = null!;
private FeatureUsageQueryService _service = null!;
private const int EnvironmentId = 1;
[SetUp]
public void SetUp()
{
_db = new Mock<IMicCheckDbContext>();
_usage = [];
_db.SetupDbSet(c => c.FeatureUsageDaily, _usage);
_service = new FeatureUsageQueryService(_db.Object);
}
private void SeedUsage(int environmentId, int featureId, string featureName, DateOnly date, long count)
{
_usage.Add(new FeatureUsageDaily
{
EnvironmentId = environmentId,
FeatureId = featureId,
FeatureName = featureName,
UsageDate = date,
Count = count,
UpdatedAt = DateTimeOffset.UtcNow
});
}
[Test]
public async Task WhenNoUsageData_ThenEmptyCollectionsAreReturned()
{
var result = await _service.GetDashboardUsageAsync(EnvironmentId, days: 14);
Assert.That(result.TopFeaturesLastDay, Is.Empty);
Assert.That(result.DailyUsage, Is.Empty);
}
[Test]
public async Task WhenMultipleFeaturesEvaluatedToday_ThenTopFeaturesLastDayOrdersByCountDescending()
{
var today = DateOnly.FromDateTime(DateTime.UtcNow);
SeedUsage(EnvironmentId, featureId: 1, "feature_a", today, count: 50);
SeedUsage(EnvironmentId, featureId: 2, "feature_b", today, count: 200);
SeedUsage(EnvironmentId, featureId: 3, "feature_c", today, count: 10);
var result = await _service.GetDashboardUsageAsync(EnvironmentId, days: 1);
Assert.That(result.TopFeaturesLastDay, Has.Count.EqualTo(3));
Assert.That(result.TopFeaturesLastDay[0].FeatureName, Is.EqualTo("feature_b"));
Assert.That(result.TopFeaturesLastDay[0].Count, Is.EqualTo(200));
Assert.That(result.TopFeaturesLastDay[1].FeatureName, Is.EqualTo("feature_a"));
Assert.That(result.TopFeaturesLastDay[2].FeatureName, Is.EqualTo("feature_c"));
}
[Test]
public async Task WhenMoreThanTenFeatures_ThenTopFeaturesLastDayLimitsToTen()
{
var today = DateOnly.FromDateTime(DateTime.UtcNow);
for (var i = 1; i <= 15; i++)
SeedUsage(EnvironmentId, featureId: i, $"feature_{i}", today, count: i * 10L);
var result = await _service.GetDashboardUsageAsync(EnvironmentId, days: 1);
Assert.That(result.TopFeaturesLastDay, Has.Count.EqualTo(10));
}
[Test]
public async Task WhenUsageSpansMultipleDays_ThenDailyUsageGroupsByDateAscending()
{
var today = DateOnly.FromDateTime(DateTime.UtcNow);
SeedUsage(EnvironmentId, featureId: 1, "flag", today.AddDays(-2), count: 30);
SeedUsage(EnvironmentId, featureId: 1, "flag", today.AddDays(-1), count: 50);
SeedUsage(EnvironmentId, featureId: 1, "flag", today, count: 20);
var result = await _service.GetDashboardUsageAsync(EnvironmentId, days: 7);
Assert.That(result.DailyUsage, Has.Count.EqualTo(3));
Assert.That(result.DailyUsage[0].Date, Is.EqualTo(today.AddDays(-2)));
Assert.That(result.DailyUsage[0].TotalCount, Is.EqualTo(30));
Assert.That(result.DailyUsage[1].TotalCount, Is.EqualTo(50));
Assert.That(result.DailyUsage[2].TotalCount, Is.EqualTo(20));
}
[Test]
public async Task WhenHoveringADay_ThenDailyUsageIncludesPerFeatureBreakdownOrderedByCount()
{
var today = DateOnly.FromDateTime(DateTime.UtcNow);
SeedUsage(EnvironmentId, featureId: 1, "feature_a", today, count: 10);
SeedUsage(EnvironmentId, featureId: 2, "feature_b", today, count: 40);
var result = await _service.GetDashboardUsageAsync(EnvironmentId, days: 1);
var day = result.DailyUsage.Single();
Assert.That(day.TotalCount, Is.EqualTo(50));
Assert.That(day.Features, Has.Count.EqualTo(2));
Assert.That(day.Features[0].FeatureName, Is.EqualTo("feature_b"));
Assert.That(day.Features[0].Count, Is.EqualTo(40));
Assert.That(day.Features[1].FeatureName, Is.EqualTo("feature_a"));
}
[Test]
public async Task WhenUsageExistsOutsideDaysWindow_ThenOldDataIsExcluded()
{
var today = DateOnly.FromDateTime(DateTime.UtcNow);
SeedUsage(EnvironmentId, featureId: 1, "flag", today.AddDays(-30), count: 999);
SeedUsage(EnvironmentId, featureId: 1, "flag", today.AddDays(-3), count: 5);
var result = await _service.GetDashboardUsageAsync(EnvironmentId, days: 7);
Assert.That(result.DailyUsage, Has.Count.EqualTo(1));
Assert.That(result.DailyUsage[0].TotalCount, Is.EqualTo(5));
}
[Test]
public async Task WhenOtherEnvironmentHasUsage_ThenItIsNotIncludedInResults()
{
var today = DateOnly.FromDateTime(DateTime.UtcNow);
SeedUsage(environmentId: 99, featureId: 1, "flag", today, count: 100);
var result = await _service.GetDashboardUsageAsync(EnvironmentId, days: 7);
Assert.That(result.TopFeaturesLastDay, Is.Empty);
Assert.That(result.DailyUsage, Is.Empty);
}
}