Files
mic-check/tests/api/MicCheck.Api.Tests.Unit/Features/FeatureEvaluationServiceTests.cs
James Wampler 7a3e2167c2
All checks were successful
CI / build-and-push (push) Successful in 1m0s
CI / deploy-qa (push) Successful in 11s
CI / smoke-qa (push) Successful in 26s
Remove FluentValidation; validator/DI cleanup (#7)
## Summary
- Remove FluentValidation dependency; replace with plain IModelValidator<T>/ValidationResult pattern (ported from BuyEngine's Guard/validation approach), wired via a ModelValidationActionFilter
- Move FeatureUsage code into MicCheck.Api.Features.Usage namespace
- Convert logic-free data classes to records per CLAUDE.md (WebhookEvent, FeatureStateResult, ProjectPermissionRequirement, AuditLog, Tag, FeatureUsageDaily)
- Extract IAuditService interface, drop virtual-method mock seam on AuditService
- Split Program.cs DI registrations into per-namespace DependencyRegistration classes

## Test plan
- [x] dotnet build (API + tests) clean
- [x] dotnet test MicCheck.Api.Tests.Unit — 646/646 passing
- [x] pre-push hook (full solution build/test + admin build) passed

Co-authored-by: miccheck-ci <ci@miccheck.local>
Reviewed-on: #7
2026-07-05 22:54:30 -07:00

327 lines
11 KiB
C#
Executable File

using System.Diagnostics.Metrics;
using MicCheck.Api.Data;
using MicCheck.Api.Features;
using MicCheck.Api.Features.Usage;
using MicCheck.Api.Identities;
using MicCheck.Api.Segments;
using MicCheck.Api.Tests.Unit.TestSupport;
using Microsoft.Extensions.Caching.Memory;
using Moq;
using NUnit.Framework;
using AppEnvironment = MicCheck.Api.Environments.Environment;
namespace MicCheck.Api.Tests.Unit.Features;
[TestFixture]
public class FeatureEvaluationServiceTests
{
private Mock<IMicCheckDbContext> _db = null!;
private List<AppEnvironment> _environments = null!;
private List<Feature> _features = null!;
private List<FeatureState> _featureStates = null!;
private List<Identity> _identities = null!;
private List<Segment> _segments = null!;
private List<FeatureSegment> _featureSegments = null!;
private FeatureEvaluationService _service = null!;
private FeatureUsageMetrics _usageMetrics = null!;
private const int EnvironmentId = 1;
private const int ProjectId = 1;
[SetUp]
public void SetUp()
{
_db = new Mock<IMicCheckDbContext>();
_environments = [new AppEnvironment { Id = EnvironmentId, Name = "Production", ApiKey = "env-key-test", ProjectId = ProjectId, CreatedAt = DateTimeOffset.UtcNow }];
var environmentsSet = MockDbSetFactory.Create(_environments);
environmentsSet.Setup(m => m.FindAsync(It.IsAny<object[]>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((object[] keys, CancellationToken _) => _environments.FirstOrDefault(e => e.Id == (int)keys[0]));
_db.Setup(c => c.Environments).Returns(environmentsSet.Object);
_features = [];
_db.SetupDbSetWithGeneratedIds(c => c.Features, _features);
_featureStates = [];
_db.SetupDbSet(c => c.FeatureStates, _featureStates);
_identities = [];
_db.SetupDbSetWithGeneratedIds(c => c.Identities, _identities);
_db.SetupDbSet(c => c.IdentityTraits, []);
_segments = [];
_db.SetupDbSetWithGeneratedIds(c => c.Segments, _segments);
_db.SetupDbSet(c => c.SegmentRules, []);
_db.SetupDbSet(c => c.SegmentConditions, []);
_featureSegments = [];
_db.SetupDbSetWithGeneratedIds(c => c.FeatureSegments, _featureSegments);
var meterFactory = new Mock<IMeterFactory>();
meterFactory.Setup(f => f.Create(It.IsAny<MeterOptions>())).Returns(new Meter("test"));
_usageMetrics = new FeatureUsageMetrics(meterFactory.Object);
var cache = new FlagCache(new MemoryCache(new MemoryCacheOptions()));
_service = new FeatureEvaluationService(_db.Object, new SegmentEvaluator(), cache, _usageMetrics);
}
[TearDown]
public void TearDown() => _usageMetrics.Dispose();
private Feature AddFeature(string name, bool defaultEnabled = false)
{
var feature = new Feature
{
Name = name,
ProjectId = ProjectId,
CreatedAt = DateTimeOffset.UtcNow,
DefaultEnabled = defaultEnabled
};
_db.Object.Features.Add(feature);
return feature;
}
private FeatureState AddEnvironmentDefault(int featureId, bool enabled, string? value = null)
{
var fs = new FeatureState
{
FeatureId = featureId,
EnvironmentId = EnvironmentId,
Enabled = enabled,
Value = value,
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow
};
_featureStates.Add(fs);
return fs;
}
[Test]
public async Task WhenNoOverridesExist_ThenEnvironmentDefaultIsReturned()
{
var feature = AddFeature("dark_mode");
AddEnvironmentDefault(feature.Id, enabled: true, value: null);
var results = await _service.EvaluateForEnvironmentAsync(EnvironmentId);
Assert.That(results, Has.Count.EqualTo(1));
Assert.That(results[0].Feature.Name, Is.EqualTo("dark_mode"));
Assert.That(results[0].Enabled, Is.True);
}
[Test]
public async Task WhenFeatureIsDisabled_ThenResultReflectsThat()
{
var feature = AddFeature("beta_feature");
AddEnvironmentDefault(feature.Id, enabled: false);
var results = await _service.EvaluateForEnvironmentAsync(EnvironmentId);
Assert.That(results[0].Enabled, Is.False);
}
[Test]
public async Task WhenIdentityHasNoOverrides_ThenEnvironmentDefaultIsUsed()
{
var feature = AddFeature("dark_mode");
AddEnvironmentDefault(feature.Id, enabled: true);
var results = await _service.EvaluateForIdentityAsync(EnvironmentId, "user-123");
Assert.That(results, Has.Count.EqualTo(1));
Assert.That(results[0].Enabled, Is.True);
}
[Test]
public async Task WhenIdentityHasOverride_ThenIdentityOverrideTakesPriority()
{
var feature = AddFeature("dark_mode");
AddEnvironmentDefault(feature.Id, enabled: false);
var identity = new Identity
{
Identifier = "user-vip",
EnvironmentId = EnvironmentId,
CreatedAt = DateTimeOffset.UtcNow
};
_db.Object.Identities.Add(identity);
_featureStates.Add(new FeatureState
{
FeatureId = feature.Id,
EnvironmentId = EnvironmentId,
IdentityId = identity.Id,
Enabled = true,
Value = "identity-value",
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow
});
var results = await _service.EvaluateForIdentityAsync(EnvironmentId, "user-vip");
Assert.That(results[0].Enabled, Is.True);
Assert.That(results[0].Value, Is.EqualTo("identity-value"));
}
[Test]
public async Task WhenSegmentMatchesAndHasOverride_ThenSegmentOverrideIsUsed()
{
var feature = AddFeature("premium_feature");
AddEnvironmentDefault(feature.Id, enabled: false);
var segment = new Segment { Name = "Premium Users", ProjectId = ProjectId, CreatedAt = DateTimeOffset.UtcNow };
_db.Object.Segments.Add(segment);
var rule = new SegmentRule { SegmentId = segment.Id, Type = SegmentRuleType.All };
rule.Conditions.Add(new SegmentCondition
{
RuleId = rule.Id,
Property = "plan",
Operator = SegmentConditionOperator.Equal,
Value = "premium"
});
segment.Rules.Add(rule);
var featureSegment = new FeatureSegment
{
FeatureId = feature.Id,
SegmentId = segment.Id,
EnvironmentId = EnvironmentId,
Priority = 1
};
_db.Object.FeatureSegments.Add(featureSegment);
_featureStates.Add(new FeatureState
{
FeatureId = feature.Id,
EnvironmentId = EnvironmentId,
FeatureSegmentId = featureSegment.Id,
Enabled = true,
Value = "segment-value",
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow
});
var results = await _service.EvaluateForIdentityAsync(
EnvironmentId, "user-123",
[new TraitInput("plan", "premium")]);
Assert.That(results[0].Enabled, Is.True);
Assert.That(results[0].Value, Is.EqualTo("segment-value"));
}
[Test]
public async Task WhenSegmentDoesNotMatch_ThenEnvironmentDefaultIsUsed()
{
var feature = AddFeature("premium_feature");
AddEnvironmentDefault(feature.Id, enabled: false, value: "default-value");
var segment = new Segment { Name = "Premium Users", ProjectId = ProjectId, CreatedAt = DateTimeOffset.UtcNow };
_db.Object.Segments.Add(segment);
var rule = new SegmentRule { SegmentId = segment.Id, Type = SegmentRuleType.All };
rule.Conditions.Add(new SegmentCondition
{
RuleId = rule.Id,
Property = "plan",
Operator = SegmentConditionOperator.Equal,
Value = "premium"
});
segment.Rules.Add(rule);
var featureSegment = new FeatureSegment
{
FeatureId = feature.Id,
SegmentId = segment.Id,
EnvironmentId = EnvironmentId,
Priority = 1
};
_db.Object.FeatureSegments.Add(featureSegment);
_featureStates.Add(new FeatureState
{
FeatureId = feature.Id,
EnvironmentId = EnvironmentId,
FeatureSegmentId = featureSegment.Id,
Enabled = true,
Value = "segment-value",
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow
});
var results = await _service.EvaluateForIdentityAsync(
EnvironmentId, "user-123",
[new TraitInput("plan", "free")]);
Assert.That(results[0].Enabled, Is.False);
Assert.That(results[0].Value, Is.EqualTo("default-value"));
}
[Test]
public async Task WhenIdentityOverrideAndSegmentBothExist_ThenIdentityOverrideTakesPriority()
{
var feature = AddFeature("feature_x");
AddEnvironmentDefault(feature.Id, enabled: false);
var segment = new Segment { Name = "All Users", ProjectId = ProjectId, CreatedAt = DateTimeOffset.UtcNow };
_db.Object.Segments.Add(segment);
var rule = new SegmentRule { SegmentId = segment.Id, Type = SegmentRuleType.All };
rule.Conditions.Add(new SegmentCondition
{
RuleId = rule.Id,
Property = "country",
Operator = SegmentConditionOperator.IsSet,
Value = ""
});
segment.Rules.Add(rule);
var featureSegment = new FeatureSegment
{
FeatureId = feature.Id,
SegmentId = segment.Id,
EnvironmentId = EnvironmentId,
Priority = 1
};
_db.Object.FeatureSegments.Add(featureSegment);
_featureStates.Add(new FeatureState
{
FeatureId = feature.Id,
EnvironmentId = EnvironmentId,
FeatureSegmentId = featureSegment.Id,
Enabled = true,
Value = "segment-value",
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow
});
var identity = new Identity
{
Identifier = "user-special",
EnvironmentId = EnvironmentId,
CreatedAt = DateTimeOffset.UtcNow
};
_db.Object.Identities.Add(identity);
identity.Traits.Add(new IdentityTrait
{
IdentityId = identity.Id,
Key = "country",
Value = "US"
});
_featureStates.Add(new FeatureState
{
FeatureId = feature.Id,
EnvironmentId = EnvironmentId,
IdentityId = identity.Id,
Enabled = false,
Value = "identity-override",
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow
});
var results = await _service.EvaluateForIdentityAsync(
EnvironmentId, "user-special",
[new TraitInput("country", "US")]);
Assert.That(results[0].Value, Is.EqualTo("identity-override"));
}
}