Files
mic-check/src/api/MicCheck.Api/Features/FeatureUsageFlushBackgroundService.cs
James Wampler 678f605197 Add unit tests for MicCheck.Api.Features
Covers validators, response mapping, TagService/TagsController,
FeaturesController (CRUD + tag assignment), FeatureSegmentService,
FeatureStateService (incl. webhook dispatch), and
FeatureUsageController. Fills gaps in FeatureService
(FindByIdAsync, not-found exceptions).

FeatureUsageFlushBackgroundService is marked [ExcludeFromCodeCoverage]:
timer-driven, issues raw SQL through a DI-scoped concrete DbContext,
can't be exercised cleanly without a live DB (CLAUDE.md disallows
WebApplicationFactory/InMemory).

Raises the namespace from 44% to 97%.
2026-07-05 13:26:11 -07:00

63 lines
2.5 KiB
C#

using System.Diagnostics.CodeAnalysis;
using MicCheck.Api.Data;
using Microsoft.EntityFrameworkCore;
namespace MicCheck.Api.Features;
[ExcludeFromCodeCoverage(Justification = "Timer-driven BackgroundService that issues raw SQL through a DI-scoped concrete MicCheckDbContext; exercising it cleanly requires a live DB, which CLAUDE.md disallows (no WebApplicationFactory/InMemory). DrainAccumulated's bucketing logic is covered by FeatureUsageMetricsTests.")]
public class FeatureUsageFlushBackgroundService(
FeatureUsageMetrics metrics,
IServiceScopeFactory scopeFactory,
ILogger<FeatureUsageFlushBackgroundService> logger) : BackgroundService
{
private static readonly TimeSpan FlushInterval = TimeSpan.FromSeconds(30);
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(FlushInterval);
while (await timer.WaitForNextTickAsync(stoppingToken))
{
await FlushAsync(stoppingToken);
}
}
public override async Task StopAsync(CancellationToken cancellationToken)
{
await base.StopAsync(cancellationToken);
await FlushAsync(CancellationToken.None);
}
private async Task FlushAsync(CancellationToken ct)
{
var deltas = metrics.DrainAccumulated();
if (deltas.Count == 0)
return;
var updatedAt = DateTimeOffset.UtcNow;
try
{
await using var scope = scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<MicCheckDbContext>();
foreach (var (key, count) in deltas)
{
await db.Database.ExecuteSqlAsync(
$"""
INSERT INTO "FeatureUsageDaily" ("EnvironmentId", "FeatureId", "FeatureName", "UsageDate", "Count", "UpdatedAt")
VALUES ({key.EnvironmentId}, {key.FeatureId}, {key.FeatureName}, {key.UsageDate}, {count}, {updatedAt})
ON CONFLICT ("EnvironmentId", "FeatureId", "UsageDate")
DO UPDATE SET "Count" = "FeatureUsageDaily"."Count" + EXCLUDED."Count",
"FeatureName" = EXCLUDED."FeatureName",
"UpdatedAt" = EXCLUDED."UpdatedAt"
""",
ct);
}
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to flush feature usage data ({BucketCount} buckets)", deltas.Count);
}
}
}