From d6594521f8044af77dc3a6c135229f431f4dca4e Mon Sep 17 00:00:00 2001 From: James Wampler Date: Sun, 5 Jul 2026 14:20:31 -0700 Subject: [PATCH] Add unit tests for MicCheck.Api.Identities Covers AdminIdentityService (list/create/find/upsert-trait/delete-trait/ delete/feature-state get-set-delete, all previously 0%), the AdminIdentityResponse mapping, TraitInput's JSON/plain-type branch logic, and IdentitiesController.GetByIdentifier. Raises the namespace from 31% to 97.5%. --- .../Identities/AdminIdentityResponseTests.cs | 34 +++ .../Identities/AdminIdentityServiceTests.cs | 251 ++++++++++++++++++ .../Identities/IdentitiesControllerTests.cs | 92 +++++++ .../Identities/TraitInputTests.cs | 125 +++++++++ 4 files changed, 502 insertions(+) create mode 100644 tests/api/MicCheck.Api.Tests.Unit/Identities/AdminIdentityResponseTests.cs create mode 100644 tests/api/MicCheck.Api.Tests.Unit/Identities/AdminIdentityServiceTests.cs create mode 100644 tests/api/MicCheck.Api.Tests.Unit/Identities/IdentitiesControllerTests.cs create mode 100644 tests/api/MicCheck.Api.Tests.Unit/Identities/TraitInputTests.cs diff --git a/tests/api/MicCheck.Api.Tests.Unit/Identities/AdminIdentityResponseTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Identities/AdminIdentityResponseTests.cs new file mode 100644 index 0000000..d33160f --- /dev/null +++ b/tests/api/MicCheck.Api.Tests.Unit/Identities/AdminIdentityResponseTests.cs @@ -0,0 +1,34 @@ +using MicCheck.Api.Identities; +using NUnit.Framework; + +namespace MicCheck.Api.Tests.Unit.Identities; + +[TestFixture] +public class AdminIdentityResponseTests +{ + [Test] + public void WhenMappingAnIdentityWithNoTraits_ThenTheResponseHasAnEmptyTraitsList() + { + var identity = new Identity { Id = 1, Identifier = "user-1", EnvironmentId = 5, CreatedAt = DateTimeOffset.UtcNow }; + + var response = AdminIdentityResponse.From(identity); + + Assert.That(response.Id, Is.EqualTo(1)); + Assert.That(response.Identifier, Is.EqualTo("user-1")); + Assert.That(response.EnvironmentId, Is.EqualTo(5)); + Assert.That(response.Traits, Is.Empty); + } + + [Test] + public void WhenMappingAnIdentityWithTraits_ThenEachTraitIsMapped() + { + var identity = new Identity { Id = 1, Identifier = "user-1", EnvironmentId = 1, CreatedAt = DateTimeOffset.UtcNow }; + identity.Traits.Add(new IdentityTrait { Key = "plan", Value = "premium" }); + + var response = AdminIdentityResponse.From(identity); + + Assert.That(response.Traits, Has.Count.EqualTo(1)); + Assert.That(response.Traits[0].Key, Is.EqualTo("plan")); + Assert.That(response.Traits[0].Value, Is.EqualTo("premium")); + } +} diff --git a/tests/api/MicCheck.Api.Tests.Unit/Identities/AdminIdentityServiceTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Identities/AdminIdentityServiceTests.cs new file mode 100644 index 0000000..48dcafd --- /dev/null +++ b/tests/api/MicCheck.Api.Tests.Unit/Identities/AdminIdentityServiceTests.cs @@ -0,0 +1,251 @@ +using MicCheck.Api.Data; +using MicCheck.Api.Features; +using MicCheck.Api.Identities; +using MicCheck.Api.Tests.Unit.TestSupport; +using Moq; +using NUnit.Framework; + +namespace MicCheck.Api.Tests.Unit.Identities; + +[TestFixture] +public class AdminIdentityServiceTests +{ + private Mock _db = null!; + private List _identities = null!; + private List _traits = null!; + private List _featureStates = null!; + private AdminIdentityService _service = null!; + private const int EnvironmentId = 1; + + [SetUp] + public void SetUp() + { + _db = new Mock(); + _identities = []; + _db.SetupDbSetWithGeneratedIds(c => c.Identities, _identities); + _traits = []; + var traitsSet = MockDbSetFactory.Create(_traits); + traitsSet.Setup(m => m.Add(It.IsAny())).Callback(trait => + { + _traits.Add(trait); + _identities.FirstOrDefault(i => i.Id == trait.IdentityId)?.Traits.Add(trait); + }); + traitsSet.Setup(m => m.Remove(It.IsAny())).Callback(trait => + { + _traits.Remove(trait); + _identities.FirstOrDefault(i => i.Id == trait.IdentityId)?.Traits.Remove(trait); + }); + _db.Setup(c => c.IdentityTraits).Returns(traitsSet.Object); + _featureStates = []; + _db.SetupDbSetWithGeneratedIds(c => c.FeatureStates, _featureStates); + + _service = new AdminIdentityService(_db.Object); + } + + [Test] + public async Task WhenCreatingAnIdentityWithANewIdentifier_ThenItIsPersisted() + { + var identity = await _service.CreateAsync(EnvironmentId, "user-1"); + + Assert.That(identity, Is.Not.Null); + Assert.That(identity!.Identifier, Is.EqualTo("user-1")); + Assert.That(_identities, Has.Count.EqualTo(1)); + } + + [Test] + public async Task WhenCreatingAnIdentityWithAnIdentifierThatAlreadyExistsInTheEnvironment_ThenNullIsReturned() + { + await _service.CreateAsync(EnvironmentId, "user-1"); + + var result = await _service.CreateAsync(EnvironmentId, "user-1"); + + Assert.That(result, Is.Null); + Assert.That(_identities, Has.Count.EqualTo(1)); + } + + [Test] + public async Task WhenCreatingAnIdentityWithTheSameIdentifierInADifferentEnvironment_ThenItIsPersisted() + { + await _service.CreateAsync(EnvironmentId, "user-1"); + + var result = await _service.CreateAsync(999, "user-1"); + + Assert.That(result, Is.Not.Null); + Assert.That(_identities, Has.Count.EqualTo(2)); + } + + [Test] + public async Task WhenListingIdentitiesForAnEnvironment_ThenOnlyThatEnvironmentsIdentitiesAreReturnedWithTheTotalCount() + { + await _service.CreateAsync(EnvironmentId, "user-1"); + await _service.CreateAsync(EnvironmentId, "user-2"); + await _service.CreateAsync(999, "other-env-user"); + + var result = await _service.ListAsync(EnvironmentId, page: 1, pageSize: 20); + + Assert.That(result.Total, Is.EqualTo(2)); + Assert.That(result.Items, Has.Count.EqualTo(2)); + } + + [Test] + public async Task WhenListingIdentitiesWithPagination_ThenTheSecondPageSkipsTheFirstPageSize() + { + for (var i = 0; i < 5; i++) + await _service.CreateAsync(EnvironmentId, $"user-{i}"); + + var result = await _service.ListAsync(EnvironmentId, page: 2, pageSize: 2); + + Assert.That(result.Total, Is.EqualTo(5)); + Assert.That(result.Items, Has.Count.EqualTo(2)); + Assert.That(result.Items[0].Identifier, Is.EqualTo("user-2")); + } + + [Test] + public async Task WhenFindingAnIdentityByIdInTheCorrectEnvironment_ThenItIsReturned() + { + var identity = await _service.CreateAsync(EnvironmentId, "user-1"); + + var found = await _service.FindByIdAsync(identity!.Id, EnvironmentId); + + Assert.That(found, Is.Not.Null); + } + + [Test] + public async Task WhenFindingAnIdentityByIdInADifferentEnvironment_ThenNullIsReturned() + { + var identity = await _service.CreateAsync(EnvironmentId, "user-1"); + + var found = await _service.FindByIdAsync(identity!.Id, 999); + + Assert.That(found, Is.Null); + } + + [Test] + public async Task WhenUpsertingATraitForAnIdentityThatDoesNotExist_ThenNullIsReturned() + { + var result = await _service.UpsertTraitAsync(999, EnvironmentId, "plan", "premium"); + + Assert.That(result, Is.Null); + } + + [Test] + public async Task WhenUpsertingANewTrait_ThenItIsAddedToTheIdentity() + { + var identity = await _service.CreateAsync(EnvironmentId, "user-1"); + + var response = await _service.UpsertTraitAsync(identity!.Id, EnvironmentId, "plan", "premium"); + + Assert.That(response!.Key, Is.EqualTo("plan")); + Assert.That(response.Value, Is.EqualTo("premium")); + Assert.That(_traits, Has.Count.EqualTo(1)); + } + + [Test] + public async Task WhenUpsertingATraitThatAlreadyExists_ThenItsValueIsUpdatedWithoutDuplication() + { + var identity = await _service.CreateAsync(EnvironmentId, "user-1"); + await _service.UpsertTraitAsync(identity!.Id, EnvironmentId, "plan", "free"); + + await _service.UpsertTraitAsync(identity.Id, EnvironmentId, "plan", "premium"); + + Assert.That(_traits, Has.Count.EqualTo(1)); + Assert.That(_traits[0].Value, Is.EqualTo("premium")); + } + + [Test] + public async Task WhenDeletingATraitFromAnIdentityThatDoesNotExist_ThenFalseIsReturned() + { + var result = await _service.DeleteTraitAsync(999, EnvironmentId, "plan"); + + Assert.That(result, Is.False); + } + + [Test] + public async Task WhenDeletingATraitThatDoesNotExistOnTheIdentity_ThenFalseIsReturned() + { + var identity = await _service.CreateAsync(EnvironmentId, "user-1"); + + var result = await _service.DeleteTraitAsync(identity!.Id, EnvironmentId, "unknown"); + + Assert.That(result, Is.False); + } + + [Test] + public async Task WhenDeletingATraitThatExists_ThenItIsRemovedAndTrueIsReturned() + { + var identity = await _service.CreateAsync(EnvironmentId, "user-1"); + await _service.UpsertTraitAsync(identity!.Id, EnvironmentId, "plan", "premium"); + + var result = await _service.DeleteTraitAsync(identity.Id, EnvironmentId, "plan"); + + Assert.That(result, Is.True); + Assert.That(_traits, Is.Empty); + } + + [Test] + public void WhenDeletingAnIdentityThatDoesNotExist_ThenNoExceptionIsThrown() + { + Assert.DoesNotThrowAsync(() => _service.DeleteAsync(999)); + } + + [Test] + public async Task WhenDeletingAnIdentity_ThenItIsRemovedFromTheDatabase() + { + var identity = await _service.CreateAsync(EnvironmentId, "user-1"); + + await _service.DeleteAsync(identity!.Id); + + Assert.That(_identities.Any(i => i.Id == identity.Id), Is.False); + } + + [Test] + public async Task WhenGettingFeatureStatesForAnIdentity_ThenOnlyThatIdentitysStatesInTheEnvironmentAreReturned() + { + _featureStates.Add(new FeatureState { Id = 1, FeatureId = 1, EnvironmentId = EnvironmentId, IdentityId = 1, CreatedAt = DateTimeOffset.UtcNow, UpdatedAt = DateTimeOffset.UtcNow }); + _featureStates.Add(new FeatureState { Id = 2, FeatureId = 2, EnvironmentId = EnvironmentId, IdentityId = 2, CreatedAt = DateTimeOffset.UtcNow, UpdatedAt = DateTimeOffset.UtcNow }); + + var states = await _service.GetFeatureStatesAsync(1, EnvironmentId); + + Assert.That(states, Has.Count.EqualTo(1)); + Assert.That(states[0].FeatureId, Is.EqualTo(1)); + } + + [Test] + public async Task WhenSettingAFeatureStateThatDoesNotExist_ThenANewOverrideIsCreated() + { + var state = await _service.SetFeatureStateAsync(1, EnvironmentId, featureId: 1, enabled: true, value: "on"); + + Assert.That(state.IdentityId, Is.EqualTo(1)); + Assert.That(state.Enabled, Is.True); + Assert.That(_featureStates, Has.Count.EqualTo(1)); + } + + [Test] + public async Task WhenSettingAFeatureStateThatAlreadyExists_ThenItIsUpdatedAndVersionIncremented() + { + var created = await _service.SetFeatureStateAsync(1, EnvironmentId, featureId: 1, enabled: false, value: "off"); + var versionBeforeUpdate = created.Version; + + var updated = await _service.SetFeatureStateAsync(1, EnvironmentId, featureId: 1, enabled: true, value: "on"); + + Assert.That(_featureStates, Has.Count.EqualTo(1)); + Assert.That(updated.Enabled, Is.True); + Assert.That(updated.Version, Is.EqualTo(versionBeforeUpdate + 1)); + } + + [Test] + public void WhenDeletingAFeatureStateThatDoesNotExist_ThenNoExceptionIsThrown() + { + Assert.DoesNotThrowAsync(() => _service.DeleteFeatureStateAsync(1, EnvironmentId, featureId: 1)); + } + + [Test] + public async Task WhenDeletingAFeatureStateThatExists_ThenItIsRemoved() + { + await _service.SetFeatureStateAsync(1, EnvironmentId, featureId: 1, enabled: true, value: "on"); + + await _service.DeleteFeatureStateAsync(1, EnvironmentId, featureId: 1); + + Assert.That(_featureStates, Is.Empty); + } +} diff --git a/tests/api/MicCheck.Api.Tests.Unit/Identities/IdentitiesControllerTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Identities/IdentitiesControllerTests.cs new file mode 100644 index 0000000..7b66443 --- /dev/null +++ b/tests/api/MicCheck.Api.Tests.Unit/Identities/IdentitiesControllerTests.cs @@ -0,0 +1,92 @@ +using System.Security.Claims; +using MicCheck.Api.Data; +using MicCheck.Api.Environments; +using MicCheck.Api.Features; +using MicCheck.Api.Identities; +using MicCheck.Api.Projects; +using MicCheck.Api.Segments; +using MicCheck.Api.Tests.Unit.TestSupport; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Caching.Memory; +using Moq; +using NUnit.Framework; +using AppEnvironment = MicCheck.Api.Environments.Environment; + +namespace MicCheck.Api.Tests.Unit.Identities; + +[TestFixture] +public class IdentitiesControllerTests +{ + private Mock _db = null!; + private List _identities = null!; + private IdentitiesController _controller = null!; + private const int EnvironmentId = 1; + private const int ProjectId = 1; + + [SetUp] + public void SetUp() + { + _db = new Mock(); + + _db.SetupDbSet(c => c.Projects, [ + new Project { Id = ProjectId, Name = "Test Project", OrganizationId = 1, CreatedAt = DateTimeOffset.UtcNow } + ]); + var environments = new List { + new() { Id = EnvironmentId, Name = "Test Env", ApiKey = "test-env-key", ProjectId = ProjectId, CreatedAt = DateTimeOffset.UtcNow } + }; + var environmentsSet = MockDbSetFactory.Create(environments); + environmentsSet.Setup(m => m.FindAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((object[] keys, CancellationToken _) => environments.FirstOrDefault(e => e.Id == (int)keys[0])); + _db.Setup(c => c.Environments).Returns(environmentsSet.Object); + _db.SetupDbSet(c => c.Features, [ + new Feature { Id = 1, Name = "dark_mode", ProjectId = ProjectId, CreatedAt = DateTimeOffset.UtcNow } + ]); + _db.SetupDbSet(c => c.FeatureStates, [ + new FeatureState { FeatureId = 1, EnvironmentId = EnvironmentId, Enabled = true, CreatedAt = DateTimeOffset.UtcNow, UpdatedAt = DateTimeOffset.UtcNow } + ]); + _identities = []; + _db.SetupDbSetWithGeneratedIds(c => c.Identities, _identities); + _db.SetupDbSet(c => c.IdentityTraits, new List()); + _db.SetupDbSetWithGeneratedIds(c => c.Segments, new List()); + _db.SetupDbSet(c => c.SegmentRules, new List()); + _db.SetupDbSet(c => c.SegmentConditions, new List()); + _db.SetupDbSetWithGeneratedIds(c => c.FeatureSegments, new List()); + + var meterFactory = new Mock(); + meterFactory.Setup(f => f.Create(It.IsAny())) + .Returns(new System.Diagnostics.Metrics.Meter("test")); + var usageMetrics = new FeatureUsageMetrics(meterFactory.Object); + var evaluationService = new FeatureEvaluationService(_db.Object, new SegmentEvaluator(), new FlagCache(new MemoryCache(new MemoryCacheOptions())), usageMetrics); + var resolutionService = new IdentityResolutionService(_db.Object); + + _controller = new IdentitiesController(evaluationService, resolutionService); + + var identity = new ClaimsIdentity([new Claim("EnvironmentId", EnvironmentId.ToString())], "EnvironmentKey"); + _controller.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal(identity) } + }; + } + + [Test] + public async Task WhenIdentifierIsMissing_ThenBadRequestIsReturned() + { + var result = await _controller.GetByIdentifier("", CancellationToken.None); + + Assert.That(result.Result, Is.InstanceOf()); + } + + [Test] + public async Task WhenGettingByIdentifier_ThenFlagsAndTraitsAreReturnedForTheResolvedIdentity() + { + var result = await _controller.GetByIdentifier("user-1", CancellationToken.None); + + var ok = result.Result as OkObjectResult; + Assert.That(ok, Is.Not.Null); + var response = (IdentityResponse)ok!.Value!; + Assert.That(response.Flags, Has.Count.EqualTo(1)); + Assert.That(response.Flags[0].Feature.Name, Is.EqualTo("dark_mode")); + Assert.That(_identities.Any(i => i.Identifier == "user-1"), Is.True); + } +} diff --git a/tests/api/MicCheck.Api.Tests.Unit/Identities/TraitInputTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Identities/TraitInputTests.cs new file mode 100644 index 0000000..d148e62 --- /dev/null +++ b/tests/api/MicCheck.Api.Tests.Unit/Identities/TraitInputTests.cs @@ -0,0 +1,125 @@ +using System.Text.Json; +using MicCheck.Api.Identities; +using NUnit.Framework; + +namespace MicCheck.Api.Tests.Unit.Identities; + +[TestFixture] +public class TraitInputTests +{ + private static JsonElement ParseElement(string json) => JsonDocument.Parse(json).RootElement; + + [Test] + public void WhenTraitValueIsAJsonString_ThenGetStringValueReturnsTheString() + { + var trait = new TraitInput("plan", ParseElement("\"premium\"")); + + Assert.That(trait.GetStringValue(), Is.EqualTo("premium")); + Assert.That(trait.GetValueType(), Is.EqualTo(TraitValueType.String)); + } + + [Test] + public void WhenTraitValueIsJsonTrue_ThenGetStringValueReturnsTrueAndTypeIsBoolean() + { + var trait = new TraitInput("beta", ParseElement("true")); + + Assert.That(trait.GetStringValue(), Is.EqualTo("true")); + Assert.That(trait.GetValueType(), Is.EqualTo(TraitValueType.Boolean)); + } + + [Test] + public void WhenTraitValueIsJsonFalse_ThenGetStringValueReturnsFalseAndTypeIsBoolean() + { + var trait = new TraitInput("beta", ParseElement("false")); + + Assert.That(trait.GetStringValue(), Is.EqualTo("false")); + Assert.That(trait.GetValueType(), Is.EqualTo(TraitValueType.Boolean)); + } + + [Test] + public void WhenTraitValueIsJsonNull_ThenGetStringValueReturnsEmptyString() + { + var trait = new TraitInput("plan", ParseElement("null")); + + Assert.That(trait.GetStringValue(), Is.EqualTo(string.Empty)); + } + + [Test] + public void WhenTraitValueIsAJsonInteger_ThenTypeIsInteger() + { + var trait = new TraitInput("age", ParseElement("42")); + + Assert.That(trait.GetStringValue(), Is.EqualTo("42")); + Assert.That(trait.GetValueType(), Is.EqualTo(TraitValueType.Integer)); + } + + [Test] + public void WhenTraitValueIsAJsonFloat_ThenTypeIsFloat() + { + var trait = new TraitInput("score", ParseElement("4.5")); + + Assert.That(trait.GetStringValue(), Is.EqualTo("4.5")); + Assert.That(trait.GetValueType(), Is.EqualTo(TraitValueType.Float)); + } + + [Test] + public void WhenTraitValueIsAJsonArray_ThenGetStringValueFallsBackToToString() + { + var element = ParseElement("[1,2,3]"); + var trait = new TraitInput("list", element); + + Assert.That(trait.GetStringValue(), Is.EqualTo(element.ToString())); + Assert.That(trait.GetValueType(), Is.EqualTo(TraitValueType.String)); + } + + [Test] + public void WhenTraitValueIsNull_ThenGetStringValueReturnsEmptyStringAndTypeIsString() + { + var trait = new TraitInput("plan", null); + + Assert.That(trait.GetStringValue(), Is.EqualTo(string.Empty)); + Assert.That(trait.GetValueType(), Is.EqualTo(TraitValueType.String)); + } + + [Test] + public void WhenTraitValueIsAPlainBool_ThenTypeIsBoolean() + { + var trait = new TraitInput("beta", true); + + Assert.That(trait.GetStringValue(), Is.EqualTo("True")); + Assert.That(trait.GetValueType(), Is.EqualTo(TraitValueType.Boolean)); + } + + [Test] + public void WhenTraitValueIsAPlainInt_ThenTypeIsInteger() + { + var trait = new TraitInput("age", 42); + + Assert.That(trait.GetValueType(), Is.EqualTo(TraitValueType.Integer)); + } + + [Test] + public void WhenTraitValueIsAPlainLong_ThenTypeIsInteger() + { + var trait = new TraitInput("age", 42L); + + Assert.That(trait.GetValueType(), Is.EqualTo(TraitValueType.Integer)); + } + + [Test] + public void WhenTraitValueIsAPlainDouble_ThenTypeIsFloat() + { + var trait = new TraitInput("score", 4.5d); + + Assert.That(trait.GetValueType(), Is.EqualTo(TraitValueType.Float)); + } + + [Test] + public void WhenTraitValueIsAPlainString_ThenTypeIsString() + { + var trait = new TraitInput("plan", "premium"); + + Assert.That(trait.GetStringValue(), Is.EqualTo("premium")); + Assert.That(trait.GetValueType(), Is.EqualTo(TraitValueType.String)); + } +}