diff --git a/src/api/MicCheck.Api/Features/FeatureUsageFlushBackgroundService.cs b/src/api/MicCheck.Api/Features/FeatureUsageFlushBackgroundService.cs index 6562d40..f69a807 100644 --- a/src/api/MicCheck.Api/Features/FeatureUsageFlushBackgroundService.cs +++ b/src/api/MicCheck.Api/Features/FeatureUsageFlushBackgroundService.cs @@ -1,8 +1,10 @@ +using System.Diagnostics.CodeAnalysis; using MicCheck.Api.Data; using Microsoft.EntityFrameworkCore; namespace MicCheck.Api.Features; +[ExcludeFromCodeCoverage(Justification = "Timer-driven BackgroundService that issues raw SQL through a DI-scoped concrete MicCheckDbContext; exercising it cleanly requires a live DB, which CLAUDE.md disallows (no WebApplicationFactory/InMemory). DrainAccumulated's bucketing logic is covered by FeatureUsageMetricsTests.")] public class FeatureUsageFlushBackgroundService( FeatureUsageMetrics metrics, IServiceScopeFactory scopeFactory, diff --git a/tests/api/MicCheck.Api.Tests.Unit/Features/CreateFeatureRequestValidatorTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Features/CreateFeatureRequestValidatorTests.cs new file mode 100644 index 0000000..16340d0 --- /dev/null +++ b/tests/api/MicCheck.Api.Tests.Unit/Features/CreateFeatureRequestValidatorTests.cs @@ -0,0 +1,61 @@ +using MicCheck.Api.Features; +using NUnit.Framework; + +namespace MicCheck.Api.Tests.Unit.Features; + +[TestFixture] +public class CreateFeatureRequestValidatorTests +{ + private CreateFeatureRequestValidator _validator = null!; + + [SetUp] + public void SetUp() => _validator = new CreateFeatureRequestValidator(); + + [Test] + public void WhenTheRequestIsValid_ThenValidationSucceeds() + { + var result = _validator.Validate(new CreateFeatureRequest("dark_mode", FeatureType.Standard, null, null)); + + Assert.That(result.IsValid, Is.True); + } + + [Test] + public void WhenNameIsEmpty_ThenValidationFails() + { + var result = _validator.Validate(new CreateFeatureRequest("", FeatureType.Standard, null, null)); + + Assert.That(result.IsValid, Is.False); + } + + [Test] + public void WhenNameExceedsMaximumLength_ThenValidationFails() + { + var result = _validator.Validate(new CreateFeatureRequest(new string('a', 151), FeatureType.Standard, null, null)); + + Assert.That(result.IsValid, Is.False); + } + + [Test] + public void WhenNameContainsInvalidCharacters_ThenValidationFails() + { + var result = _validator.Validate(new CreateFeatureRequest("dark mode!", FeatureType.Standard, null, null)); + + Assert.That(result.IsValid, Is.False); + } + + [Test] + public void WhenInitialValueExceedsMaximumLength_ThenValidationFails() + { + var result = _validator.Validate(new CreateFeatureRequest("flag", FeatureType.Standard, new string('a', 20_001), null)); + + Assert.That(result.IsValid, Is.False); + } + + [Test] + public void WhenInitialValueIsNull_ThenTheLengthRuleIsSkipped() + { + var result = _validator.Validate(new CreateFeatureRequest("flag", FeatureType.Standard, null, null)); + + Assert.That(result.IsValid, Is.True); + } +} diff --git a/tests/api/MicCheck.Api.Tests.Unit/Features/CreateTagRequestValidatorTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Features/CreateTagRequestValidatorTests.cs new file mode 100644 index 0000000..d1d3323 --- /dev/null +++ b/tests/api/MicCheck.Api.Tests.Unit/Features/CreateTagRequestValidatorTests.cs @@ -0,0 +1,61 @@ +using MicCheck.Api.Features; +using NUnit.Framework; + +namespace MicCheck.Api.Tests.Unit.Features; + +[TestFixture] +public class CreateTagRequestValidatorTests +{ + private CreateTagRequestValidator _validator = null!; + + [SetUp] + public void SetUp() => _validator = new CreateTagRequestValidator(); + + [Test] + public void WhenTheRequestIsValid_ThenValidationSucceeds() + { + var result = _validator.Validate(new CreateTagRequest("Beta", "#FF0000")); + + Assert.That(result.IsValid, Is.True); + } + + [Test] + public void WhenLabelIsEmpty_ThenValidationFails() + { + var result = _validator.Validate(new CreateTagRequest("", "#FF0000")); + + Assert.That(result.IsValid, Is.False); + } + + [Test] + public void WhenLabelExceedsMaximumLength_ThenValidationFails() + { + var result = _validator.Validate(new CreateTagRequest(new string('a', 101), "#FF0000")); + + Assert.That(result.IsValid, Is.False); + } + + [Test] + public void WhenColorIsEmpty_ThenValidationFails() + { + var result = _validator.Validate(new CreateTagRequest("Beta", "")); + + Assert.That(result.IsValid, Is.False); + } + + [Test] + public void WhenColorIsNotAValidHexCode_ThenValidationFails() + { + var result = _validator.Validate(new CreateTagRequest("Beta", "red")); + + Assert.That(result.IsValid, Is.False); + } + + [Test] + public void WhenColorIsAShortHandHexCode_ThenValidationSucceeds() + { + var result = _validator.Validate(new CreateTagRequest("Beta", "#F00")); + + Assert.That(result.IsValid, Is.True); + } +} diff --git a/tests/api/MicCheck.Api.Tests.Unit/Features/FeatureResponseTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Features/FeatureResponseTests.cs new file mode 100644 index 0000000..4721fa1 --- /dev/null +++ b/tests/api/MicCheck.Api.Tests.Unit/Features/FeatureResponseTests.cs @@ -0,0 +1,88 @@ +using MicCheck.Api.Features; +using NUnit.Framework; + +namespace MicCheck.Api.Tests.Unit.Features; + +[TestFixture] +public class FeatureResponseTests +{ + [Test] + public void WhenMappingAFeatureWithNoTags_ThenTheResponseHasAnEmptyTagsList() + { + var feature = new Feature { Id = 1, Name = "dark_mode", ProjectId = 5, Type = FeatureType.Standard, CreatedAt = DateTimeOffset.UtcNow }; + + var response = FeatureResponse.From(feature); + + Assert.That(response.Id, Is.EqualTo(1)); + Assert.That(response.Name, Is.EqualTo("dark_mode")); + Assert.That(response.ProjectId, Is.EqualTo(5)); + Assert.That(response.Tags, Is.Empty); + } + + [Test] + public void WhenMappingAFeature_ThenTypeIsUpperInvariant() + { + var feature = new Feature { Id = 1, Name = "flag", ProjectId = 1, Type = FeatureType.MultiVariate, CreatedAt = DateTimeOffset.UtcNow }; + + var response = FeatureResponse.From(feature); + + Assert.That(response.Type, Is.EqualTo("MULTIVARIATE")); + } + + [Test] + public void WhenMappingAFeatureWithTags_ThenEachTagIsMapped() + { + var feature = new Feature { Id = 1, Name = "flag", ProjectId = 1, CreatedAt = DateTimeOffset.UtcNow }; + feature.Tags.Add(new Tag { Id = 1, Label = "beta", Color = "#FF0000", ProjectId = 1 }); + + var response = FeatureResponse.From(feature); + + Assert.That(response.Tags, Has.Count.EqualTo(1)); + Assert.That(response.Tags[0].Label, Is.EqualTo("beta")); + } +} + +[TestFixture] +public class TagResponseTests +{ + [Test] + public void WhenMappingATag_ThenAllFieldsAreCopied() + { + var tag = new Tag { Id = 1, Label = "beta", Color = "#FF0000", ProjectId = 5 }; + + var response = TagResponse.From(tag); + + Assert.That(response.Id, Is.EqualTo(1)); + Assert.That(response.Label, Is.EqualTo("beta")); + Assert.That(response.Color, Is.EqualTo("#FF0000")); + Assert.That(response.ProjectId, Is.EqualTo(5)); + } +} + +[TestFixture] +public class FeatureStateResponseTests +{ + [Test] + public void WhenMappingAFeatureState_ThenAllFieldsAreCopied() + { + var now = DateTimeOffset.UtcNow; + var state = new FeatureState + { + Id = 1, + FeatureId = 2, + EnvironmentId = 3, + Enabled = true, + Value = "on", + CreatedAt = now, + UpdatedAt = now + }; + + var response = FeatureStateResponse.From(state); + + Assert.That(response.Id, Is.EqualTo(1)); + Assert.That(response.FeatureId, Is.EqualTo(2)); + Assert.That(response.EnvironmentId, Is.EqualTo(3)); + Assert.That(response.Enabled, Is.True); + Assert.That(response.Value, Is.EqualTo("on")); + } +} diff --git a/tests/api/MicCheck.Api.Tests.Unit/Features/FeatureSegmentServiceTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Features/FeatureSegmentServiceTests.cs new file mode 100644 index 0000000..45cf5d0 --- /dev/null +++ b/tests/api/MicCheck.Api.Tests.Unit/Features/FeatureSegmentServiceTests.cs @@ -0,0 +1,131 @@ +using MicCheck.Api.Data; +using MicCheck.Api.Features; +using MicCheck.Api.Segments; +using MicCheck.Api.Tests.Unit.TestSupport; +using Moq; +using NUnit.Framework; + +namespace MicCheck.Api.Tests.Unit.Features; + +[TestFixture] +public class FeatureSegmentServiceTests +{ + private Mock _db = null!; + private List _segments = null!; + private List _featureSegments = null!; + private List _featureStates = null!; + private FeatureSegmentService _service = null!; + private const int FeatureId = 1; + private const int EnvironmentId = 1; + private const int SegmentId = 1; + + [SetUp] + public void SetUp() + { + _db = new Mock(); + _segments = [new Segment { Id = SegmentId, Name = "Premium Users", ProjectId = 1, CreatedAt = DateTimeOffset.UtcNow }]; + _db.SetupDbSetWithGeneratedIds(c => c.Segments, _segments); + _featureSegments = []; + _db.SetupDbSetWithGeneratedIds(c => c.FeatureSegments, _featureSegments); + _featureStates = []; + _db.SetupDbSetWithGeneratedIds(c => c.FeatureStates, _featureStates); + + _service = new FeatureSegmentService(_db.Object); + } + + [Test] + public async Task WhenCreatingAFeatureSegment_ThenItIsPersistedWithAMatchingFeatureState() + { + var response = await _service.CreateAsync(FeatureId, EnvironmentId, SegmentId, priority: 1, enabled: true, value: "on"); + + Assert.That(response.SegmentName, Is.EqualTo("Premium Users")); + Assert.That(response.Enabled, Is.True); + Assert.That(_featureSegments, Has.Count.EqualTo(1)); + Assert.That(_featureStates.Single().FeatureSegmentId, Is.EqualTo(response.Id)); + } + + [Test] + public void WhenCreatingAFeatureSegmentForASegmentThatDoesNotExist_ThenKeyNotFoundExceptionIsThrown() + { + Assert.ThrowsAsync(() => + _service.CreateAsync(FeatureId, EnvironmentId, 999, priority: 1, enabled: true, value: null)); + } + + [Test] + public async Task WhenListingFeatureSegments_ThenTheyAreOrderedByPriorityAndIncludeTheSegmentNameAndState() + { + await _service.CreateAsync(FeatureId, EnvironmentId, SegmentId, priority: 2, enabled: true, value: "second"); + _segments.Add(new Segment { Id = 2, Name = "Beta Users", ProjectId = 1, CreatedAt = DateTimeOffset.UtcNow }); + await _service.CreateAsync(FeatureId, EnvironmentId, 2, priority: 1, enabled: false, value: "first"); + + var results = await _service.ListByFeatureAsync(FeatureId, EnvironmentId); + + Assert.That(results, Has.Count.EqualTo(2)); + Assert.That(results[0].Priority, Is.EqualTo(1)); + Assert.That(results[0].SegmentName, Is.EqualTo("Beta Users")); + Assert.That(results[1].Priority, Is.EqualTo(2)); + } + + [Test] + public async Task WhenListingFeatureSegmentsForADifferentEnvironment_ThenNoneAreReturned() + { + await _service.CreateAsync(FeatureId, EnvironmentId, SegmentId, priority: 1, enabled: true, value: null); + + var results = await _service.ListByFeatureAsync(FeatureId, 999); + + Assert.That(results, Is.Empty); + } + + [Test] + public async Task WhenUpdatingAFeatureSegmentThatDoesNotExist_ThenNullIsReturned() + { + var result = await _service.UpdateAsync(999, priority: 1, enabled: true, value: null); + + Assert.That(result, Is.Null); + } + + [Test] + public async Task WhenUpdatingAFeatureSegment_ThenTheExistingStateIsUpdated() + { + var created = await _service.CreateAsync(FeatureId, EnvironmentId, SegmentId, priority: 1, enabled: false, value: "off"); + + var updated = await _service.UpdateAsync(created.Id, priority: 5, enabled: true, value: "on"); + + Assert.That(updated!.Priority, Is.EqualTo(5)); + Assert.That(updated.Enabled, Is.True); + Assert.That(updated.Value, Is.EqualTo("on")); + Assert.That(_featureStates, Has.Count.EqualTo(1)); + } + + [Test] + public async Task WhenUpdatingAFeatureSegmentThatHasNoExistingState_ThenANewStateIsCreated() + { + var featureSegment = new FeatureSegment { Id = 1, FeatureId = FeatureId, SegmentId = SegmentId, EnvironmentId = EnvironmentId, Priority = 1 }; + _featureSegments.Add(featureSegment); + + var updated = await _service.UpdateAsync(featureSegment.Id, priority: 3, enabled: true, value: "on"); + + Assert.That(updated!.Enabled, Is.True); + Assert.That(_featureStates, Has.Count.EqualTo(1)); + Assert.That(_featureStates[0].FeatureSegmentId, Is.EqualTo(featureSegment.Id)); + } + + [Test] + public async Task WhenDeletingAFeatureSegmentThatDoesNotExist_ThenFalseIsReturned() + { + var result = await _service.DeleteAsync(999); + + Assert.That(result, Is.False); + } + + [Test] + public async Task WhenDeletingAFeatureSegment_ThenTrueIsReturnedAndItIsRemoved() + { + var created = await _service.CreateAsync(FeatureId, EnvironmentId, SegmentId, priority: 1, enabled: true, value: null); + + var result = await _service.DeleteAsync(created.Id); + + Assert.That(result, Is.True); + Assert.That(_featureSegments.Any(fsg => fsg.Id == created.Id), Is.False); + } +} diff --git a/tests/api/MicCheck.Api.Tests.Unit/Features/FeatureServiceTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Features/FeatureServiceTests.cs index fc53292..45ec63c 100755 --- a/tests/api/MicCheck.Api.Tests.Unit/Features/FeatureServiceTests.cs +++ b/tests/api/MicCheck.Api.Tests.Unit/Features/FeatureServiceTests.cs @@ -179,4 +179,68 @@ public class FeatureServiceTests Assert.That(updated.Tags.Select(t => t.Id), Does.Not.Contain(tag.Id)); } + + [Test] + public async Task WhenFindingAFeatureByIdThatExists_ThenTheFeatureIsReturned() + { + var feature = await _service.CreateAsync(ProjectId, "flag", FeatureType.Standard, null, null); + + var found = await _service.FindByIdAsync(feature.Id); + + Assert.That(found, Is.Not.Null); + Assert.That(found!.Id, Is.EqualTo(feature.Id)); + } + + [Test] + public async Task WhenFindingAFeatureByIdThatDoesNotExist_ThenNullIsReturned() + { + var found = await _service.FindByIdAsync(999); + + Assert.That(found, Is.Null); + } + + [Test] + public void WhenUpdatingAFeatureThatDoesNotExist_ThenKeyNotFoundExceptionIsThrown() + { + Assert.ThrowsAsync(() => _service.UpdateAsync(999, "renamed", null)); + } + + [Test] + public void WhenAssigningATagToAFeatureThatDoesNotExist_ThenKeyNotFoundExceptionIsThrown() + { + var tag = new Tag { Label = "beta", Color = "#FF0000", ProjectId = ProjectId }; + _db.Object.Tags.Add(tag); + + Assert.ThrowsAsync(() => _service.AssignTagAsync(999, tag.Id)); + } + + [Test] + public async Task WhenAssigningATagThatDoesNotExist_ThenKeyNotFoundExceptionIsThrown() + { + var feature = await _service.CreateAsync(ProjectId, "flag", FeatureType.Standard, null, null); + + Assert.ThrowsAsync(() => _service.AssignTagAsync(feature.Id, 999)); + } + + [Test] + public void WhenRemovingATagFromAFeatureThatDoesNotExist_ThenKeyNotFoundExceptionIsThrown() + { + Assert.ThrowsAsync(() => _service.RemoveTagAsync(999, 1)); + } + + [Test] + public async Task WhenRemovingATagThatIsNotAssigned_ThenTheFeatureIsUnchanged() + { + var feature = await _service.CreateAsync(ProjectId, "flag", FeatureType.Standard, null, null); + + var updated = await _service.RemoveTagAsync(feature.Id, 999); + + Assert.That(updated.Tags, Is.Empty); + } + + [Test] + public void WhenDeletingAFeatureThatDoesNotExist_ThenNoExceptionIsThrown() + { + Assert.DoesNotThrowAsync(() => _service.DeleteAsync(999)); + } } diff --git a/tests/api/MicCheck.Api.Tests.Unit/Features/FeatureStateServiceTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Features/FeatureStateServiceTests.cs new file mode 100644 index 0000000..834991d --- /dev/null +++ b/tests/api/MicCheck.Api.Tests.Unit/Features/FeatureStateServiceTests.cs @@ -0,0 +1,174 @@ +using MicCheck.Api.Audit; +using MicCheck.Api.Data; +using MicCheck.Api.Features; +using MicCheck.Api.Organizations; +using MicCheck.Api.Projects; +using MicCheck.Api.Tests.Unit.TestSupport; +using MicCheck.Api.Webhooks; +using Moq; +using NUnit.Framework; +using AppEnvironment = MicCheck.Api.Environments.Environment; + +namespace MicCheck.Api.Tests.Unit.Features; + +[TestFixture] +public class FeatureStateServiceTests +{ + private Mock _db = null!; + private List _featureStates = null!; + private List _features = null!; + private List _environments = null!; + private FeatureStateService _service = null!; + private const int OrganizationId = 1; + private const int ProjectId = 1; + private const int EnvironmentId = 1; + private const int FeatureId = 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 } + ]); + _environments = [new AppEnvironment { Id = EnvironmentId, Name = "Production", ApiKey = "test-key", ProjectId = ProjectId, CreatedAt = DateTimeOffset.UtcNow }]; + _db.SetupDbSet(c => c.Environments, _environments); + _features = [new Feature { Id = FeatureId, Name = "dark_mode", ProjectId = ProjectId, CreatedAt = DateTimeOffset.UtcNow }]; + _db.SetupDbSet(c => c.Features, _features); + _featureStates = []; + _db.SetupDbSetWithGeneratedIds(c => c.FeatureStates, _featureStates); + + var webhookQueue = new WebhookQueue(); + var auditService = new Mock(_db.Object, null!, webhookQueue); + auditService.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); + + _service = new FeatureStateService(_db.Object, webhookQueue, auditService.Object); + } + + private FeatureState AddState(bool enabled = false, string? value = null) + { + var state = new FeatureState + { + Id = 1, + FeatureId = FeatureId, + EnvironmentId = EnvironmentId, + Enabled = enabled, + Value = value, + CreatedAt = DateTimeOffset.UtcNow, + UpdatedAt = DateTimeOffset.UtcNow + }; + _featureStates.Add(state); + return state; + } + + [Test] + public async Task WhenListingEnvironmentLevelStates_ThenIdentityAndSegmentOverridesAreExcluded() + { + AddState(); + _featureStates.Add(new FeatureState { Id = 2, FeatureId = FeatureId, EnvironmentId = EnvironmentId, IdentityId = 5, CreatedAt = DateTimeOffset.UtcNow, UpdatedAt = DateTimeOffset.UtcNow }); + _featureStates.Add(new FeatureState { Id = 3, FeatureId = FeatureId, EnvironmentId = EnvironmentId, FeatureSegmentId = 7, CreatedAt = DateTimeOffset.UtcNow, UpdatedAt = DateTimeOffset.UtcNow }); + + var results = await _service.ListByEnvironmentAsync(EnvironmentId); + + Assert.That(results, Has.Count.EqualTo(1)); + Assert.That(results[0].Id, Is.EqualTo(1)); + } + + [Test] + public async Task WhenFindingAStateByIdInTheCorrectEnvironment_ThenItIsReturned() + { + var state = AddState(); + + var found = await _service.FindByIdAsync(state.Id, EnvironmentId); + + Assert.That(found, Is.Not.Null); + } + + [Test] + public async Task WhenFindingAStateByIdInADifferentEnvironment_ThenNullIsReturned() + { + var state = AddState(); + + var found = await _service.FindByIdAsync(state.Id, 999); + + Assert.That(found, Is.Null); + } + + [Test] + public void WhenUpdatingAStateThatDoesNotExist_ThenKeyNotFoundExceptionIsThrown() + { + Assert.ThrowsAsync(() => _service.UpdateAsync(999, true, "on")); + } + + [Test] + public async Task WhenUpdatingAState_ThenEnabledAndValueAreChangedAndVersionIsIncremented() + { + var state = AddState(enabled: false, value: "old"); + + var updated = await _service.UpdateAsync(state.Id, true, "new"); + + Assert.That(updated.Enabled, Is.True); + Assert.That(updated.Value, Is.EqualTo("new")); + Assert.That(updated.Version, Is.EqualTo(1)); + } + + [Test] + public void WhenPatchingAStateThatDoesNotExist_ThenKeyNotFoundExceptionIsThrown() + { + Assert.ThrowsAsync(() => _service.PatchAsync(999, true, null)); + } + + [Test] + public async Task WhenPatchingOnlyTheEnabledField_ThenValueIsUnchanged() + { + var state = AddState(enabled: false, value: "original"); + + var patched = await _service.PatchAsync(state.Id, true, null); + + Assert.That(patched.Enabled, Is.True); + Assert.That(patched.Value, Is.EqualTo("original")); + } + + [Test] + public async Task WhenPatchingOnlyTheValueField_ThenEnabledIsUnchanged() + { + var state = AddState(enabled: true, value: "original"); + + var patched = await _service.PatchAsync(state.Id, null, "updated"); + + Assert.That(patched.Enabled, Is.True); + Assert.That(patched.Value, Is.EqualTo("updated")); + } + + [Test] + public async Task WhenUpdatingAState_ThenAWebhookEventIsEnqueued() + { + var state = AddState(); + var queue = new WebhookQueue(); + + var auditService = new Mock(_db.Object, null!, queue); + auditService.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 service = new FeatureStateService(_db.Object, queue, auditService.Object); + + await service.UpdateAsync(state.Id, true, "on"); + + using var cts = new CancellationTokenSource(); + await using var enumerator = queue.ReadAllAsync(cts.Token).GetAsyncEnumerator(cts.Token); + Assert.That(await enumerator.MoveNextAsync(), Is.True); + Assert.That(enumerator.Current.EventType, Is.EqualTo(WebhookEventTypes.FlagUpdated)); + Assert.That(enumerator.Current.EnvironmentId, Is.EqualTo(EnvironmentId)); + Assert.That(enumerator.Current.OrganizationId, Is.EqualTo(OrganizationId)); + } +} diff --git a/tests/api/MicCheck.Api.Tests.Unit/Features/FeatureUsageControllerTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Features/FeatureUsageControllerTests.cs new file mode 100644 index 0000000..051f809 --- /dev/null +++ b/tests/api/MicCheck.Api.Tests.Unit/Features/FeatureUsageControllerTests.cs @@ -0,0 +1,93 @@ +using MicCheck.Api.Data; +using MicCheck.Api.Features; +using MicCheck.Api.Tests.Unit.TestSupport; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Caching.Memory; +using Moq; +using NUnit.Framework; + +namespace MicCheck.Api.Tests.Unit.Features; + +[TestFixture] +public class FeatureUsageControllerTests +{ + private Mock _db = null!; + private List _usage = null!; + private IMemoryCache _cache = null!; + private FeatureUsageController _controller = null!; + private const int EnvironmentId = 1; + + [SetUp] + public void SetUp() + { + _db = new Mock(); + _usage = []; + _db.SetupDbSet(c => c.FeatureUsageDaily, _usage); + _cache = new MemoryCache(new MemoryCacheOptions()); + + _controller = new FeatureUsageController(new FeatureUsageQueryService(_db.Object), _cache); + } + + [Test] + public async Task WhenGettingDashboardUsageWithNoData_ThenEmptyResultsAreReturned() + { + var result = await _controller.GetDashboardUsage(EnvironmentId); + + var ok = result.Result as OkObjectResult; + var response = (DashboardUsageResponse)ok!.Value!; + Assert.That(response.TopFeaturesLastDay, Is.Empty); + } + + [Test] + public async Task WhenGettingDashboardUsageASecondTime_ThenTheCachedResultIsReturnedWithoutQueryingAgain() + { + var first = await _controller.GetDashboardUsage(EnvironmentId); + _usage.Add(new FeatureUsageDaily + { + EnvironmentId = EnvironmentId, + FeatureId = 1, + FeatureName = "flag", + UsageDate = DateOnly.FromDateTime(DateTime.UtcNow), + Count = 100, + UpdatedAt = DateTimeOffset.UtcNow + }); + + var second = await _controller.GetDashboardUsage(EnvironmentId); + + var firstOk = (DashboardUsageResponse)((OkObjectResult)first.Result!).Value!; + var secondOk = (DashboardUsageResponse)((OkObjectResult)second.Result!).Value!; + Assert.That(secondOk, Is.SameAs(firstOk)); + } + + [Test] + public async Task WhenGettingDashboardUsageForDifferentDayRanges_ThenEachRangeIsCachedSeparately() + { + var sevenDays = await _controller.GetDashboardUsage(EnvironmentId, days: 7); + var fourteenDays = await _controller.GetDashboardUsage(EnvironmentId, days: 14); + + var sevenOk = (DashboardUsageResponse)((OkObjectResult)sevenDays.Result!).Value!; + var fourteenOk = (DashboardUsageResponse)((OkObjectResult)fourteenDays.Result!).Value!; + Assert.That(sevenOk, Is.Not.SameAs(fourteenOk)); + } + + [Test] + public async Task WhenDaysExceedsTheMaximum_ThenItIsClampedTo90() + { + _usage.Add(new FeatureUsageDaily + { + EnvironmentId = EnvironmentId, + FeatureId = 1, + FeatureName = "flag", + UsageDate = DateOnly.FromDateTime(DateTime.UtcNow), + Count = 5, + UpdatedAt = DateTimeOffset.UtcNow + }); + + var clamped = await _controller.GetDashboardUsage(EnvironmentId, days: 1000); + var explicitNinety = await _controller.GetDashboardUsage(EnvironmentId, days: 90); + + var clampedOk = (DashboardUsageResponse)((OkObjectResult)clamped.Result!).Value!; + var explicitOk = (DashboardUsageResponse)((OkObjectResult)explicitNinety.Result!).Value!; + Assert.That(clampedOk.DailyUsage.Count, Is.EqualTo(explicitOk.DailyUsage.Count)); + } +} diff --git a/tests/api/MicCheck.Api.Tests.Unit/Features/FeaturesControllerTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Features/FeaturesControllerTests.cs new file mode 100644 index 0000000..36456da --- /dev/null +++ b/tests/api/MicCheck.Api.Tests.Unit/Features/FeaturesControllerTests.cs @@ -0,0 +1,282 @@ +using MicCheck.Api.Audit; +using MicCheck.Api.Common; +using MicCheck.Api.Data; +using MicCheck.Api.Features; +using MicCheck.Api.Organizations; +using MicCheck.Api.Projects; +using MicCheck.Api.Tests.Unit.TestSupport; +using Microsoft.AspNetCore.Mvc; +using Moq; +using NUnit.Framework; +using AppEnvironment = MicCheck.Api.Environments.Environment; + +namespace MicCheck.Api.Tests.Unit.Features; + +[TestFixture] +public class FeaturesControllerTests +{ + private Mock _db = null!; + private List _features = null!; + private List _tags = null!; + private FeaturesController _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 } + ]); + _db.SetupDbSet(c => c.Environments, new List()); + _features = []; + _db.SetupDbSetWithGeneratedIds(c => c.Features, _features); + _db.SetupDbSet(c => c.FeatureStates, new List()); + _tags = []; + _db.SetupDbSetWithGeneratedIds(c => c.Tags, _tags); + + var webhookQueue = new MicCheck.Api.Webhooks.WebhookQueue(); + var auditService = new Mock(_db.Object, null!, webhookQueue); + auditService.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 service = new FeatureService(_db.Object, auditService.Object, webhookQueue); + _controller = new FeaturesController(service); + } + + private static CreateFeatureRequest ValidRequest(string name = "dark_mode") => + new(name, FeatureType.Standard, null, null); + + [Test] + public async Task WhenListingFeaturesForAProject_ThenAllFeaturesAreReturnedInAPage() + { + await _controller.Create(ProjectId, ValidRequest("feature_a"), CancellationToken.None); + await _controller.Create(ProjectId, ValidRequest("feature_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 WhenListingFeaturesWithAPageSizeAboveTheMaximum_ThenThePageSizeIsClampedTo100() + { + for (var i = 0; i < 3; i++) + await _controller.Create(ProjectId, ValidRequest($"feature_{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 WhenCreatingAFeatureWithValidData_ThenACreatedResultWithTheFeatureIsReturned() + { + var result = await _controller.Create(ProjectId, ValidRequest(), CancellationToken.None); + + var created = result.Result as CreatedAtActionResult; + Assert.That(created, Is.Not.Null); + Assert.That(((FeatureResponse)created!.Value!).Name, Is.EqualTo("dark_mode")); + } + + [Test] + public async Task WhenCreatingAFeatureThatExceedsTheProjectLimit_ThenBadRequestIsReturned() + { + for (var i = 0; i < 400; i++) + _features.Add(new Feature { Id = i + 1, Name = $"feature_{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 WhenGettingAFeatureByIdInTheCorrectProject_ThenTheFeatureIsReturned() + { + var created = await _controller.Create(ProjectId, ValidRequest(), CancellationToken.None); + var featureId = ((FeatureResponse)((CreatedAtActionResult)created.Result!).Value!).Id; + + var result = await _controller.GetById(ProjectId, featureId, CancellationToken.None); + + var ok = result.Result as OkObjectResult; + Assert.That(ok, Is.Not.Null); + Assert.That(((FeatureResponse)ok!.Value!).Id, Is.EqualTo(featureId)); + } + + [Test] + public async Task WhenGettingAFeatureThatDoesNotExist_ThenNotFoundIsReturned() + { + var result = await _controller.GetById(ProjectId, 999, CancellationToken.None); + + Assert.That(result.Result, Is.InstanceOf()); + } + + [Test] + public async Task WhenGettingAFeatureThatBelongsToADifferentProject_ThenNotFoundIsReturned() + { + var created = await _controller.Create(ProjectId, ValidRequest(), CancellationToken.None); + var featureId = ((FeatureResponse)((CreatedAtActionResult)created.Result!).Value!).Id; + + var result = await _controller.GetById(999, featureId, CancellationToken.None); + + Assert.That(result.Result, Is.InstanceOf()); + } + + [Test] + public async Task WhenUpdatingAFeatureThatDoesNotExist_ThenNotFoundIsReturned() + { + var result = await _controller.Update(ProjectId, 999, new UpdateFeatureRequest("renamed", null), CancellationToken.None); + + Assert.That(result.Result, Is.InstanceOf()); + } + + [Test] + public async Task WhenUpdatingAFeatureInTheCorrectProject_ThenTheUpdatedFeatureIsReturned() + { + var created = await _controller.Create(ProjectId, ValidRequest(), CancellationToken.None); + var featureId = ((FeatureResponse)((CreatedAtActionResult)created.Result!).Value!).Id; + + var result = await _controller.Update(ProjectId, featureId, new UpdateFeatureRequest("renamed", "new desc"), CancellationToken.None); + + var ok = result.Result as OkObjectResult; + Assert.That(ok, Is.Not.Null); + Assert.That(((FeatureResponse)ok!.Value!).Name, Is.EqualTo("renamed")); + } + + [Test] + public async Task WhenPatchingAFeatureThatDoesNotExist_ThenNotFoundIsReturned() + { + var result = await _controller.Patch(ProjectId, 999, new PatchFeatureRequest("renamed", null, null), CancellationToken.None); + + Assert.That(result.Result, Is.InstanceOf()); + } + + [Test] + public async Task WhenPatchingOnlyTheNameField_ThenDescriptionAndDefaultEnabledAreUnchanged() + { + var created = await _controller.Create(ProjectId, new CreateFeatureRequest("original", FeatureType.Standard, null, "original desc"), CancellationToken.None); + var featureId = ((FeatureResponse)((CreatedAtActionResult)created.Result!).Value!).Id; + + var result = await _controller.Patch(ProjectId, featureId, new PatchFeatureRequest("renamed", null, null), CancellationToken.None); + + var ok = result.Result as OkObjectResult; + var response = (FeatureResponse)ok!.Value!; + Assert.That(response.Name, Is.EqualTo("renamed")); + Assert.That(response.Description, Is.EqualTo("original desc")); + Assert.That(response.DefaultEnabled, Is.False); + } + + [Test] + public async Task WhenPatchingTheDefaultEnabledField_ThenItIsUpdated() + { + var created = await _controller.Create(ProjectId, ValidRequest(), CancellationToken.None); + var featureId = ((FeatureResponse)((CreatedAtActionResult)created.Result!).Value!).Id; + + var result = await _controller.Patch(ProjectId, featureId, new PatchFeatureRequest(null, null, true), CancellationToken.None); + + var ok = result.Result as OkObjectResult; + Assert.That(((FeatureResponse)ok!.Value!).DefaultEnabled, Is.True); + } + + [Test] + public async Task WhenDeletingAFeatureThatDoesNotExist_ThenNotFoundIsReturned() + { + var result = await _controller.Delete(ProjectId, 999, CancellationToken.None); + + Assert.That(result, Is.InstanceOf()); + } + + [Test] + public async Task WhenDeletingAFeatureInTheCorrectProject_ThenNoContentIsReturnedAndTheFeatureIsRemoved() + { + var created = await _controller.Create(ProjectId, ValidRequest(), CancellationToken.None); + var featureId = ((FeatureResponse)((CreatedAtActionResult)created.Result!).Value!).Id; + + var result = await _controller.Delete(ProjectId, featureId, CancellationToken.None); + + Assert.That(result, Is.InstanceOf()); + Assert.That(_features.Any(f => f.Id == featureId), Is.False); + } + + [Test] + public async Task WhenAssigningATagThatDoesNotExist_ThenNotFoundIsReturned() + { + var created = await _controller.Create(ProjectId, ValidRequest(), CancellationToken.None); + var featureId = ((FeatureResponse)((CreatedAtActionResult)created.Result!).Value!).Id; + + var result = await _controller.AssignTag(ProjectId, featureId, 999, CancellationToken.None); + + Assert.That(result.Result, Is.InstanceOf()); + } + + [Test] + public async Task WhenAssigningATagFromAnotherProject_ThenBadRequestIsReturned() + { + var created = await _controller.Create(ProjectId, ValidRequest(), CancellationToken.None); + var featureId = ((FeatureResponse)((CreatedAtActionResult)created.Result!).Value!).Id; + var foreignTag = new Tag { Id = 1, Label = "beta", Color = "#FF0000", ProjectId = 999 }; + _tags.Add(foreignTag); + + var result = await _controller.AssignTag(ProjectId, featureId, foreignTag.Id, CancellationToken.None); + + Assert.That(result.Result, Is.InstanceOf()); + } + + [Test] + public async Task WhenAssigningAValidTag_ThenTheFeatureResponseIncludesTheTag() + { + var created = await _controller.Create(ProjectId, ValidRequest(), CancellationToken.None); + var featureId = ((FeatureResponse)((CreatedAtActionResult)created.Result!).Value!).Id; + var tag = new Tag { Id = 1, Label = "beta", Color = "#FF0000", ProjectId = ProjectId }; + _tags.Add(tag); + + var result = await _controller.AssignTag(ProjectId, featureId, tag.Id, CancellationToken.None); + + var ok = result.Result as OkObjectResult; + Assert.That(((FeatureResponse)ok!.Value!).Tags.Select(t => t.Id), Does.Contain(tag.Id)); + } + + [Test] + public async Task WhenFeatureForAssignTagDoesNotExistInTheProject_ThenNotFoundIsReturned() + { + var result = await _controller.AssignTag(ProjectId, 999, 1, CancellationToken.None); + + Assert.That(result.Result, Is.InstanceOf()); + } + + [Test] + public async Task WhenRemovingTagFromFeatureThatDoesNotExist_ThenNotFoundIsReturned() + { + var result = await _controller.RemoveTag(ProjectId, 999, 1, CancellationToken.None); + + Assert.That(result.Result, Is.InstanceOf()); + } + + [Test] + public async Task WhenRemovingAnAssignedTag_ThenTheFeatureResponseNoLongerIncludesIt() + { + var created = await _controller.Create(ProjectId, ValidRequest(), CancellationToken.None); + var featureId = ((FeatureResponse)((CreatedAtActionResult)created.Result!).Value!).Id; + var tag = new Tag { Id = 1, Label = "beta", Color = "#FF0000", ProjectId = ProjectId }; + _tags.Add(tag); + await _controller.AssignTag(ProjectId, featureId, tag.Id, CancellationToken.None); + + var result = await _controller.RemoveTag(ProjectId, featureId, tag.Id, CancellationToken.None); + + var ok = result.Result as OkObjectResult; + Assert.That(((FeatureResponse)ok!.Value!).Tags.Select(t => t.Id), Does.Not.Contain(tag.Id)); + } +} diff --git a/tests/api/MicCheck.Api.Tests.Unit/Features/TagServiceTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Features/TagServiceTests.cs new file mode 100644 index 0000000..1d2cf08 --- /dev/null +++ b/tests/api/MicCheck.Api.Tests.Unit/Features/TagServiceTests.cs @@ -0,0 +1,84 @@ +using MicCheck.Api.Data; +using MicCheck.Api.Features; +using MicCheck.Api.Tests.Unit.TestSupport; +using Moq; +using NUnit.Framework; + +namespace MicCheck.Api.Tests.Unit.Features; + +[TestFixture] +public class TagServiceTests +{ + private Mock _db = null!; + private List _tags = null!; + private TagService _service = null!; + private const int ProjectId = 1; + + [SetUp] + public void SetUp() + { + _db = new Mock(); + _tags = []; + _db.SetupDbSetWithGeneratedIds(c => c.Tags, _tags); + + _service = new TagService(_db.Object); + } + + [Test] + public async Task WhenCreatingATag_ThenItIsPersistedWithTheGivenProject() + { + var tag = await _service.CreateAsync(ProjectId, "Beta", "#FF0000"); + + Assert.That(tag.Label, Is.EqualTo("Beta")); + Assert.That(tag.Color, Is.EqualTo("#FF0000")); + Assert.That(tag.ProjectId, Is.EqualTo(ProjectId)); + Assert.That(_tags, Has.Count.EqualTo(1)); + } + + [Test] + public async Task WhenListingTagsForAProject_ThenOnlyThatProjectsTagsAreReturned() + { + await _service.CreateAsync(ProjectId, "Beta", "#FF0000"); + await _service.CreateAsync(999, "Other", "#00FF00"); + + var tags = await _service.ListByProjectAsync(ProjectId); + + Assert.That(tags, Has.Count.EqualTo(1)); + Assert.That(tags[0].Label, Is.EqualTo("Beta")); + } + + [Test] + public async Task WhenFindingATagByIdThatExists_ThenTheTagIsReturned() + { + var tag = await _service.CreateAsync(ProjectId, "Beta", "#FF0000"); + + var found = await _service.FindByIdAsync(tag.Id); + + Assert.That(found, Is.Not.Null); + Assert.That(found!.Id, Is.EqualTo(tag.Id)); + } + + [Test] + public async Task WhenFindingATagByIdThatDoesNotExist_ThenNullIsReturned() + { + var found = await _service.FindByIdAsync(999); + + Assert.That(found, Is.Null); + } + + [Test] + public async Task WhenDeletingATag_ThenItIsRemovedFromTheDatabase() + { + var tag = await _service.CreateAsync(ProjectId, "Beta", "#FF0000"); + + await _service.DeleteAsync(tag.Id); + + Assert.That(_tags.Any(t => t.Id == tag.Id), Is.False); + } + + [Test] + public void WhenDeletingATagThatDoesNotExist_ThenNoExceptionIsThrown() + { + Assert.DoesNotThrowAsync(() => _service.DeleteAsync(999)); + } +} diff --git a/tests/api/MicCheck.Api.Tests.Unit/Features/TagsControllerTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Features/TagsControllerTests.cs new file mode 100644 index 0000000..ae5323a --- /dev/null +++ b/tests/api/MicCheck.Api.Tests.Unit/Features/TagsControllerTests.cs @@ -0,0 +1,82 @@ +using MicCheck.Api.Data; +using MicCheck.Api.Features; +using MicCheck.Api.Tests.Unit.TestSupport; +using Microsoft.AspNetCore.Mvc; +using Moq; +using NUnit.Framework; + +namespace MicCheck.Api.Tests.Unit.Features; + +[TestFixture] +public class TagsControllerTests +{ + private Mock _db = null!; + private List _tags = null!; + private TagsController _controller = null!; + private const int ProjectId = 1; + + [SetUp] + public void SetUp() + { + _db = new Mock(); + _tags = []; + _db.SetupDbSetWithGeneratedIds(c => c.Tags, _tags); + + _controller = new TagsController(new TagService(_db.Object)); + } + + [Test] + public async Task WhenListingTagsForAProject_ThenAllOfThatProjectsTagsAreReturned() + { + await _controller.Create(ProjectId, new CreateTagRequest("Beta", "#FF0000"), CancellationToken.None); + await _controller.Create(999, new CreateTagRequest("Other", "#00FF00"), CancellationToken.None); + + var result = await _controller.List(ProjectId, CancellationToken.None); + + var ok = result.Result as OkObjectResult; + var tags = (IReadOnlyList)ok!.Value!; + Assert.That(tags, Has.Count.EqualTo(1)); + Assert.That(tags[0].Label, Is.EqualTo("Beta")); + } + + [Test] + public async Task WhenCreatingATag_ThenACreatedResultWithTheTagIsReturned() + { + var result = await _controller.Create(ProjectId, new CreateTagRequest("Beta", "#FF0000"), CancellationToken.None); + + var created = result.Result as CreatedAtActionResult; + Assert.That(created, Is.Not.Null); + Assert.That(((TagResponse)created!.Value!).Label, Is.EqualTo("Beta")); + } + + [Test] + public async Task WhenDeletingATagThatDoesNotExist_ThenNotFoundIsReturned() + { + var result = await _controller.Delete(ProjectId, 999, CancellationToken.None); + + Assert.That(result, Is.InstanceOf()); + } + + [Test] + public async Task WhenDeletingATagFromADifferentProject_ThenNotFoundIsReturned() + { + var created = await _controller.Create(ProjectId, new CreateTagRequest("Beta", "#FF0000"), CancellationToken.None); + var tagId = ((TagResponse)((CreatedAtActionResult)created.Result!).Value!).Id; + + var result = await _controller.Delete(999, tagId, CancellationToken.None); + + Assert.That(result, Is.InstanceOf()); + } + + [Test] + public async Task WhenDeletingATagInTheCorrectProject_ThenNoContentIsReturnedAndTheTagIsRemoved() + { + var created = await _controller.Create(ProjectId, new CreateTagRequest("Beta", "#FF0000"), CancellationToken.None); + var tagId = ((TagResponse)((CreatedAtActionResult)created.Result!).Value!).Id; + + var result = await _controller.Delete(ProjectId, tagId, CancellationToken.None); + + Assert.That(result, Is.InstanceOf()); + Assert.That(_tags.Any(t => t.Id == tagId), Is.False); + } +} diff --git a/tests/api/MicCheck.Api.Tests.Unit/Features/UpdateFeatureRequestValidatorTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Features/UpdateFeatureRequestValidatorTests.cs new file mode 100644 index 0000000..29d1fca --- /dev/null +++ b/tests/api/MicCheck.Api.Tests.Unit/Features/UpdateFeatureRequestValidatorTests.cs @@ -0,0 +1,45 @@ +using MicCheck.Api.Features; +using NUnit.Framework; + +namespace MicCheck.Api.Tests.Unit.Features; + +[TestFixture] +public class UpdateFeatureRequestValidatorTests +{ + private UpdateFeatureRequestValidator _validator = null!; + + [SetUp] + public void SetUp() => _validator = new UpdateFeatureRequestValidator(); + + [Test] + public void WhenTheRequestIsValid_ThenValidationSucceeds() + { + var result = _validator.Validate(new UpdateFeatureRequest("dark_mode", "A description")); + + Assert.That(result.IsValid, Is.True); + } + + [Test] + public void WhenNameIsEmpty_ThenValidationFails() + { + var result = _validator.Validate(new UpdateFeatureRequest("", null)); + + Assert.That(result.IsValid, Is.False); + } + + [Test] + public void WhenNameExceedsMaximumLength_ThenValidationFails() + { + var result = _validator.Validate(new UpdateFeatureRequest(new string('a', 151), null)); + + Assert.That(result.IsValid, Is.False); + } + + [Test] + public void WhenNameContainsInvalidCharacters_ThenValidationFails() + { + var result = _validator.Validate(new UpdateFeatureRequest("dark mode!", null)); + + Assert.That(result.IsValid, Is.False); + } +}