Adding Flags API

This commit is contained in:
2026-04-07 15:30:40 -07:00
parent 7b15086fe5
commit 0ba076b650
78 changed files with 3862 additions and 81 deletions

View File

@@ -0,0 +1,327 @@
using MicCheck.Api.Data;
using MicCheck.Api.Features;
using MicCheck.Api.Identities;
using MicCheck.Api.Segments;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using NUnit.Framework;
using AppEnvironment = MicCheck.Api.Environments.Environment;
namespace MicCheck.Api.Tests.Unit.Features;
[TestFixture]
public class FeatureEvaluationServiceTests
{
private MicCheckDbContext _db = null!;
private FeatureEvaluationService _service = null!;
private const int EnvironmentId = 1;
private const int ProjectId = 1;
[SetUp]
public void SetUp()
{
var options = new DbContextOptionsBuilder<MicCheckDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
_db = new MicCheckDbContext(options);
var cache = new FlagCache(new MemoryCache(new MemoryCacheOptions()));
_service = new FeatureEvaluationService(_db, new SegmentEvaluator(), cache);
SeedBaseData();
}
[TearDown]
public void TearDown() => _db.Dispose();
private void SeedBaseData()
{
_db.Environments.Add(new AppEnvironment
{
Id = EnvironmentId,
Name = "Production",
ApiKey = "env-key-test",
ProjectId = ProjectId,
CreatedAt = DateTimeOffset.UtcNow
});
_db.SaveChanges();
}
private MicCheck.Api.Features.Feature AddFeature(string name, bool defaultEnabled = false)
{
var feature = new MicCheck.Api.Features.Feature
{
Name = name,
ProjectId = ProjectId,
CreatedAt = DateTimeOffset.UtcNow,
DefaultEnabled = defaultEnabled
};
_db.Features.Add(feature);
_db.SaveChanges();
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
};
_db.FeatureStates.Add(fs);
_db.SaveChanges();
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.Identities.Add(identity);
_db.SaveChanges();
_db.FeatureStates.Add(new FeatureState
{
FeatureId = feature.Id,
EnvironmentId = EnvironmentId,
IdentityId = identity.Id,
Enabled = true,
Value = "identity-value",
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow
});
_db.SaveChanges();
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");
var envDefault = AddEnvironmentDefault(feature.Id, enabled: false);
var segment = new Segment { Name = "Premium Users", ProjectId = ProjectId, CreatedAt = DateTimeOffset.UtcNow };
_db.Segments.Add(segment);
_db.SaveChanges();
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"
});
_db.SegmentRules.Add(rule);
_db.SaveChanges();
var featureSegment = new FeatureSegment
{
FeatureId = feature.Id,
SegmentId = segment.Id,
EnvironmentId = EnvironmentId,
Priority = 1
};
_db.FeatureSegments.Add(featureSegment);
_db.SaveChanges();
_db.FeatureStates.Add(new FeatureState
{
FeatureId = feature.Id,
EnvironmentId = EnvironmentId,
FeatureSegmentId = featureSegment.Id,
Enabled = true,
Value = "segment-value",
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow
});
_db.SaveChanges();
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.Segments.Add(segment);
_db.SaveChanges();
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"
});
_db.SegmentRules.Add(rule);
_db.SaveChanges();
var featureSegment = new FeatureSegment
{
FeatureId = feature.Id,
SegmentId = segment.Id,
EnvironmentId = EnvironmentId,
Priority = 1
};
_db.FeatureSegments.Add(featureSegment);
_db.SaveChanges();
_db.FeatureStates.Add(new FeatureState
{
FeatureId = feature.Id,
EnvironmentId = EnvironmentId,
FeatureSegmentId = featureSegment.Id,
Enabled = true,
Value = "segment-value",
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow
});
_db.SaveChanges();
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.Segments.Add(segment);
_db.SaveChanges();
var rule = new SegmentRule { SegmentId = segment.Id, Type = SegmentRuleType.All };
rule.Conditions.Add(new SegmentCondition
{
RuleId = rule.Id,
Property = "country",
Operator = SegmentConditionOperator.IsSet,
Value = ""
});
_db.SegmentRules.Add(rule);
_db.SaveChanges();
var featureSegment = new FeatureSegment
{
FeatureId = feature.Id,
SegmentId = segment.Id,
EnvironmentId = EnvironmentId,
Priority = 1
};
_db.FeatureSegments.Add(featureSegment);
_db.SaveChanges();
_db.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.Identities.Add(identity);
_db.SaveChanges();
identity.Traits.Add(new IdentityTrait
{
IdentityId = identity.Id,
Key = "country",
Value = "US"
});
_db.FeatureStates.Add(new FeatureState
{
FeatureId = feature.Id,
EnvironmentId = EnvironmentId,
IdentityId = identity.Id,
Enabled = false,
Value = "identity-override",
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow
});
_db.SaveChanges();
var results = await _service.EvaluateForIdentityAsync(
EnvironmentId, "user-special",
[new TraitInput("country", "US")]);
Assert.That(results[0].Value, Is.EqualTo("identity-override"));
}
}

