From 837c51d366cccbad230126591c4f01bfa83f2b9d Mon Sep 17 00:00:00 2001 From: James Wampler Date: Sun, 5 Jul 2026 14:53:32 -0700 Subject: [PATCH] Add unit tests for Environments namespace (0% to 99.5% coverage) Covers EnvironmentService (CRUD, clone, list, find-by-key), the EnvironmentsController orchestration layer (webhooks, audit logs, identities/traits, feature states, feature segments, identity segments), EnvironmentDocumentService/Controller, response mappers, and request validators. --- .../CreateEnvironmentRequestValidatorTests.cs | 95 ++ .../EnvironmentDocumentControllerTests.cs | 69 ++ .../EnvironmentDocumentResponseTests.cs | 63 ++ .../EnvironmentDocumentServiceTests.cs | 120 +++ .../Environments/EnvironmentResponseTests.cs | 30 + .../Environments/EnvironmentServiceTests.cs | 69 ++ .../EnvironmentsControllerTests.cs | 905 ++++++++++++++++++ 7 files changed, 1351 insertions(+) create mode 100644 tests/api/MicCheck.Api.Tests.Unit/Environments/CreateEnvironmentRequestValidatorTests.cs create mode 100644 tests/api/MicCheck.Api.Tests.Unit/Environments/EnvironmentDocumentControllerTests.cs create mode 100644 tests/api/MicCheck.Api.Tests.Unit/Environments/EnvironmentDocumentResponseTests.cs create mode 100644 tests/api/MicCheck.Api.Tests.Unit/Environments/EnvironmentDocumentServiceTests.cs create mode 100644 tests/api/MicCheck.Api.Tests.Unit/Environments/EnvironmentResponseTests.cs create mode 100644 tests/api/MicCheck.Api.Tests.Unit/Environments/EnvironmentsControllerTests.cs diff --git a/tests/api/MicCheck.Api.Tests.Unit/Environments/CreateEnvironmentRequestValidatorTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Environments/CreateEnvironmentRequestValidatorTests.cs new file mode 100644 index 0000000..0164828 --- /dev/null +++ b/tests/api/MicCheck.Api.Tests.Unit/Environments/CreateEnvironmentRequestValidatorTests.cs @@ -0,0 +1,95 @@ +using MicCheck.Api.Environments; +using NUnit.Framework; + +namespace MicCheck.Api.Tests.Unit.Environments; + +[TestFixture] +public class CreateEnvironmentRequestValidatorTests +{ + private CreateEnvironmentRequestValidator _validator = null!; + + [SetUp] + public void SetUp() => _validator = new CreateEnvironmentRequestValidator(); + + [Test] + public void WhenTheRequestIsValid_ThenValidationSucceeds() + { + var result = _validator.Validate(new CreateEnvironmentRequest("Staging", 1)); + + Assert.That(result.IsValid, Is.True); + } + + [Test] + public void WhenNameIsEmpty_ThenValidationFails() + { + var result = _validator.Validate(new CreateEnvironmentRequest("", 1)); + + Assert.That(result.IsValid, Is.False); + } + + [Test] + public void WhenNameExceedsMaximumLength_ThenValidationFails() + { + var result = _validator.Validate(new CreateEnvironmentRequest(new string('a', 201), 1)); + + Assert.That(result.IsValid, Is.False); + } + + [Test] + public void WhenProjectIdIsZeroOrLess_ThenValidationFails() + { + var result = _validator.Validate(new CreateEnvironmentRequest("Staging", 0)); + + Assert.That(result.IsValid, Is.False); + } +} + +[TestFixture] +public class UpdateEnvironmentRequestValidatorTests +{ + private UpdateEnvironmentRequestValidator _validator = null!; + + [SetUp] + public void SetUp() => _validator = new UpdateEnvironmentRequestValidator(); + + [Test] + public void WhenTheRequestIsValid_ThenValidationSucceeds() + { + var result = _validator.Validate(new UpdateEnvironmentRequest("Renamed")); + + Assert.That(result.IsValid, Is.True); + } + + [Test] + public void WhenNameIsEmpty_ThenValidationFails() + { + var result = _validator.Validate(new UpdateEnvironmentRequest("")); + + Assert.That(result.IsValid, Is.False); + } +} + +[TestFixture] +public class CloneEnvironmentRequestValidatorTests +{ + private CloneEnvironmentRequestValidator _validator = null!; + + [SetUp] + public void SetUp() => _validator = new CloneEnvironmentRequestValidator(); + + [Test] + public void WhenTheRequestIsValid_ThenValidationSucceeds() + { + var result = _validator.Validate(new CloneEnvironmentRequest("Staging Copy")); + + Assert.That(result.IsValid, Is.True); + } + + [Test] + public void WhenNameIsEmpty_ThenValidationFails() + { + var result = _validator.Validate(new CloneEnvironmentRequest("")); + + Assert.That(result.IsValid, Is.False); + } +} diff --git a/tests/api/MicCheck.Api.Tests.Unit/Environments/EnvironmentDocumentControllerTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Environments/EnvironmentDocumentControllerTests.cs new file mode 100644 index 0000000..4d963d3 --- /dev/null +++ b/tests/api/MicCheck.Api.Tests.Unit/Environments/EnvironmentDocumentControllerTests.cs @@ -0,0 +1,69 @@ +using System.Security.Claims; +using MicCheck.Api.Data; +using MicCheck.Api.Environments; +using MicCheck.Api.Organizations; +using MicCheck.Api.Projects; +using MicCheck.Api.Tests.Unit.TestSupport; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Moq; +using NUnit.Framework; +using AppEnvironment = MicCheck.Api.Environments.Environment; + +namespace MicCheck.Api.Tests.Unit.Environments; + +[TestFixture] +public class EnvironmentDocumentControllerTests +{ + private Mock _db = null!; + private List _environments = null!; + private EnvironmentDocumentController _controller = null!; + private const int EnvironmentId = 1; + private const int ProjectId = 1; + + [SetUp] + public void SetUp() + { + _db = new Mock(); + + _environments = [new AppEnvironment { Id = EnvironmentId, Name = "Production", ApiKey = "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.Projects, [new Project { Id = ProjectId, Name = "Test Project", OrganizationId = 1, CreatedAt = DateTimeOffset.UtcNow }]); + _db.SetupDbSet(c => c.Organizations, [new Organization { Id = 1, Name = "Org", CreatedAt = DateTimeOffset.UtcNow }]); + _db.SetupDbSet(c => c.Features, []); + _db.SetupDbSet(c => c.FeatureStates, []); + + _controller = new EnvironmentDocumentController(new EnvironmentDocumentService(_db.Object)); + + var identity = new ClaimsIdentity([new Claim("EnvironmentId", EnvironmentId.ToString())], "TestAuth"); + _controller.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal(identity) } + }; + } + + [Test] + public async Task WhenTheEnvironmentDocumentExists_ThenItIsReturned() + { + var result = await _controller.Get(CancellationToken.None); + + var ok = result.Result as OkObjectResult; + Assert.That(ok, Is.Not.Null); + var document = ok!.Value as EnvironmentDocumentResponse; + Assert.That(document!.Id, Is.EqualTo(EnvironmentId)); + } + + [Test] + public async Task WhenTheEnvironmentDocumentDoesNotExist_ThenNotFoundIsReturned() + { + _environments.Clear(); + + var result = await _controller.Get(CancellationToken.None); + + Assert.That(result.Result, Is.InstanceOf()); + } +} diff --git a/tests/api/MicCheck.Api.Tests.Unit/Environments/EnvironmentDocumentResponseTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Environments/EnvironmentDocumentResponseTests.cs new file mode 100644 index 0000000..4d482c0 --- /dev/null +++ b/tests/api/MicCheck.Api.Tests.Unit/Environments/EnvironmentDocumentResponseTests.cs @@ -0,0 +1,63 @@ +using MicCheck.Api.Environments; +using MicCheck.Api.Segments; +using NUnit.Framework; + +namespace MicCheck.Api.Tests.Unit.Environments; + +[TestFixture] +public class SegmentDocumentResponseTests +{ + [Test] + public void WhenMappingASegment_ThenOnlyTopLevelRulesAreIncluded() + { + var segment = new Segment { Id = 1, Name = "Beta Users", ProjectId = 1, CreatedAt = DateTimeOffset.UtcNow }; + var topLevelRule = new SegmentRule { Id = 1, SegmentId = 1, Type = SegmentRuleType.All }; + var nestedRule = new SegmentRule { Id = 2, SegmentId = 1, ParentRuleId = 1, Type = SegmentRuleType.Any }; + segment.Rules.Add(topLevelRule); + segment.Rules.Add(nestedRule); + + var response = SegmentDocumentResponse.From(segment); + + Assert.That(response.Id, Is.EqualTo(1)); + Assert.That(response.Name, Is.EqualTo("Beta Users")); + Assert.That(response.Rules, Has.Count.EqualTo(1)); + Assert.That(response.Rules[0].Id, Is.EqualTo(1)); + } +} + +[TestFixture] +public class SegmentRuleDocumentResponseTests +{ + [Test] + public void WhenMappingARule_ThenTypeIsUpperInvariantAndChildRulesAreIncluded() + { + var rule = new SegmentRule { Id = 1, SegmentId = 1, Type = SegmentRuleType.Any }; + var condition = new SegmentCondition { Id = 1, RuleId = 1, Property = "plan", Operator = SegmentConditionOperator.Equal, Value = "pro" }; + var childRule = new SegmentRule { Id = 2, SegmentId = 1, ParentRuleId = 1, Type = SegmentRuleType.All }; + rule.Conditions.Add(condition); + rule.ChildRules.Add(childRule); + + var response = SegmentRuleDocumentResponse.From(rule); + + Assert.That(response.Type, Is.EqualTo("ANY")); + Assert.That(response.Conditions, Has.Count.EqualTo(1)); + Assert.That(response.Rules, Has.Count.EqualTo(1)); + Assert.That(response.Rules[0].Id, Is.EqualTo(2)); + } +} + +[TestFixture] +public class SegmentConditionDocumentResponseTests +{ + [Test] + public void WhenMappingACondition_ThenPropertyOperatorAndValueAreCopied() + { + var condition = new SegmentCondition { Id = 1, RuleId = 1, Property = "plan", Operator = SegmentConditionOperator.Contains, Value = "pro" }; + + var response = SegmentConditionDocumentResponse.From(condition); + + Assert.That(response.Property, Is.EqualTo("plan")); + Assert.That(response.Operator, Is.EqualTo("Contains")); + Assert.That(response.Value, Is.EqualTo("pro")); + } +} diff --git a/tests/api/MicCheck.Api.Tests.Unit/Environments/EnvironmentDocumentServiceTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Environments/EnvironmentDocumentServiceTests.cs new file mode 100644 index 0000000..bfc82c5 --- /dev/null +++ b/tests/api/MicCheck.Api.Tests.Unit/Environments/EnvironmentDocumentServiceTests.cs @@ -0,0 +1,120 @@ +using MicCheck.Api.Data; +using MicCheck.Api.Environments; +using MicCheck.Api.Features; +using MicCheck.Api.Organizations; +using MicCheck.Api.Projects; +using MicCheck.Api.Segments; +using MicCheck.Api.Tests.Unit.TestSupport; +using Moq; +using NUnit.Framework; +using AppEnvironment = MicCheck.Api.Environments.Environment; + +namespace MicCheck.Api.Tests.Unit.Environments; + +[TestFixture] +public class EnvironmentDocumentServiceTests +{ + private Mock _db = null!; + private List _environments = null!; + private List _projects = null!; + private List _features = null!; + private List _featureStates = null!; + private EnvironmentDocumentService _service = null!; + private const int ProjectId = 1; + private const int EnvironmentId = 1; + + [SetUp] + public void SetUp() + { + _db = new Mock(); + + _environments = [new AppEnvironment { Id = EnvironmentId, Name = "Production", ApiKey = "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); + + _projects = [new Project { Id = ProjectId, Name = "Test Project", OrganizationId = 1, CreatedAt = DateTimeOffset.UtcNow }]; + _db.SetupDbSet(c => c.Projects, _projects); + _db.SetupDbSet(c => c.Organizations, [new Organization { Id = 1, Name = "Org", CreatedAt = DateTimeOffset.UtcNow }]); + + _features = []; + _db.SetupDbSet(c => c.Features, _features); + _featureStates = []; + _db.SetupDbSet(c => c.FeatureStates, _featureStates); + + _service = new EnvironmentDocumentService(_db.Object); + } + + [Test] + public async Task WhenTheEnvironmentDoesNotExist_ThenNullIsReturned() + { + var result = await _service.GetAsync(999); + + Assert.That(result, Is.Null); + } + + [Test] + public async Task WhenTheEnvironmentsProjectDoesNotExist_ThenNullIsReturned() + { + _projects.Clear(); + + var result = await _service.GetAsync(EnvironmentId); + + Assert.That(result, Is.Null); + } + + [Test] + public async Task WhenTheEnvironmentExists_ThenEnvironmentLevelFeatureStatesAreIncluded() + { + var feature = new Feature { Id = 1, Name = "flag_a", ProjectId = ProjectId, CreatedAt = DateTimeOffset.UtcNow }; + _features.Add(feature); + _featureStates.Add(new FeatureState + { + FeatureId = feature.Id, + EnvironmentId = EnvironmentId, + Enabled = true, + Value = "on", + CreatedAt = DateTimeOffset.UtcNow, + UpdatedAt = DateTimeOffset.UtcNow + }); + + var result = await _service.GetAsync(EnvironmentId); + + Assert.That(result, Is.Not.Null); + Assert.That(result!.Id, Is.EqualTo(EnvironmentId)); + Assert.That(result.ApiKey, Is.EqualTo("env-key")); + Assert.That(result.FeatureStates, Has.Count.EqualTo(1)); + } + + [Test] + public async Task WhenTheEnvironmentExists_ThenIdentityAndSegmentScopedFeatureStatesAreExcluded() + { + var feature = new Feature { Id = 1, Name = "flag_a", ProjectId = ProjectId, CreatedAt = DateTimeOffset.UtcNow }; + _features.Add(feature); + _featureStates.Add(new FeatureState + { + FeatureId = feature.Id, + EnvironmentId = EnvironmentId, + IdentityId = 5, + Enabled = true, + CreatedAt = DateTimeOffset.UtcNow, + UpdatedAt = DateTimeOffset.UtcNow + }); + + var result = await _service.GetAsync(EnvironmentId); + + Assert.That(result!.FeatureStates, Is.Empty); + } + + [Test] + public async Task WhenTheProjectHasSegments_ThenTheyAreIncludedInTheProjectResponse() + { + _projects[0].Segments.Add(new Segment { Id = 1, Name = "Beta Users", ProjectId = ProjectId, CreatedAt = DateTimeOffset.UtcNow }); + + var result = await _service.GetAsync(EnvironmentId); + + Assert.That(result!.Project.Segments, Has.Count.EqualTo(1)); + Assert.That(result.Project.Segments[0].Name, Is.EqualTo("Beta Users")); + } +} diff --git a/tests/api/MicCheck.Api.Tests.Unit/Environments/EnvironmentResponseTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Environments/EnvironmentResponseTests.cs new file mode 100644 index 0000000..471baa4 --- /dev/null +++ b/tests/api/MicCheck.Api.Tests.Unit/Environments/EnvironmentResponseTests.cs @@ -0,0 +1,30 @@ +using NUnit.Framework; +using AppEnvironment = MicCheck.Api.Environments.Environment; + +namespace MicCheck.Api.Tests.Unit.Environments; + +[TestFixture] +public class EnvironmentResponseTests +{ + [Test] + public void WhenMappingAnEnvironment_ThenAllFieldsAreCopied() + { + var createdAt = DateTimeOffset.UtcNow; + var environment = new AppEnvironment + { + Id = 1, + Name = "Production", + ApiKey = "env-key-abc", + ProjectId = 2, + CreatedAt = createdAt + }; + + var response = MicCheck.Api.Environments.EnvironmentResponse.From(environment); + + Assert.That(response.Id, Is.EqualTo(1)); + Assert.That(response.Name, Is.EqualTo("Production")); + Assert.That(response.ApiKey, Is.EqualTo("env-key-abc")); + Assert.That(response.ProjectId, Is.EqualTo(2)); + Assert.That(response.CreatedAt, Is.EqualTo(createdAt)); + } +} diff --git a/tests/api/MicCheck.Api.Tests.Unit/Environments/EnvironmentServiceTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Environments/EnvironmentServiceTests.cs index 23205f6..d7591f1 100755 --- a/tests/api/MicCheck.Api.Tests.Unit/Environments/EnvironmentServiceTests.cs +++ b/tests/api/MicCheck.Api.Tests.Unit/Environments/EnvironmentServiceTests.cs @@ -153,4 +153,73 @@ public class EnvironmentServiceTests Assert.That(cloned.ApiKey, Is.Not.EqualTo(source.ApiKey)); } + + [Test] + public void WhenCloningFromANonExistentApiKey_ThenKeyNotFoundExceptionIsThrown() + { + Assert.That(async () => await _service.CloneAsync("missing-key", "Staging"), Throws.TypeOf()); + } + + [Test] + public async Task WhenListingByProject_ThenOnlyEnvironmentsForThatProjectAreReturned() + { + await _service.CreateAsync(ProjectId, "Env1"); + await _service.CreateAsync(999, "OtherProjectEnv"); + + var result = await _service.ListByProjectAsync(ProjectId); + + Assert.That(result, Has.Count.EqualTo(1)); + Assert.That(result[0].Name, Is.EqualTo("Env1")); + } + + [Test] + public async Task WhenFindingByApiKeyThatDoesNotExist_ThenNullIsReturned() + { + var result = await _service.FindByApiKeyAsync("missing-key"); + + Assert.That(result, Is.Null); + } + + [Test] + public async Task WhenFindingByApiKeyThatExists_ThenTheEnvironmentIsReturned() + { + var created = await _service.CreateAsync(ProjectId, "Production"); + + var result = await _service.FindByApiKeyAsync(created.ApiKey); + + Assert.That(result, Is.Not.Null); + Assert.That(result!.Id, Is.EqualTo(created.Id)); + } + + [Test] + public async Task WhenUpdatingAnEnvironment_ThenNameIsChanged() + { + var created = await _service.CreateAsync(ProjectId, "Original"); + + var updated = await _service.UpdateAsync(created.ApiKey, "Renamed"); + + Assert.That(updated.Name, Is.EqualTo("Renamed")); + } + + [Test] + public void WhenUpdatingANonExistentEnvironment_ThenKeyNotFoundExceptionIsThrown() + { + Assert.That(async () => await _service.UpdateAsync("missing-key", "Renamed"), Throws.TypeOf()); + } + + [Test] + public async Task WhenDeletingAnExistingEnvironment_ThenItIsRemoved() + { + var created = await _service.CreateAsync(ProjectId, "ToDelete"); + + await _service.DeleteAsync(created.ApiKey); + + Assert.That(_environments, Is.Empty); + } + + [Test] + public void WhenDeletingANonExistentEnvironment_ThenNoExceptionIsThrown() + { + Assert.That(async () => await _service.DeleteAsync("missing-key"), Throws.Nothing); + } } diff --git a/tests/api/MicCheck.Api.Tests.Unit/Environments/EnvironmentsControllerTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Environments/EnvironmentsControllerTests.cs new file mode 100644 index 0000000..6fb5401 --- /dev/null +++ b/tests/api/MicCheck.Api.Tests.Unit/Environments/EnvironmentsControllerTests.cs @@ -0,0 +1,905 @@ +using MicCheck.Api.Audit; +using MicCheck.Api.Common; +using MicCheck.Api.Data; +using MicCheck.Api.Environments; +using MicCheck.Api.Features; +using MicCheck.Api.Identities; +using MicCheck.Api.Organizations; +using MicCheck.Api.Projects; +using MicCheck.Api.Segments; +using MicCheck.Api.Tests.Unit.TestSupport; +using MicCheck.Api.Webhooks; +using Microsoft.AspNetCore.Mvc; +using Moq; +using NUnit.Framework; +using AppEnvironment = MicCheck.Api.Environments.Environment; + +namespace MicCheck.Api.Tests.Unit.Environments; + +[TestFixture] +public class EnvironmentsControllerTests +{ + private Mock _db = null!; + private List _environments = null!; + private List _projects = null!; + private List _features = null!; + private List _featureStates = null!; + private List _identities = null!; + private List _identityTraits = null!; + private List _segments = null!; + private List _featureSegments = null!; + private List _webhooks = null!; + private List _webhookDeliveryLogs = null!; + private List _auditLogs = null!; + + private EnvironmentsController _controller = null!; + private AuditLogQueryService _auditLogQueryService = null!; + private AdminIdentityService _adminIdentityService = null!; + private FeatureStateService _featureStateService = null!; + private FeatureSegmentService _featureSegmentService = null!; + private SegmentService _segmentService = null!; + private SegmentEvaluator _segmentEvaluator = null!; + + private const int OrganizationId = 1; + private const int ProjectId = 1; + private const int EnvironmentId = 1; + private AppEnvironment _environment = null!; + + [SetUp] + public void SetUp() + { + _db = new Mock(); + + _db.SetupDbSet(c => c.Organizations, [new Organization { Id = OrganizationId, Name = "Org", CreatedAt = DateTimeOffset.UtcNow }]); + _projects = [new Project { Id = ProjectId, Name = "Project", OrganizationId = OrganizationId, CreatedAt = DateTimeOffset.UtcNow }]; + _db.SetupDbSet(c => c.Projects, _projects); + + _environment = new AppEnvironment { Id = EnvironmentId, Name = "Production", ApiKey = "env-key", ProjectId = ProjectId, CreatedAt = DateTimeOffset.UtcNow }; + _environments = [_environment]; + var environmentsSet = MockDbSetFactory.Create(_environments); + environmentsSet.Setup(m => m.Add(It.IsAny())).Callback(env => + { + typeof(AppEnvironment).GetProperty(nameof(AppEnvironment.Id))!.SetValue(env, _environments.Count + 1); + _environments.Add(env); + }); + 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); + + _features = []; + _db.SetupDbSetWithGeneratedIds(c => c.Features, _features); + + _featureStates = []; + _db.SetupDbSetWithGeneratedIds(c => c.FeatureStates, _featureStates); + + _identities = []; + var identitiesSet = MockDbSetFactory.Create(_identities); + identitiesSet.Setup(m => m.Add(It.IsAny())).Callback(i => + { + typeof(Identity).GetProperty(nameof(Identity.Id))!.SetValue(i, _identities.Count + 1); + _identities.Add(i); + }); + _db.Setup(c => c.Identities).Returns(identitiesSet.Object); + + _identityTraits = []; + var traitsSet = MockDbSetFactory.Create(_identityTraits); + traitsSet.Setup(m => m.Add(It.IsAny())).Callback(trait => + { + typeof(IdentityTrait).GetProperty(nameof(IdentityTrait.Id))!.SetValue(trait, _identityTraits.Count + 1); + _identityTraits.Add(trait); + _identities.FirstOrDefault(i => i.Id == trait.IdentityId)?.Traits.Add(trait); + }); + traitsSet.Setup(m => m.Remove(It.IsAny())).Callback(trait => + { + _identityTraits.Remove(trait); + _identities.FirstOrDefault(i => i.Id == trait.IdentityId)?.Traits.Remove(trait); + }); + _db.Setup(c => c.IdentityTraits).Returns(traitsSet.Object); + + _segments = []; + _db.SetupDbSetWithGeneratedIds(c => c.Segments, _segments); + + _featureSegments = []; + _db.SetupDbSetWithGeneratedIds(c => c.FeatureSegments, _featureSegments); + + _webhooks = []; + _db.SetupDbSetWithGeneratedIds(c => c.Webhooks, _webhooks); + + _webhookDeliveryLogs = []; + _db.SetupDbSet(c => c.WebhookDeliveryLogs, _webhookDeliveryLogs); + + _auditLogs = []; + _db.SetupDbSetWithGeneratedIds(c => c.AuditLogs, _auditLogs); + _db.SetupDbSet(c => c.Users, []); + + var webhookQueue = new WebhookQueue(); + var auditServiceMock = new Mock(_db.Object, null!, webhookQueue); + auditServiceMock.Setup(a => a.LogAsync( + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + auditServiceMock.Setup(a => a.RecordAsync( + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + var auditService = auditServiceMock.Object; + + _auditLogQueryService = new AuditLogQueryService(_db.Object); + _adminIdentityService = new AdminIdentityService(_db.Object); + _featureStateService = new FeatureStateService(_db.Object, webhookQueue, auditService); + _featureSegmentService = new FeatureSegmentService(_db.Object); + _segmentService = new SegmentService(_db.Object, auditService); + _segmentEvaluator = new SegmentEvaluator(); + + _controller = new EnvironmentsController( + new EnvironmentService(_db.Object, auditService), + new WebhookService(_db.Object)); + } + + private const string MissingApiKey = "missing-key"; + + // ─── Core CRUD ────────────────────────────────────────────────────────── + + [Test] + public async Task WhenListingEnvironments_ThenPageSizeIsClampedAndResultsAreMapped() + { + var result = await _controller.List(ProjectId, page: 1, pageSize: 1000); + + var ok = result.Result as OkObjectResult; + var paged = ok!.Value as PaginatedResponse; + Assert.That(paged!.Count, Is.EqualTo(1)); + Assert.That(paged.Results, Has.Count.EqualTo(1)); + } + + [Test] + public async Task WhenCreatingAnEnvironment_ThenItIsPersistedAndReturned() + { + var result = await _controller.Create(new CreateEnvironmentRequest("Staging", ProjectId), CancellationToken.None); + + var created = result.Result as CreatedAtActionResult; + Assert.That(created, Is.Not.Null); + var body = created!.Value as EnvironmentResponse; + Assert.That(body!.Name, Is.EqualTo("Staging")); + } + + [Test] + public async Task WhenGettingByApiKeyThatDoesNotExist_ThenNotFoundIsReturned() + { + var result = await _controller.GetByApiKey(MissingApiKey, CancellationToken.None); + + Assert.That(result.Result, Is.InstanceOf()); + } + + [Test] + public async Task WhenGettingByApiKeyThatExists_ThenTheEnvironmentIsReturned() + { + var result = await _controller.GetByApiKey(_environment.ApiKey, CancellationToken.None); + + var ok = result.Result as OkObjectResult; + var body = ok!.Value as EnvironmentResponse; + Assert.That(body!.Id, Is.EqualTo(EnvironmentId)); + } + + [Test] + public async Task WhenUpdatingAnEnvironmentThatDoesNotExist_ThenNotFoundIsReturned() + { + var result = await _controller.Update(MissingApiKey, new UpdateEnvironmentRequest("Renamed"), CancellationToken.None); + + Assert.That(result.Result, Is.InstanceOf()); + } + + [Test] + public async Task WhenUpdatingAnEnvironmentThatExists_ThenTheNameIsChanged() + { + var result = await _controller.Update(_environment.ApiKey, new UpdateEnvironmentRequest("Renamed"), CancellationToken.None); + + var ok = result.Result as OkObjectResult; + var body = ok!.Value as EnvironmentResponse; + Assert.That(body!.Name, Is.EqualTo("Renamed")); + } + + [Test] + public async Task WhenDeletingAnEnvironmentThatDoesNotExist_ThenNotFoundIsReturned() + { + var result = await _controller.Delete(MissingApiKey, CancellationToken.None); + + Assert.That(result, Is.InstanceOf()); + } + + [Test] + public async Task WhenDeletingAnEnvironmentThatExists_ThenNoContentIsReturned() + { + var result = await _controller.Delete(_environment.ApiKey, CancellationToken.None); + + Assert.That(result, Is.InstanceOf()); + Assert.That(_environments, Is.Empty); + } + + [Test] + public async Task WhenCloningAnEnvironmentThatDoesNotExist_ThenNotFoundIsReturned() + { + var result = await _controller.Clone(MissingApiKey, new CloneEnvironmentRequest("Clone"), CancellationToken.None); + + Assert.That(result.Result, Is.InstanceOf()); + } + + [Test] + public async Task WhenCloningAnEnvironmentThatExists_ThenTheCloneIsReturned() + { + var result = await _controller.Clone(_environment.ApiKey, new CloneEnvironmentRequest("Clone"), CancellationToken.None); + + var created = result.Result as CreatedAtActionResult; + var body = created!.Value as EnvironmentResponse; + Assert.That(body!.Name, Is.EqualTo("Clone")); + Assert.That(body.ApiKey, Is.Not.EqualTo(_environment.ApiKey)); + } + + // ─── Webhooks ─────────────────────────────────────────────────────────── + + [Test] + public async Task WhenListingWebhooksForAnEnvironmentThatDoesNotExist_ThenNotFoundIsReturned() + { + var result = await _controller.ListWebhooks(MissingApiKey, CancellationToken.None); + + Assert.That(result.Result, Is.InstanceOf()); + } + + [Test] + public async Task WhenListingWebhooksForAnEnvironmentThatExists_ThenTheyAreReturned() + { + _webhooks.Add(new Webhook { Id = 1, Url = "https://example.com", Scope = WebhookScope.Environment, EnvironmentId = EnvironmentId, Enabled = true, CreatedAt = DateTimeOffset.UtcNow }); + + var result = await _controller.ListWebhooks(_environment.ApiKey, CancellationToken.None); + + var ok = result.Result as OkObjectResult; + var body = ok!.Value as IReadOnlyList; + Assert.That(body, Has.Count.EqualTo(1)); + } + + [Test] + public async Task WhenCreatingAWebhookForAnEnvironmentThatDoesNotExist_ThenNotFoundIsReturned() + { + var result = await _controller.CreateWebhook(MissingApiKey, new CreateWebhookRequest("https://example.com", null, true), CancellationToken.None); + + Assert.That(result.Result, Is.InstanceOf()); + } + + [Test] + public async Task WhenCreatingAWebhookForAnEnvironmentThatExists_ThenItIsPersisted() + { + var result = await _controller.CreateWebhook(_environment.ApiKey, new CreateWebhookRequest("https://example.com", "secret", true), CancellationToken.None); + + var created = result.Result as CreatedAtActionResult; + var body = created!.Value as WebhookResponse; + Assert.That(body!.Url, Is.EqualTo("https://example.com")); + Assert.That(_webhooks, Has.Count.EqualTo(1)); + } + + [Test] + public async Task WhenUpdatingAWebhookForAnEnvironmentThatDoesNotExist_ThenNotFoundIsReturned() + { + var result = await _controller.UpdateWebhook(MissingApiKey, 1, new CreateWebhookRequest("https://example.com", null, true), CancellationToken.None); + + Assert.That(result.Result, Is.InstanceOf()); + } + + [Test] + public async Task WhenUpdatingAWebhookThatBelongsToAnotherEnvironment_ThenNotFoundIsReturned() + { + _webhooks.Add(new Webhook { Id = 1, Url = "https://example.com", Scope = WebhookScope.Environment, EnvironmentId = 999, Enabled = true, CreatedAt = DateTimeOffset.UtcNow }); + + var result = await _controller.UpdateWebhook(_environment.ApiKey, 1, new CreateWebhookRequest("https://updated.com", null, true), CancellationToken.None); + + Assert.That(result.Result, Is.InstanceOf()); + } + + [Test] + public async Task WhenUpdatingAWebhookThatBelongsToTheEnvironment_ThenItIsUpdated() + { + _webhooks.Add(new Webhook { Id = 1, Url = "https://example.com", Scope = WebhookScope.Environment, EnvironmentId = EnvironmentId, Enabled = true, CreatedAt = DateTimeOffset.UtcNow }); + + var result = await _controller.UpdateWebhook(_environment.ApiKey, 1, new CreateWebhookRequest("https://updated.com", null, false), CancellationToken.None); + + var ok = result.Result as OkObjectResult; + var body = ok!.Value as WebhookResponse; + Assert.That(body!.Url, Is.EqualTo("https://updated.com")); + } + + [Test] + public async Task WhenListingWebhookDeliveriesForAnEnvironmentThatDoesNotExist_ThenNotFoundIsReturned() + { + var result = await _controller.ListWebhookDeliveries(MissingApiKey, 1, CancellationToken.None); + + Assert.That(result.Result, Is.InstanceOf()); + } + + [Test] + public async Task WhenListingWebhookDeliveriesForAWebhookThatBelongsToAnotherEnvironment_ThenNotFoundIsReturned() + { + _webhooks.Add(new Webhook { Id = 1, Url = "https://example.com", Scope = WebhookScope.Environment, EnvironmentId = 999, Enabled = true, CreatedAt = DateTimeOffset.UtcNow }); + + var result = await _controller.ListWebhookDeliveries(_environment.ApiKey, 1, CancellationToken.None); + + Assert.That(result.Result, Is.InstanceOf()); + } + + [Test] + public async Task WhenListingWebhookDeliveriesForAWebhookThatBelongsToTheEnvironment_ThenTheyAreReturned() + { + _webhooks.Add(new Webhook { Id = 1, Url = "https://example.com", Scope = WebhookScope.Environment, EnvironmentId = EnvironmentId, Enabled = true, CreatedAt = DateTimeOffset.UtcNow }); + _webhookDeliveryLogs.Add(new WebhookDeliveryLog + { + Id = 1, WebhookId = 1, EventType = "flag.updated", PayloadJson = "{}", Success = true, AttemptNumber = 1, AttemptedAt = DateTimeOffset.UtcNow + }); + + var result = await _controller.ListWebhookDeliveries(_environment.ApiKey, 1, CancellationToken.None); + + var ok = result.Result as OkObjectResult; + var body = ok!.Value as IReadOnlyList; + Assert.That(body, Has.Count.EqualTo(1)); + } + + [Test] + public async Task WhenDeletingAWebhookForAnEnvironmentThatDoesNotExist_ThenNotFoundIsReturned() + { + var result = await _controller.DeleteWebhook(MissingApiKey, 1, CancellationToken.None); + + Assert.That(result, Is.InstanceOf()); + } + + [Test] + public async Task WhenDeletingAWebhookThatBelongsToAnotherEnvironment_ThenNotFoundIsReturned() + { + _webhooks.Add(new Webhook { Id = 1, Url = "https://example.com", Scope = WebhookScope.Environment, EnvironmentId = 999, Enabled = true, CreatedAt = DateTimeOffset.UtcNow }); + + var result = await _controller.DeleteWebhook(_environment.ApiKey, 1, CancellationToken.None); + + Assert.That(result, Is.InstanceOf()); + } + + [Test] + public async Task WhenDeletingAWebhookThatBelongsToTheEnvironment_ThenNoContentIsReturned() + { + _webhooks.Add(new Webhook { Id = 1, Url = "https://example.com", Scope = WebhookScope.Environment, EnvironmentId = EnvironmentId, Enabled = true, CreatedAt = DateTimeOffset.UtcNow }); + + var result = await _controller.DeleteWebhook(_environment.ApiKey, 1, CancellationToken.None); + + Assert.That(result, Is.InstanceOf()); + Assert.That(_webhooks, Is.Empty); + } + + // ─── Audit logs ───────────────────────────────────────────────────────── + + [Test] + public async Task WhenListingAuditLogsForAnEnvironmentThatDoesNotExist_ThenNotFoundIsReturned() + { + var result = await _controller.ListAuditLogs(MissingApiKey, _auditLogQueryService, CancellationToken.None); + + Assert.That(result.Result, Is.InstanceOf()); + } + + [Test] + public async Task WhenListingAuditLogsForAnEnvironmentThatExists_ThenTheyAreReturned() + { + _auditLogs.Add(new AuditLog { Id = 1, ResourceType = "Environment", ResourceId = "1", Action = "updated", OrganizationId = OrganizationId, EnvironmentId = EnvironmentId, CreatedAt = DateTimeOffset.UtcNow }); + + var result = await _controller.ListAuditLogs(_environment.ApiKey, _auditLogQueryService, CancellationToken.None); + + var ok = result.Result as OkObjectResult; + var body = ok!.Value as List; + Assert.That(body, Has.Count.EqualTo(1)); + } + + // ─── Identities ───────────────────────────────────────────────────────── + + [Test] + public async Task WhenListingIdentitiesForAnEnvironmentThatDoesNotExist_ThenNotFoundIsReturned() + { + var result = await _controller.ListIdentities(MissingApiKey, _adminIdentityService, page: 1, pageSize: 1000, CancellationToken.None); + + Assert.That(result.Result, Is.InstanceOf()); + } + + [Test] + public async Task WhenListingIdentitiesForAnEnvironmentThatExists_ThenPageSizeIsClampedAndResultsMapped() + { + _identities.Add(new Identity { Id = 1, Identifier = "user-1", EnvironmentId = EnvironmentId, CreatedAt = DateTimeOffset.UtcNow }); + + var result = await _controller.ListIdentities(_environment.ApiKey, _adminIdentityService, page: 1, pageSize: 1000, CancellationToken.None); + + var ok = result.Result as OkObjectResult; + var body = ok!.Value as PaginatedResponse; + Assert.That(body!.Count, Is.EqualTo(1)); + } + + [Test] + public async Task WhenCreatingAnIdentityForAnEnvironmentThatDoesNotExist_ThenNotFoundIsReturned() + { + var result = await _controller.CreateIdentity(MissingApiKey, new CreateIdentityRequest("user-1"), _adminIdentityService, CancellationToken.None); + + Assert.That(result.Result, Is.InstanceOf()); + } + + [Test] + public async Task WhenCreatingAnIdentityThatAlreadyExists_ThenConflictIsReturned() + { + _identities.Add(new Identity { Id = 1, Identifier = "user-1", EnvironmentId = EnvironmentId, CreatedAt = DateTimeOffset.UtcNow }); + + var result = await _controller.CreateIdentity(_environment.ApiKey, new CreateIdentityRequest("user-1"), _adminIdentityService, CancellationToken.None); + + Assert.That(result.Result, Is.InstanceOf()); + } + + [Test] + public async Task WhenCreatingANewIdentity_ThenItIsPersisted() + { + var result = await _controller.CreateIdentity(_environment.ApiKey, new CreateIdentityRequest("user-1"), _adminIdentityService, CancellationToken.None); + + var created = result.Result as CreatedAtActionResult; + var body = created!.Value as AdminIdentityResponse; + Assert.That(body!.Identifier, Is.EqualTo("user-1")); + } + + [Test] + public async Task WhenGettingAnIdentityForAnEnvironmentThatDoesNotExist_ThenNotFoundIsReturned() + { + var result = await _controller.GetIdentity(MissingApiKey, 1, _adminIdentityService, CancellationToken.None); + + Assert.That(result.Result, Is.InstanceOf()); + } + + [Test] + public async Task WhenGettingAnIdentityThatDoesNotExist_ThenNotFoundIsReturned() + { + var result = await _controller.GetIdentity(_environment.ApiKey, 999, _adminIdentityService, CancellationToken.None); + + Assert.That(result.Result, Is.InstanceOf()); + } + + [Test] + public async Task WhenGettingAnIdentityThatExists_ThenItIsReturned() + { + _identities.Add(new Identity { Id = 1, Identifier = "user-1", EnvironmentId = EnvironmentId, CreatedAt = DateTimeOffset.UtcNow }); + + var result = await _controller.GetIdentity(_environment.ApiKey, 1, _adminIdentityService, CancellationToken.None); + + var ok = result.Result as OkObjectResult; + var body = ok!.Value as AdminIdentityResponse; + Assert.That(body!.Identifier, Is.EqualTo("user-1")); + } + + [Test] + public async Task WhenDeletingAnIdentityForAnEnvironmentThatDoesNotExist_ThenNotFoundIsReturned() + { + var result = await _controller.DeleteIdentity(MissingApiKey, 1, _adminIdentityService, CancellationToken.None); + + Assert.That(result, Is.InstanceOf()); + } + + [Test] + public async Task WhenDeletingAnIdentityThatDoesNotExist_ThenNotFoundIsReturned() + { + var result = await _controller.DeleteIdentity(_environment.ApiKey, 999, _adminIdentityService, CancellationToken.None); + + Assert.That(result, Is.InstanceOf()); + } + + [Test] + public async Task WhenDeletingAnIdentityThatExists_ThenNoContentIsReturned() + { + _identities.Add(new Identity { Id = 1, Identifier = "user-1", EnvironmentId = EnvironmentId, CreatedAt = DateTimeOffset.UtcNow }); + + var result = await _controller.DeleteIdentity(_environment.ApiKey, 1, _adminIdentityService, CancellationToken.None); + + Assert.That(result, Is.InstanceOf()); + Assert.That(_identities, Is.Empty); + } + + [Test] + public async Task WhenUpsertingATraitForAnEnvironmentThatDoesNotExist_ThenNotFoundIsReturned() + { + var result = await _controller.UpsertIdentityTrait(MissingApiKey, 1, "plan", new UpsertTraitRequest("pro"), _adminIdentityService, CancellationToken.None); + + Assert.That(result.Result, Is.InstanceOf()); + } + + [Test] + public async Task WhenUpsertingATraitForAnIdentityThatDoesNotExist_ThenNotFoundIsReturned() + { + var result = await _controller.UpsertIdentityTrait(_environment.ApiKey, 999, "plan", new UpsertTraitRequest("pro"), _adminIdentityService, CancellationToken.None); + + Assert.That(result.Result, Is.InstanceOf()); + } + + [Test] + public async Task WhenUpsertingATraitForAnIdentityThatExists_ThenTheTraitIsReturned() + { + _identities.Add(new Identity { Id = 1, Identifier = "user-1", EnvironmentId = EnvironmentId, CreatedAt = DateTimeOffset.UtcNow }); + + var result = await _controller.UpsertIdentityTrait(_environment.ApiKey, 1, "plan", new UpsertTraitRequest("pro"), _adminIdentityService, CancellationToken.None); + + var ok = result.Result as OkObjectResult; + var body = ok!.Value as TraitResponse; + Assert.That(body!.Value, Is.EqualTo("pro")); + } + + [Test] + public async Task WhenDeletingATraitForAnEnvironmentThatDoesNotExist_ThenNotFoundIsReturned() + { + var result = await _controller.DeleteIdentityTrait(MissingApiKey, 1, "plan", _adminIdentityService, CancellationToken.None); + + Assert.That(result, Is.InstanceOf()); + } + + [Test] + public async Task WhenDeletingATraitThatDoesNotExist_ThenNotFoundIsReturned() + { + _identities.Add(new Identity { Id = 1, Identifier = "user-1", EnvironmentId = EnvironmentId, CreatedAt = DateTimeOffset.UtcNow }); + + var result = await _controller.DeleteIdentityTrait(_environment.ApiKey, 1, "plan", _adminIdentityService, CancellationToken.None); + + Assert.That(result, Is.InstanceOf()); + } + + [Test] + public async Task WhenDeletingATraitThatExists_ThenNoContentIsReturned() + { + var identity = new Identity { Id = 1, Identifier = "user-1", EnvironmentId = EnvironmentId, CreatedAt = DateTimeOffset.UtcNow }; + _identities.Add(identity); + await _adminIdentityService.UpsertTraitAsync(1, EnvironmentId, "plan", "pro"); + + var result = await _controller.DeleteIdentityTrait(_environment.ApiKey, 1, "plan", _adminIdentityService, CancellationToken.None); + + Assert.That(result, Is.InstanceOf()); + } + + [Test] + public async Task WhenGettingFeatureStatesForAnIdentityInAnEnvironmentThatDoesNotExist_ThenNotFoundIsReturned() + { + var result = await _controller.GetIdentityFeatureStates(MissingApiKey, 1, _adminIdentityService, CancellationToken.None); + + Assert.That(result.Result, Is.InstanceOf()); + } + + [Test] + public async Task WhenGettingFeatureStatesForAnIdentityThatDoesNotExist_ThenNotFoundIsReturned() + { + var result = await _controller.GetIdentityFeatureStates(_environment.ApiKey, 999, _adminIdentityService, CancellationToken.None); + + Assert.That(result.Result, Is.InstanceOf()); + } + + [Test] + public async Task WhenGettingFeatureStatesForAnIdentityThatExists_ThenTheyAreReturned() + { + _identities.Add(new Identity { Id = 1, Identifier = "user-1", EnvironmentId = EnvironmentId, CreatedAt = DateTimeOffset.UtcNow }); + _featureStates.Add(new FeatureState { Id = 1, FeatureId = 1, EnvironmentId = EnvironmentId, IdentityId = 1, Enabled = true, CreatedAt = DateTimeOffset.UtcNow, UpdatedAt = DateTimeOffset.UtcNow }); + + var result = await _controller.GetIdentityFeatureStates(_environment.ApiKey, 1, _adminIdentityService, CancellationToken.None); + + var ok = result.Result as OkObjectResult; + var body = ok!.Value as IReadOnlyList; + Assert.That(body, Has.Count.EqualTo(1)); + } + + [Test] + public async Task WhenSettingAFeatureStateForAnIdentityInAnEnvironmentThatDoesNotExist_ThenNotFoundIsReturned() + { + var result = await _controller.SetIdentityFeatureState(MissingApiKey, 1, 1, new UpdateFeatureStateRequest(true, "on"), _adminIdentityService, CancellationToken.None); + + Assert.That(result.Result, Is.InstanceOf()); + } + + [Test] + public async Task WhenSettingAFeatureStateForAnIdentityThatDoesNotExist_ThenNotFoundIsReturned() + { + var result = await _controller.SetIdentityFeatureState(_environment.ApiKey, 999, 1, new UpdateFeatureStateRequest(true, "on"), _adminIdentityService, CancellationToken.None); + + Assert.That(result.Result, Is.InstanceOf()); + } + + [Test] + public async Task WhenSettingAFeatureStateForAnIdentityThatExists_ThenTheStateIsPersisted() + { + _identities.Add(new Identity { Id = 1, Identifier = "user-1", EnvironmentId = EnvironmentId, CreatedAt = DateTimeOffset.UtcNow }); + + var result = await _controller.SetIdentityFeatureState(_environment.ApiKey, 1, 1, new UpdateFeatureStateRequest(true, "on"), _adminIdentityService, CancellationToken.None); + + var ok = result.Result as OkObjectResult; + var body = ok!.Value as FeatureStateResponse; + Assert.That(body!.Enabled, Is.True); + Assert.That(body.Value, Is.EqualTo("on")); + } + + [Test] + public async Task WhenDeletingAFeatureStateForAnIdentityInAnEnvironmentThatDoesNotExist_ThenNotFoundIsReturned() + { + var result = await _controller.DeleteIdentityFeatureState(MissingApiKey, 1, 1, _adminIdentityService, CancellationToken.None); + + Assert.That(result, Is.InstanceOf()); + } + + [Test] + public async Task WhenDeletingAFeatureStateForAnIdentityThatDoesNotExist_ThenNotFoundIsReturned() + { + var result = await _controller.DeleteIdentityFeatureState(_environment.ApiKey, 999, 1, _adminIdentityService, CancellationToken.None); + + Assert.That(result, Is.InstanceOf()); + } + + [Test] + public async Task WhenDeletingAFeatureStateForAnIdentityThatExists_ThenNoContentIsReturned() + { + _identities.Add(new Identity { Id = 1, Identifier = "user-1", EnvironmentId = EnvironmentId, CreatedAt = DateTimeOffset.UtcNow }); + _featureStates.Add(new FeatureState { Id = 1, FeatureId = 1, EnvironmentId = EnvironmentId, IdentityId = 1, Enabled = true, CreatedAt = DateTimeOffset.UtcNow, UpdatedAt = DateTimeOffset.UtcNow }); + + var result = await _controller.DeleteIdentityFeatureState(_environment.ApiKey, 1, 1, _adminIdentityService, CancellationToken.None); + + Assert.That(result, Is.InstanceOf()); + Assert.That(_featureStates, Is.Empty); + } + + // ─── Environment-level feature states ────────────────────────────────── + + [Test] + public async Task WhenListingFeatureStatesForAnEnvironmentThatDoesNotExist_ThenNotFoundIsReturned() + { + var result = await _controller.ListFeatureStates(MissingApiKey, _featureStateService, CancellationToken.None); + + Assert.That(result.Result, Is.InstanceOf()); + } + + [Test] + public async Task WhenListingFeatureStatesForAnEnvironmentThatExists_ThenTheyAreReturned() + { + _featureStates.Add(new FeatureState { Id = 1, FeatureId = 1, EnvironmentId = EnvironmentId, Enabled = true, CreatedAt = DateTimeOffset.UtcNow, UpdatedAt = DateTimeOffset.UtcNow }); + + var result = await _controller.ListFeatureStates(_environment.ApiKey, _featureStateService, CancellationToken.None); + + var ok = result.Result as OkObjectResult; + var body = ok!.Value as IReadOnlyList; + Assert.That(body, Has.Count.EqualTo(1)); + } + + [Test] + public async Task WhenGettingAFeatureStateForAnEnvironmentThatDoesNotExist_ThenNotFoundIsReturned() + { + var result = await _controller.GetFeatureState(MissingApiKey, 1, _featureStateService, CancellationToken.None); + + Assert.That(result.Result, Is.InstanceOf()); + } + + [Test] + public async Task WhenGettingAFeatureStateThatDoesNotExist_ThenNotFoundIsReturned() + { + var result = await _controller.GetFeatureState(_environment.ApiKey, 999, _featureStateService, CancellationToken.None); + + Assert.That(result.Result, Is.InstanceOf()); + } + + [Test] + public async Task WhenGettingAFeatureStateThatExists_ThenItIsReturned() + { + _featureStates.Add(new FeatureState { Id = 1, FeatureId = 1, EnvironmentId = EnvironmentId, Enabled = true, CreatedAt = DateTimeOffset.UtcNow, UpdatedAt = DateTimeOffset.UtcNow }); + + var result = await _controller.GetFeatureState(_environment.ApiKey, 1, _featureStateService, CancellationToken.None); + + var ok = result.Result as OkObjectResult; + var body = ok!.Value as FeatureStateResponse; + Assert.That(body!.Id, Is.EqualTo(1)); + } + + [Test] + public async Task WhenUpdatingAFeatureStateForAnEnvironmentThatDoesNotExist_ThenNotFoundIsReturned() + { + var result = await _controller.UpdateFeatureState(MissingApiKey, 1, new UpdateFeatureStateRequest(true, "on"), _featureStateService, CancellationToken.None); + + Assert.That(result.Result, Is.InstanceOf()); + } + + [Test] + public async Task WhenUpdatingAFeatureStateThatDoesNotExist_ThenNotFoundIsReturned() + { + var result = await _controller.UpdateFeatureState(_environment.ApiKey, 999, new UpdateFeatureStateRequest(true, "on"), _featureStateService, CancellationToken.None); + + Assert.That(result.Result, Is.InstanceOf()); + } + + [Test] + public async Task WhenUpdatingAFeatureStateThatExists_ThenItIsUpdated() + { + _features.Add(new Feature { Id = 1, Name = "flag", ProjectId = ProjectId, CreatedAt = DateTimeOffset.UtcNow }); + _featureStates.Add(new FeatureState { Id = 1, FeatureId = 1, EnvironmentId = EnvironmentId, Enabled = false, CreatedAt = DateTimeOffset.UtcNow, UpdatedAt = DateTimeOffset.UtcNow }); + + var result = await _controller.UpdateFeatureState(_environment.ApiKey, 1, new UpdateFeatureStateRequest(true, "on"), _featureStateService, CancellationToken.None); + + var ok = result.Result as OkObjectResult; + var body = ok!.Value as FeatureStateResponse; + Assert.That(body!.Enabled, Is.True); + } + + [Test] + public async Task WhenPatchingAFeatureStateForAnEnvironmentThatDoesNotExist_ThenNotFoundIsReturned() + { + var result = await _controller.PatchFeatureState(MissingApiKey, 1, new PatchFeatureStateRequest(true, null), _featureStateService, CancellationToken.None); + + Assert.That(result.Result, Is.InstanceOf()); + } + + [Test] + public async Task WhenPatchingAFeatureStateThatDoesNotExist_ThenNotFoundIsReturned() + { + var result = await _controller.PatchFeatureState(_environment.ApiKey, 999, new PatchFeatureStateRequest(true, null), _featureStateService, CancellationToken.None); + + Assert.That(result.Result, Is.InstanceOf()); + } + + [Test] + public async Task WhenPatchingAFeatureStateThatExists_ThenOnlyProvidedFieldsAreChanged() + { + _features.Add(new Feature { Id = 1, Name = "flag", ProjectId = ProjectId, CreatedAt = DateTimeOffset.UtcNow }); + _featureStates.Add(new FeatureState { Id = 1, FeatureId = 1, EnvironmentId = EnvironmentId, Enabled = false, Value = "original", CreatedAt = DateTimeOffset.UtcNow, UpdatedAt = DateTimeOffset.UtcNow }); + + var result = await _controller.PatchFeatureState(_environment.ApiKey, 1, new PatchFeatureStateRequest(true, null), _featureStateService, CancellationToken.None); + + var ok = result.Result as OkObjectResult; + var body = ok!.Value as FeatureStateResponse; + Assert.That(body!.Enabled, Is.True); + Assert.That(body.Value, Is.EqualTo("original")); + } + + // ─── Feature segments ─────────────────────────────────────────────────── + + [Test] + public async Task WhenListingFeatureSegmentsForAnEnvironmentThatDoesNotExist_ThenNotFoundIsReturned() + { + var result = await _controller.ListFeatureSegments(MissingApiKey, 1, _featureSegmentService, CancellationToken.None); + + Assert.That(result.Result, Is.InstanceOf()); + } + + [Test] + public async Task WhenListingFeatureSegmentsForAnEnvironmentThatExists_ThenTheyAreReturned() + { + _segments.Add(new Segment { Id = 1, Name = "Beta", ProjectId = ProjectId, CreatedAt = DateTimeOffset.UtcNow }); + _featureSegments.Add(new FeatureSegment { Id = 1, FeatureId = 1, SegmentId = 1, EnvironmentId = EnvironmentId, Priority = 1 }); + + var result = await _controller.ListFeatureSegments(_environment.ApiKey, 1, _featureSegmentService, CancellationToken.None); + + var ok = result.Result as OkObjectResult; + var body = ok!.Value as IReadOnlyList; + Assert.That(body, Has.Count.EqualTo(1)); + } + + [Test] + public async Task WhenCreatingAFeatureSegmentForAnEnvironmentThatDoesNotExist_ThenNotFoundIsReturned() + { + var result = await _controller.CreateFeatureSegment(MissingApiKey, 1, new CreateFeatureSegmentRequest(1, 1, true, null), _featureSegmentService, CancellationToken.None); + + Assert.That(result.Result, Is.InstanceOf()); + } + + [Test] + public async Task WhenCreatingAFeatureSegmentForASegmentThatDoesNotExist_ThenNotFoundIsReturned() + { + var result = await _controller.CreateFeatureSegment(_environment.ApiKey, 1, new CreateFeatureSegmentRequest(999, 1, true, null), _featureSegmentService, CancellationToken.None); + + var notFound = result.Result as NotFoundObjectResult; + Assert.That(notFound, Is.Not.Null); + } + + [Test] + public async Task WhenCreatingAFeatureSegmentForASegmentThatExists_ThenItIsPersisted() + { + _segments.Add(new Segment { Id = 1, Name = "Beta", ProjectId = ProjectId, CreatedAt = DateTimeOffset.UtcNow }); + + var result = await _controller.CreateFeatureSegment(_environment.ApiKey, 1, new CreateFeatureSegmentRequest(1, 1, true, "on"), _featureSegmentService, CancellationToken.None); + + var ok = result.Result as OkObjectResult; + var body = ok!.Value as FeatureSegmentResponse; + Assert.That(body!.SegmentName, Is.EqualTo("Beta")); + } + + [Test] + public async Task WhenUpdatingAFeatureSegmentForAnEnvironmentThatDoesNotExist_ThenNotFoundIsReturned() + { + var result = await _controller.UpdateFeatureSegment(MissingApiKey, 1, 1, new UpdateFeatureSegmentRequest(1, true, null), _featureSegmentService, CancellationToken.None); + + Assert.That(result.Result, Is.InstanceOf()); + } + + [Test] + public async Task WhenUpdatingAFeatureSegmentThatDoesNotExist_ThenNotFoundIsReturned() + { + var result = await _controller.UpdateFeatureSegment(_environment.ApiKey, 1, 999, new UpdateFeatureSegmentRequest(1, true, null), _featureSegmentService, CancellationToken.None); + + Assert.That(result.Result, Is.InstanceOf()); + } + + [Test] + public async Task WhenUpdatingAFeatureSegmentThatExists_ThenItIsUpdated() + { + _segments.Add(new Segment { Id = 1, Name = "Beta", ProjectId = ProjectId, CreatedAt = DateTimeOffset.UtcNow }); + _featureSegments.Add(new FeatureSegment { Id = 1, FeatureId = 1, SegmentId = 1, EnvironmentId = EnvironmentId, Priority = 1 }); + + var result = await _controller.UpdateFeatureSegment(_environment.ApiKey, 1, 1, new UpdateFeatureSegmentRequest(5, true, "on"), _featureSegmentService, CancellationToken.None); + + var ok = result.Result as OkObjectResult; + var body = ok!.Value as FeatureSegmentResponse; + Assert.That(body!.Priority, Is.EqualTo(5)); + } + + [Test] + public async Task WhenDeletingAFeatureSegmentForAnEnvironmentThatDoesNotExist_ThenNotFoundIsReturned() + { + var result = await _controller.DeleteFeatureSegment(MissingApiKey, 1, 1, _featureSegmentService, CancellationToken.None); + + Assert.That(result, Is.InstanceOf()); + } + + [Test] + public async Task WhenDeletingAFeatureSegmentThatDoesNotExist_ThenNotFoundIsReturned() + { + var result = await _controller.DeleteFeatureSegment(_environment.ApiKey, 1, 999, _featureSegmentService, CancellationToken.None); + + Assert.That(result, Is.InstanceOf()); + } + + [Test] + public async Task WhenDeletingAFeatureSegmentThatExists_ThenNoContentIsReturned() + { + _featureSegments.Add(new FeatureSegment { Id = 1, FeatureId = 1, SegmentId = 1, EnvironmentId = EnvironmentId, Priority = 1 }); + + var result = await _controller.DeleteFeatureSegment(_environment.ApiKey, 1, 1, _featureSegmentService, CancellationToken.None); + + Assert.That(result, Is.InstanceOf()); + Assert.That(_featureSegments, Is.Empty); + } + + // ─── Identity segments ────────────────────────────────────────────────── + + [Test] + public async Task WhenGettingIdentitySegmentsForAnEnvironmentThatDoesNotExist_ThenNotFoundIsReturned() + { + var result = await _controller.GetIdentitySegments(MissingApiKey, 1, _adminIdentityService, _segmentService, _segmentEvaluator, CancellationToken.None); + + Assert.That(result.Result, Is.InstanceOf()); + } + + [Test] + public async Task WhenGettingIdentitySegmentsForAnIdentityThatDoesNotExist_ThenNotFoundIsReturned() + { + var result = await _controller.GetIdentitySegments(_environment.ApiKey, 999, _adminIdentityService, _segmentService, _segmentEvaluator, CancellationToken.None); + + Assert.That(result.Result, Is.InstanceOf()); + } + + [Test] + public async Task WhenGettingIdentitySegments_ThenOnlyMatchingSegmentsAreReturned() + { + var identity = new Identity { Id = 1, Identifier = "user-1", EnvironmentId = EnvironmentId, CreatedAt = DateTimeOffset.UtcNow }; + _identities.Add(identity); + await _adminIdentityService.UpsertTraitAsync(1, EnvironmentId, "plan", "pro"); + + var matchingSegment = new Segment { Id = 1, Name = "Pro Users", ProjectId = ProjectId, CreatedAt = DateTimeOffset.UtcNow }; + var matchingRule = new SegmentRule { Id = 1, SegmentId = 1, Type = SegmentRuleType.All }; + matchingRule.Conditions.Add(new SegmentCondition { Id = 1, RuleId = 1, Property = "plan", Operator = SegmentConditionOperator.Equal, Value = "pro" }); + matchingSegment.Rules.Add(matchingRule); + _segments.Add(matchingSegment); + + var nonMatchingSegment = new Segment { Id = 2, Name = "Free Users", ProjectId = ProjectId, CreatedAt = DateTimeOffset.UtcNow }; + var nonMatchingRule = new SegmentRule { Id = 2, SegmentId = 2, Type = SegmentRuleType.All }; + nonMatchingRule.Conditions.Add(new SegmentCondition { Id = 2, RuleId = 2, Property = "plan", Operator = SegmentConditionOperator.Equal, Value = "free" }); + nonMatchingSegment.Rules.Add(nonMatchingRule); + _segments.Add(nonMatchingSegment); + + var result = await _controller.GetIdentitySegments(_environment.ApiKey, 1, _adminIdentityService, _segmentService, _segmentEvaluator, CancellationToken.None); + + var ok = result.Result as OkObjectResult; + var body = ok!.Value as IReadOnlyList; + Assert.That(body, Has.Count.EqualTo(1)); + Assert.That(body![0].Name, Is.EqualTo("Pro Users")); + } +}