diff --git a/tests/api/MicCheck.Api.Tests.Unit/Segments/CreateSegmentRequestValidatorTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Segments/CreateSegmentRequestValidatorTests.cs new file mode 100644 index 0000000..13f9977 --- /dev/null +++ b/tests/api/MicCheck.Api.Tests.Unit/Segments/CreateSegmentRequestValidatorTests.cs @@ -0,0 +1,120 @@ +using MicCheck.Api.Segments; +using NUnit.Framework; + +namespace MicCheck.Api.Tests.Unit.Segments; + +[TestFixture] +public class CreateSegmentRequestValidatorTests +{ + private CreateSegmentRequestValidator _validator = null!; + + [SetUp] + public void SetUp() => _validator = new CreateSegmentRequestValidator(); + + private static CreateSegmentRequest ValidRequest() => new( + "Premium Users", + [new CreateSegmentRuleRequest("All", [new CreateSegmentConditionRequest("plan", "Equal", "premium")])]); + + [Test] + public void WhenTheRequestIsValid_ThenValidationSucceeds() + { + var result = _validator.Validate(ValidRequest()); + + Assert.That(result.IsValid, Is.True); + } + + [Test] + public void WhenTheNameIsEmpty_ThenValidationFails() + { + var request = ValidRequest() with { Name = "" }; + + var result = _validator.Validate(request); + + Assert.That(result.IsValid, Is.False); + Assert.That(result.Errors.Any(e => e.PropertyName == "Name"), Is.True); + } + + [Test] + public void WhenTheNameExceedsMaximumLength_ThenValidationFails() + { + var request = ValidRequest() with { Name = new string('a', 201) }; + + var result = _validator.Validate(request); + + Assert.That(result.IsValid, Is.False); + } + + [Test] + public void WhenRulesIsNull_ThenValidationFails() + { + var request = ValidRequest() with { Rules = null! }; + + var result = _validator.Validate(request); + + Assert.That(result.IsValid, Is.False); + } + + [Test] + public void WhenARuleTypeIsInvalid_ThenValidationFails() + { + var request = new CreateSegmentRequest( + "Test", + [new CreateSegmentRuleRequest("NotAType", [new CreateSegmentConditionRequest("plan", "Equal", "premium")])]); + + var result = _validator.Validate(request); + + Assert.That(result.IsValid, Is.False); + } + + [TestCase("All")] + [TestCase("Any")] + [TestCase("None")] + [TestCase("all")] + public void WhenARuleTypeIsAValidCaseInsensitiveName_ThenValidationSucceeds(string type) + { + var request = new CreateSegmentRequest( + "Test", + [new CreateSegmentRuleRequest(type, [new CreateSegmentConditionRequest("plan", "Equal", "premium")])]); + + var result = _validator.Validate(request); + + Assert.That(result.IsValid, Is.True); + } + + [Test] + public void WhenAConditionPropertyIsEmpty_ThenValidationFails() + { + var request = new CreateSegmentRequest( + "Test", + [new CreateSegmentRuleRequest("All", [new CreateSegmentConditionRequest("", "Equal", "premium")])]); + + var result = _validator.Validate(request); + + Assert.That(result.IsValid, Is.False); + } + + [Test] + public void WhenAConditionOperatorIsInvalid_ThenValidationFails() + { + var request = new CreateSegmentRequest( + "Test", + [new CreateSegmentRuleRequest("All", [new CreateSegmentConditionRequest("plan", "NotAnOperator", "premium")])]); + + var result = _validator.Validate(request); + + Assert.That(result.IsValid, Is.False); + } + + [Test] + public void WhenANestedChildRuleHasAnInvalidOperator_ThenValidationStillSucceedsBecauseChildRulesAreNotValidatedRecursively() + { + var childRule = new CreateSegmentRuleRequest("All", [new CreateSegmentConditionRequest("plan", "NotAnOperator", "premium")]); + var request = new CreateSegmentRequest( + "Test", + [new CreateSegmentRuleRequest("All", [new CreateSegmentConditionRequest("plan", "Equal", "premium")], [childRule])]); + + var result = _validator.Validate(request); + + Assert.That(result.IsValid, Is.True); + } +} diff --git a/tests/api/MicCheck.Api.Tests.Unit/Segments/SegmentEvaluatorTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Segments/SegmentEvaluatorTests.cs index 80c6730..d196618 100755 --- a/tests/api/MicCheck.Api.Tests.Unit/Segments/SegmentEvaluatorTests.cs +++ b/tests/api/MicCheck.Api.Tests.Unit/Segments/SegmentEvaluatorTests.cs @@ -274,6 +274,51 @@ public class SegmentEvaluatorTests Assert.That(_evaluator.Evaluate(segment, [], "another-user"), Is.False); } + [Test] + public void WhenModuloConditionValueIsMalformed_ThenSegmentDoesNotMatch() + { + var segment = BuildSegment(SegmentRuleType.All, + Condition("userId", SegmentConditionOperator.ModuloValueDivisorRemainder, "not-a-modulo-spec")); + var traits = new[] { Trait("userId", "9") }; + + Assert.That(_evaluator.Evaluate(segment, traits), Is.False); + } + + [Test] + public void WhenModuloDivisorIsZero_ThenSegmentDoesNotMatch() + { + var segment = BuildSegment(SegmentRuleType.All, + Condition("userId", SegmentConditionOperator.ModuloValueDivisorRemainder, "0|0")); + var traits = new[] { Trait("userId", "9") }; + + Assert.That(_evaluator.Evaluate(segment, traits), Is.False); + } + + [Test] + public void WhenPercentageSplitValueIsNotANumber_ThenSegmentDoesNotMatch() + { + var segment = BuildSegment(SegmentRuleType.All, + Condition("", SegmentConditionOperator.PercentageSplit, "not-a-number")); + + Assert.That(_evaluator.Evaluate(segment, [], "any-user"), Is.False); + } + + [Test] + public void WhenARuleHasAChildRuleThatFails_ThenTheParentRuleDoesNotMatchEvenIfItsOwnConditionsPass() + { + var segment = new Segment { Name = "Test", ProjectId = 1, CreatedAt = DateTimeOffset.UtcNow }; + var parent = new SegmentRule { Type = SegmentRuleType.All }; + parent.Conditions.Add(Condition("plan", SegmentConditionOperator.Equal, "premium")); + var child = new SegmentRule { Type = SegmentRuleType.All }; + child.Conditions.Add(Condition("country", SegmentConditionOperator.Equal, "US")); + parent.ChildRules.Add(child); + segment.Rules.Add(parent); + + var traits = new[] { Trait("plan", "premium"), Trait("country", "UK") }; + + Assert.That(_evaluator.Evaluate(segment, traits), Is.False); + } + [Test] public void WhenRuleTypeIsAny_ThenAtLeastOneConditionMustMatch() { diff --git a/tests/api/MicCheck.Api.Tests.Unit/Segments/SegmentResponseTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Segments/SegmentResponseTests.cs new file mode 100644 index 0000000..f03bae5 --- /dev/null +++ b/tests/api/MicCheck.Api.Tests.Unit/Segments/SegmentResponseTests.cs @@ -0,0 +1,84 @@ +using MicCheck.Api.Segments; +using NUnit.Framework; + +namespace MicCheck.Api.Tests.Unit.Segments; + +[TestFixture] +public class SegmentResponseTests +{ + [Test] + public void WhenMappingASegmentWithNoRules_ThenTheResponseHasAnEmptyRulesList() + { + var segment = new Segment { Id = 1, Name = "Premium Users", ProjectId = 5, CreatedAt = DateTimeOffset.UtcNow }; + + var response = SegmentResponse.From(segment); + + Assert.That(response.Id, Is.EqualTo(segment.Id)); + Assert.That(response.Name, Is.EqualTo("Premium Users")); + Assert.That(response.ProjectId, Is.EqualTo(5)); + Assert.That(response.Rules, Is.Empty); + } + + [Test] + public void WhenMappingASegmentWithTopLevelRules_ThenOnlyRulesWithoutAParentAreAtTheTopLevel() + { + var segment = new Segment { Id = 1, Name = "Test", ProjectId = 1, CreatedAt = DateTimeOffset.UtcNow }; + var topLevelRule = new SegmentRule { Id = 1, SegmentId = 1, Type = SegmentRuleType.All }; + var childRule = new SegmentRule { Id = 2, SegmentId = 1, ParentRuleId = 1, Type = SegmentRuleType.Any }; + segment.Rules.Add(topLevelRule); + segment.Rules.Add(childRule); + + var response = SegmentResponse.From(segment); + + Assert.That(response.Rules, Has.Count.EqualTo(1)); + Assert.That(response.Rules[0].Id, Is.EqualTo(topLevelRule.Id)); + } + + [Test] + public void WhenMappingARuleWithChildRules_ThenChildRulesAreNestedUnderTheParent() + { + var parent = new SegmentRule { Id = 1, SegmentId = 1, Type = SegmentRuleType.All }; + var child = new SegmentRule { Id = 2, SegmentId = 1, ParentRuleId = 1, Type = SegmentRuleType.Any }; + var allRules = new List { parent, child }; + + var response = SegmentRuleResponse.From(parent, allRules); + + Assert.That(response.Type, Is.EqualTo(nameof(SegmentRuleType.All))); + Assert.That(response.ChildRules, Has.Count.EqualTo(1)); + Assert.That(response.ChildRules[0].Id, Is.EqualTo(child.Id)); + } + + [Test] + public void WhenMappingARuleWithConditions_ThenEachConditionIsMapped() + { + var rule = new SegmentRule { Id = 1, SegmentId = 1, Type = SegmentRuleType.All }; + rule.Conditions.Add(new SegmentCondition { Id = 1, RuleId = 1, Property = "plan", Operator = SegmentConditionOperator.Equal, Value = "premium" }); + + var response = SegmentRuleResponse.From(rule, [rule]); + + Assert.That(response.Conditions, Has.Count.EqualTo(1)); + Assert.That(response.Conditions[0].Property, Is.EqualTo("plan")); + } + + [Test] + public void WhenMappingACondition_ThenIdPropertyOperatorAndValueAreCopied() + { + var condition = new SegmentCondition { Id = 7, RuleId = 1, Property = "plan", Operator = SegmentConditionOperator.Contains, Value = "premium" }; + + var response = SegmentConditionResponse.From(condition); + + Assert.That(response.Id, Is.EqualTo(7)); + Assert.That(response.Property, Is.EqualTo("plan")); + Assert.That(response.Operator, Is.EqualTo(nameof(SegmentConditionOperator.Contains))); + Assert.That(response.Value, Is.EqualTo("premium")); + } + + [Test] + public void WhenCreatingASegmentSummaryResponse_ThenIdAndNameAreSet() + { + var response = new SegmentSummaryResponse(1, "Premium Users"); + + Assert.That(response.Id, Is.EqualTo(1)); + Assert.That(response.Name, Is.EqualTo("Premium Users")); + } +} diff --git a/tests/api/MicCheck.Api.Tests.Unit/Segments/SegmentServiceTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Segments/SegmentServiceTests.cs index 1cbb161..51bb9f1 100755 --- a/tests/api/MicCheck.Api.Tests.Unit/Segments/SegmentServiceTests.cs +++ b/tests/api/MicCheck.Api.Tests.Unit/Segments/SegmentServiceTests.cs @@ -136,4 +136,51 @@ public class SegmentServiceTests Assert.That(segments, Has.Count.EqualTo(2)); } + + [Test] + public async Task WhenFindingASegmentByIdThatExists_ThenTheSegmentIsReturned() + { + var segment = await _service.CreateAsync(ProjectId, "Segment", [SingleConditionRule()]); + + var found = await _service.FindByIdAsync(segment.Id); + + Assert.That(found, Is.Not.Null); + Assert.That(found!.Id, Is.EqualTo(segment.Id)); + } + + [Test] + public async Task WhenFindingASegmentByIdThatDoesNotExist_ThenNullIsReturned() + { + var found = await _service.FindByIdAsync(999); + + Assert.That(found, Is.Null); + } + + [Test] + public void WhenUpdatingASegmentThatDoesNotExist_ThenKeyNotFoundExceptionIsThrown() + { + Assert.ThrowsAsync(() => + _service.UpdateAsync(999, "Renamed", [SingleConditionRule()])); + } + + [Test] + public void WhenDeletingASegmentThatDoesNotExist_ThenNoExceptionIsThrown() + { + Assert.DoesNotThrowAsync(() => _service.DeleteAsync(999)); + } + + [Test] + public async Task WhenCreatingASegmentWithNestedChildRules_ThenChildRulesArePersistedWithTheParentAsTheirParent() + { + var childRule = new SegmentRuleDefinition(SegmentRuleType.Any, [new SegmentConditionDefinition("country", SegmentConditionOperator.Equal, "US")]); + var parentRule = new SegmentRuleDefinition(SegmentRuleType.All, [new SegmentConditionDefinition("plan", SegmentConditionOperator.Equal, "premium")], [childRule]); + + var segment = await _service.CreateAsync(ProjectId, "Nested", [parentRule]); + + var persistedRules = _segmentRules.Where(r => r.SegmentId == segment.Id).ToList(); + var parent = persistedRules.Single(r => r.ParentRuleId == null); + var child = persistedRules.Single(r => r.ParentRuleId == parent.Id); + + Assert.That(child.Conditions.Single().Property, Is.EqualTo("country")); + } } diff --git a/tests/api/MicCheck.Api.Tests.Unit/Segments/SegmentsControllerTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Segments/SegmentsControllerTests.cs new file mode 100644 index 0000000..b85c22e --- /dev/null +++ b/tests/api/MicCheck.Api.Tests.Unit/Segments/SegmentsControllerTests.cs @@ -0,0 +1,197 @@ +using MicCheck.Api.Audit; +using MicCheck.Api.Common; +using MicCheck.Api.Data; +using MicCheck.Api.Organizations; +using MicCheck.Api.Projects; +using MicCheck.Api.Segments; +using MicCheck.Api.Tests.Unit.TestSupport; +using Microsoft.AspNetCore.Mvc; +using Moq; +using NUnit.Framework; + +namespace MicCheck.Api.Tests.Unit.Segments; + +[TestFixture] +public class SegmentsControllerTests +{ + private Mock _db = null!; + private List _segments = null!; + private List _segmentRules = null!; + private List _segmentConditions = null!; + private SegmentsController _controller = null!; + private const int OrganizationId = 1; + private const int ProjectId = 1; + + [SetUp] + public void SetUp() + { + _db = new Mock(); + + _db.SetupDbSet(c => c.Organizations, [ + new Organization { Id = OrganizationId, Name = "Test Org", CreatedAt = DateTimeOffset.UtcNow } + ]); + _db.SetupDbSet(c => c.Projects, [ + new Project { Id = ProjectId, Name = "Test Project", OrganizationId = OrganizationId, CreatedAt = DateTimeOffset.UtcNow } + ]); + _segments = []; + _db.SetupDbSetWithGeneratedIds(c => c.Segments, _segments); + _segmentRules = []; + _db.SetupDbSetWithGeneratedIds(c => c.SegmentRules, _segmentRules); + _segmentConditions = []; + _db.SetupDbSetWithGeneratedIds(c => c.SegmentConditions, _segmentConditions); + + var webhookQueue = new MicCheck.Api.Webhooks.WebhookQueue(); + var auditService = new Mock(_db.Object, null!, webhookQueue); + auditService.Setup(a => a.LogAsync( + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + var service = new SegmentService(_db.Object, auditService.Object); + _controller = new SegmentsController(service); + } + + private static CreateSegmentRequest ValidRequest(string name = "Premium Users") => new( + name, + [new CreateSegmentRuleRequest("All", [new CreateSegmentConditionRequest("plan", "Equal", "premium")])]); + + [Test] + public async Task WhenListingSegmentsForAProject_ThenAllSegmentsAreReturnedInAPage() + { + await _controller.Create(ProjectId, ValidRequest("Segment A"), CancellationToken.None); + await _controller.Create(ProjectId, ValidRequest("Segment B"), CancellationToken.None); + + var result = await _controller.List(ProjectId); + + var ok = result.Result as OkObjectResult; + var page = (PaginatedResponse)ok!.Value!; + Assert.That(page.Count, Is.EqualTo(2)); + Assert.That(page.Results, Has.Count.EqualTo(2)); + } + + [Test] + public async Task WhenListingSegmentsWithAPageSizeAboveTheMaximum_ThenThePageSizeIsClampedTo100() + { + for (var i = 0; i < 3; i++) + await _controller.Create(ProjectId, ValidRequest($"Segment {i}"), CancellationToken.None); + + var result = await _controller.List(ProjectId, page: 1, pageSize: 1000); + + var ok = result.Result as OkObjectResult; + var page = (PaginatedResponse)ok!.Value!; + Assert.That(page.Results, Has.Count.EqualTo(3)); + } + + [Test] + public async Task WhenCreatingASegmentWithValidData_ThenACreatedResultWithTheSegmentIsReturned() + { + var result = await _controller.Create(ProjectId, ValidRequest(), CancellationToken.None); + + var created = result.Result as CreatedAtActionResult; + Assert.That(created, Is.Not.Null); + Assert.That(((SegmentResponse)created!.Value!).Name, Is.EqualTo("Premium Users")); + } + + [Test] + public async Task WhenCreatingASegmentThatExceedsTheProjectLimit_ThenBadRequestIsReturned() + { + for (var i = 0; i < 100; i++) + { + _segments.Add(new Segment { Id = i + 1, Name = $"segment_{i}", ProjectId = ProjectId, CreatedAt = DateTimeOffset.UtcNow }); + } + + var result = await _controller.Create(ProjectId, ValidRequest("overflow"), CancellationToken.None); + + Assert.That(result.Result, Is.InstanceOf()); + } + + [Test] + public async Task WhenGettingASegmentByIdInTheCorrectProject_ThenTheSegmentIsReturned() + { + var created = await _controller.Create(ProjectId, ValidRequest(), CancellationToken.None); + var segmentId = ((SegmentResponse)((CreatedAtActionResult)created.Result!).Value!).Id; + + var result = await _controller.GetById(ProjectId, segmentId, CancellationToken.None); + + var ok = result.Result as OkObjectResult; + Assert.That(ok, Is.Not.Null); + Assert.That(((SegmentResponse)ok!.Value!).Id, Is.EqualTo(segmentId)); + } + + [Test] + public async Task WhenGettingASegmentThatDoesNotExist_ThenNotFoundIsReturned() + { + var result = await _controller.GetById(ProjectId, 999, CancellationToken.None); + + Assert.That(result.Result, Is.InstanceOf()); + } + + [Test] + public async Task WhenGettingASegmentThatBelongsToADifferentProject_ThenNotFoundIsReturned() + { + var created = await _controller.Create(ProjectId, ValidRequest(), CancellationToken.None); + var segmentId = ((SegmentResponse)((CreatedAtActionResult)created.Result!).Value!).Id; + + var result = await _controller.GetById(999, segmentId, CancellationToken.None); + + Assert.That(result.Result, Is.InstanceOf()); + } + + [Test] + public async Task WhenUpdatingASegmentThatDoesNotExist_ThenNotFoundIsReturned() + { + var result = await _controller.Update(ProjectId, 999, ValidRequest(), CancellationToken.None); + + Assert.That(result.Result, Is.InstanceOf()); + } + + [Test] + public async Task WhenUpdatingASegmentInTheCorrectProject_ThenTheUpdatedSegmentIsReturned() + { + var created = await _controller.Create(ProjectId, ValidRequest(), CancellationToken.None); + var segmentId = ((SegmentResponse)((CreatedAtActionResult)created.Result!).Value!).Id; + + var result = await _controller.Update(ProjectId, segmentId, ValidRequest("Renamed"), CancellationToken.None); + + var ok = result.Result as OkObjectResult; + Assert.That(ok, Is.Not.Null); + Assert.That(((SegmentResponse)ok!.Value!).Name, Is.EqualTo("Renamed")); + } + + [Test] + public async Task WhenUpdatingASegmentWithTooManyConditions_ThenBadRequestIsReturned() + { + var created = await _controller.Create(ProjectId, ValidRequest(), CancellationToken.None); + var segmentId = ((SegmentResponse)((CreatedAtActionResult)created.Result!).Value!).Id; + + var conditions = Enumerable.Range(0, 101) + .Select(i => new CreateSegmentConditionRequest($"prop_{i}", "Equal", "val")) + .ToList(); + var overflowRequest = new CreateSegmentRequest("Too Many", [new CreateSegmentRuleRequest("All", conditions)]); + + var result = await _controller.Update(ProjectId, segmentId, overflowRequest, CancellationToken.None); + + Assert.That(result.Result, Is.InstanceOf()); + } + + [Test] + public async Task WhenDeletingASegmentThatDoesNotExist_ThenNotFoundIsReturned() + { + var result = await _controller.Delete(ProjectId, 999, CancellationToken.None); + + Assert.That(result, Is.InstanceOf()); + } + + [Test] + public async Task WhenDeletingASegmentInTheCorrectProject_ThenNoContentIsReturnedAndTheSegmentIsRemoved() + { + var created = await _controller.Create(ProjectId, ValidRequest(), CancellationToken.None); + var segmentId = ((SegmentResponse)((CreatedAtActionResult)created.Result!).Value!).Id; + + var result = await _controller.Delete(ProjectId, segmentId, CancellationToken.None); + + Assert.That(result, Is.InstanceOf()); + Assert.That(_segments.Any(s => s.Id == segmentId), Is.False); + } +}