View File

@@ -0,0 +1,137 @@
using System.Net;
using System.Net.Http.Json;
using MicCheck.Api.Data;
using MicCheck.Api.Features;
using Microsoft.Extensions.DependencyInjection;
using NUnit.Framework;
using AppEnvironment = MicCheck.Api.Environments.Environment;
namespace MicCheck.Api.Tests.Unit.Features;
[TestFixture]
public class FlagsApiIntegrationTests
{
private MicCheckWebApplicationFactory _factory = null!;
private string _envApiKey = null!;
[SetUp]
public void SetUp()
{
_factory = new MicCheckWebApplicationFactory();
_envApiKey = SeedEnvironmentWithFlag();
}
[TearDown]
public void TearDown() => _factory.Dispose();
private string SeedEnvironmentWithFlag()
{
using var scope = _factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<MicCheckDbContext>();
var apiKey = $"test-env-key-{Guid.NewGuid():N}";
var project = new MicCheck.Api.Projects.Project
{
Name = "Test Project",
OrganizationId = 1,
CreatedAt = DateTimeOffset.UtcNow
};
db.Projects.Add(project);
db.SaveChanges();
var environment = new AppEnvironment
{
Name = "Test Env",
ApiKey = apiKey,
ProjectId = project.Id,
CreatedAt = DateTimeOffset.UtcNow
};
db.Environments.Add(environment);
db.SaveChanges();
var feature = new Feature
{
Name = "dark_mode",
ProjectId = project.Id,
CreatedAt = DateTimeOffset.UtcNow
};
db.Features.Add(feature);
db.SaveChanges();
db.FeatureStates.Add(new FeatureState
{
FeatureId = feature.Id,
EnvironmentId = environment.Id,
Enabled = true,
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow
});
db.SaveChanges();
return apiKey;
}
[Test]
public async Task WhenEnvironmentKeyIsValid_ThenFlagsAreReturned()
{
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Add("X-Environment-Key", _envApiKey);
var response = await client.GetAsync("/api/v1/flags/");
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK));
var flags = await response.Content.ReadFromJsonAsync<List<FlagResponse>>();
Assert.That(flags, Has.Count.EqualTo(1));
Assert.That(flags![0].Feature.Name, Is.EqualTo("dark_mode"));
Assert.That(flags[0].Enabled, Is.True);
}
[Test]
public async Task WhenEnvironmentKeyIsAbsent_ThenUnauthorizedIsReturned()
{
var client = _factory.CreateClient();
var response = await client.GetAsync("/api/v1/flags/");
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
}
[Test]
public async Task WhenEnvironmentKeyIsInvalid_ThenUnauthorizedIsReturned()
{
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Add("X-Environment-Key", "not-a-real-key");
var response = await client.GetAsync("/api/v1/flags/");
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
}
[Test]
public async Task WhenIdentifyingUserWithValidKey_ThenFlagsAndTraitsAreReturned()
{
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Add("X-Environment-Key", _envApiKey);
var response = await client.PostAsJsonAsync("/api/v1/identities/", new
{
identifier = "user-123",
traits = new[] { new { trait_key = "plan", trait_value = "premium" } }
});
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK));
}
[Test]
public async Task WhenGettingEnvironmentDocument_ThenDocumentIsReturned()
{
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Add("X-Environment-Key", _envApiKey);
var response = await client.GetAsync("/api/v1/environment-document/");
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK));
}
}