diff --git a/CLAUDE.md b/CLAUDE.md index 1cfdd58..b2e4e4f 100755 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,10 +1,10 @@ # CLAUDE.md -Guidance for Claude Code (claude.ai/code) when working in this repo. +Guidance for Claude Code (claude.ai/code) in this repo. ## Project -MicCheck: open source. asp.net, c#, typescript, VueJS. Manages feature flags, projects, environments. +MicCheck: open source. asp.net, c#, typescript, VueJS. Manage feature flags, projects, environments. ## Planned Structure @@ -18,27 +18,30 @@ MicCheck: open source. asp.net, c#, typescript, VueJS. Manages feature flags, pr - Use latest LTS .NET + latest supported nuget packages for that version - Set `langVersion` to latest in all csproj files; enable nullable - Organize code by feature/area, not type (e.g. `features` namespace) -- New features need unit tests covering as much logic as possible (both nunit and jest) -- Any modified file: evaluate for missing test coverage and that all tests pass +- New features need unit tests covering logic as much as possible (both nunit and jest) +- Modified file: check missing test coverage, all tests pass # Coding -- Descriptive names for all classes/methods. No generic names: Provider, Manager, Helper +- Descriptive names all classes/methods. No generic: Provider, Manager, Helper - Match formatting/style from `.editorconfig` -- Wrap lines at 220 characters, leave single line if fewer -- Place interfaces that are implemented by a single class at the bottom of the class file. An interface with multiple implementations of an interface should be in a seperate file. -- Do not use tuples for return types. Prefer records or classes for multiple values -- Do not use `sealed` +- Wrap lines at 220 chars, single line if fewer +- Interfaces implemented by single class → bottom of class file. Interface w/ multiple implementations → separate file. +- No tuples for return types. Prefer records or classes for multiple values +- No `sealed` - Use `record` for data objects, `class` for objects with behavior. Avoid mutable state where possible. ## Testing +- Min 70% code coverage, target 90%. Unit tests focus end-user scenarios first. +- Don't write tests just for coverage. Call out missing coverage rather than cover stuff not valuable to end user. +- Code not cleanly unit-testable → mark `[ExcludeFromCodeCoverage]` or exclude namespace from coverage in .runsettings file - BDD-style unit tests, end-to-end as possible, no external resources (DB, filesystem). e.g. `WhenAUserDoesSomething_ThenAThingAppears` -- Mock external deps with Moq -- Mock EntityFramework DBContexts with an extracted interface and Moq. Do not rely on InMemory provider. -- New features need unit tests covering as much logic as possible -- Any modified file: evaluate for missing test coverage- +- Mock external deps w/ Moq +- Mock EntityFramework DBContexts via extracted interface + Moq. Don't rely on InMemory provider. +- New features need unit tests covering logic as much as possible +- Modified file: check missing test coverage - No "Mock" in mocked object names - No Arrange/Act/Assert comments -- All tests should pass before commit +- All tests pass before commit ## Claude - Plans = `.md` files in `docs/plans/`. Admin → `docs/plans/admin/`, API → `docs/plans/api/` @@ -47,4 +50,4 @@ MicCheck: open source. asp.net, c#, typescript, VueJS. Manages feature flags, pr - Plan implemented from `docs/plans/.md` → save summary as `docs/plans/_output.md` ## Stack -`.gitignore` configured for .NET/Visual Studio (C#, NuGet, MSBuild). Update if stack changes. +`.gitignore` set for .NET/Visual Studio (C#, NuGet, MSBuild). Update if stack change. diff --git a/CLAUDE.original.md b/CLAUDE.original.md old mode 100755 new mode 100644 index 46ccce2..d3180fb --- a/CLAUDE.original.md +++ b/CLAUDE.original.md @@ -1,44 +1,53 @@ # CLAUDE.md -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +Guidance for Claude Code (claude.ai/code) when working in this repo. ## Project -MicCheck is an open source project written in asp.net, c#, typescript and VueJS that manages feature flags, their projects, and their environments. +MicCheck: open source. asp.net, c#, typescript, VueJS. Manages feature flags, projects, environments. ## Planned Structure -- `src/admin/` — administrative components in VueJS -- `src/api/` - api and restful endpoints in .NET +- `src/admin/` — VueJS admin components +- `src/api/` - .NET API + REST endpoints - `tests/` — test suite - `docs/` — documentation ## Best Practices -- Ensure all projects are using the latest LTS version of .NET as well as the latest supported nuget packages for that .NET version -- Ensure that langVersion is set to latest in all csproj files and nullable is enabled -- Code in all projects should be organized by feature or area instead of type. (e.g. a features namespace with all feature related code in it or in child namespaces of it) -- All new feature requests should include corresponding unit tests that cover as much of the logic as possible. -- Any file modified should be evaluated for potential test cases and missing coverage areas. +- Use latest LTS .NET + latest supported nuget packages for that version +- Set `langVersion` to latest in all csproj files; enable nullable +- Organize code by feature/area, not type (e.g. `features` namespace) +- New features need unit tests covering as much logic as possible (both nunit and jest) +- Any modified file: evaluate for missing test coverage and that all tests pass # Coding -- Use descriptive names for all classes and method created. Avoid generic names like Provider, Manager, Helper -- Coding should match formating and style rules in the .editorconfig file +- Descriptive names for all classes/methods. No generic names: Provider, Manager, Helper +- Match formatting/style from `.editorconfig` +- Wrap lines at 220 characters, leave single line if fewer +- Place interfaces that are implemented by a single class at the bottom of the class file. An interface with multiple implementations of an interface should be in a seperate file. +- Do not use tuples for return types. Prefer records or classes for multiple values +- Do not use `sealed` +- Use `record` for data objects, `class` for objects with behavior. Avoid mutable state where possible. ## Testing -- Write unit tests in a BDD style, testing as much code end-to-end as possible without without touching external resources like databases or file systems (e.g. WhenAUserDoesSomething_ThenAThingAppears) -- Mock any external dependencies using Moq. -- Do not name any mocked objects with the word Mock in them -- Do not include any Arrange / Act / Assert comments in the code - +- Require a minimum of 70% code coverage with a target of 90%. Unit tests should focus on end-user scenarios first. +- Do not write tests for just to increase code coverage. Call out lack of test coverage rather than covering something that isn't valuable to the end user. +- Code that can not be cleanly unit tested should be marked with [ExcludeFromCodeCoverage] or have it's namespace excluded from code coverage. +- BDD-style unit tests, end-to-end as possible, no external resources (DB, filesystem). e.g. `WhenAUserDoesSomething_ThenAThingAppears` +- Mock external deps with Moq +- Mock EntityFramework DBContexts with an extracted interface and Moq. Do not rely on InMemory provider. +- New features need unit tests covering as much logic as possible +- Any modified file: evaluate for missing test coverage- +- No "Mock" in mocked object names +- No Arrange/Act/Assert comments +- All tests should pass before commit ## Claude -- Create all plans as .md file located in the `docs/` folder. Admin in `docs/admin/` and API in `docs/api/` -- Divide up large plans into discrete chucks of functionality so each can be built and committed independently. -- When generating a plan from a .md file in /docs/ save the plan in the same folder with the same filename minus the .md extention, but with a _plan.md at the end -- When implementing a plan from a .md file in /docs/ save the summary of the plan in the same folder with the same filename minus the .md extention, but with a _output.md at the end - +- Plans = `.md` files in `docs/plans/`. Admin → `docs/plans/admin/`, API → `docs/plans/api/` +- Split large plans into discrete chunks — each buildable + committable independently +- Plan generated from `docs/plans/.md` → save as `docs/plans/_plan.md` +- Plan implemented from `docs/plans/.md` → save summary as `docs/plans/_output.md` ## Stack - -The `.gitignore` is configured for a .NET/Visual Studio project (C#, NuGet, MSBuild). If this changes, update this file accordingly. +`.gitignore` configured for .NET/Visual Studio (C#, NuGet, MSBuild). Update if stack changes. diff --git a/scripts/ci/test.sh b/scripts/ci/test.sh index 67e026e..6f67d98 100755 --- a/scripts/ci/test.sh +++ b/scripts/ci/test.sh @@ -8,9 +8,17 @@ cd "$CI_ROOT" ensure_dotnet +# --results-directory doesn't clear prior runs - it adds a new GUID folder +# alongside old ones every time. On a runner that reuses its workspace +# (self-hosted, unlike GitHub's ephemeral ones), stale coverage from past +# runs would otherwise get merged in by coverage.sh and silently skew the +# combined percentage. +rm -rf "$CI_ROOT/coverage/dotnet" + log "Running MicCheck.Api.Tests.Unit" dotnet test tests/api/MicCheck.Api.Tests.Unit/MicCheck.Api.Tests.Unit.csproj -c Release --logger trx \ - --collect:"XPlat Code Coverage" --results-directory "$CI_ROOT/coverage/dotnet" + --collect:"XPlat Code Coverage" --results-directory "$CI_ROOT/coverage/dotnet" \ + --settings tests/api/MicCheck.Api.Tests.Unit/coverlet.runsettings log "Running admin Jest tests" npm --prefix src/admin test -- --coverage --coverageReporters=lcov --coverageReporters=text-summary diff --git a/src/api/MicCheck.Api/Audit/AuditLogsController.cs b/src/api/MicCheck.Api/Audit/AuditLogsController.cs index cc5311e..b815d7c 100755 --- a/src/api/MicCheck.Api/Audit/AuditLogsController.cs +++ b/src/api/MicCheck.Api/Audit/AuditLogsController.cs @@ -12,17 +12,15 @@ namespace MicCheck.Api.Audit; [ApiController] [Authorize(Policy = AuthorizationPolicies.AdminApiAccess)] [EnableRateLimiting("AdminApi")] +[Route("api/v1")] public class AuditLogsController( AuditLogQueryService auditLogQueryService, OrganizationService organizationService, ProjectService projectService, EnvironmentService environmentService) : ControllerBase { - [HttpGet("api/v1/organisation/{id}/audit-logs")] - public async Task>> ListByOrganization( - int id, - [FromQuery] AuditLogFilter filter, - CancellationToken ct) + [HttpGet("organisation/{id}/audit-logs")] + public async Task>> ListByOrganization(int id, [FromQuery] AuditLogFilter filter, CancellationToken ct) { var org = await organizationService.FindByIdAsync(id, ct); if (org is null) return NotFound(); @@ -31,11 +29,8 @@ public class AuditLogsController( return Ok(new PaginatedResponse(result.Total, null, null, result.Items.ToList())); } - [HttpGet("api/v1/project/{projectId}/audit-logs")] - public async Task>> ListByProject( - int projectId, - [FromQuery] AuditLogFilter filter, - CancellationToken ct) + [HttpGet("project/{projectId}/audit-logs")] + public async Task>> ListByProject(int projectId, [FromQuery] AuditLogFilter filter, CancellationToken ct) { var project = await projectService.FindByIdAsync(projectId, ct); if (project is null) return NotFound(); @@ -44,11 +39,8 @@ public class AuditLogsController( return Ok(new PaginatedResponse(result.Total, null, null, result.Items.ToList())); } - [HttpGet("api/v1/environment/{apiKey}/audit-logs")] - public async Task>> ListByEnvironment( - string apiKey, - [FromQuery] AuditLogFilter filter, - CancellationToken ct) + [HttpGet("environment/{apiKey}/audit-logs")] + public async Task>> ListByEnvironment(string apiKey, [FromQuery] AuditLogFilter filter, CancellationToken ct) { var environment = await environmentService.FindByApiKeyAsync(apiKey, ct); if (environment is null) return NotFound(); diff --git a/src/api/MicCheck.Api/Common/Security/ApiKeys/ApiKeyEndpoints.cs b/src/api/MicCheck.Api/Common/Security/ApiKeys/ApiKeyEndpoints.cs index 65131e5..0b57098 100755 --- a/src/api/MicCheck.Api/Common/Security/ApiKeys/ApiKeyEndpoints.cs +++ b/src/api/MicCheck.Api/Common/Security/ApiKeys/ApiKeyEndpoints.cs @@ -1,7 +1,9 @@ +using System.Diagnostics.CodeAnalysis; using MicCheck.Api.Common.Security.Authorization; namespace MicCheck.Api.Common.Security.ApiKeys; +[ExcludeFromCodeCoverage(Justification = "Minimal-API route registration; requires a live HTTP pipeline to exercise, which CLAUDE.md disallows (no WebApplicationFactory/InMemory). Branch logic is covered via ApiKeyService unit tests.")] public static class ApiKeyEndpoints { public static void MapApiKeyEndpoints(this WebApplication app) diff --git a/src/api/MicCheck.Api/Common/Security/ApiKeys/ApiKeyResponse.cs b/src/api/MicCheck.Api/Common/Security/ApiKeys/ApiKeyResponse.cs index 3ffa955..a2289d0 100755 --- a/src/api/MicCheck.Api/Common/Security/ApiKeys/ApiKeyResponse.cs +++ b/src/api/MicCheck.Api/Common/Security/ApiKeys/ApiKeyResponse.cs @@ -1,9 +1,3 @@ namespace MicCheck.Api.Common.Security.ApiKeys; -public record ApiKeyResponse( - int Id, - string Name, - string Prefix, - bool IsActive, - DateTimeOffset? ExpiresAt, - DateTimeOffset CreatedAt); +public record ApiKeyResponse(int Id, string Name, string Prefix, bool IsActive, DateTimeOffset? ExpiresAt, DateTimeOffset CreatedAt); diff --git a/src/api/MicCheck.Api/Common/Security/Authorization/AuthEndpoints.cs b/src/api/MicCheck.Api/Common/Security/Authorization/AuthEndpoints.cs index c413ff9..b519bf4 100755 --- a/src/api/MicCheck.Api/Common/Security/Authorization/AuthEndpoints.cs +++ b/src/api/MicCheck.Api/Common/Security/Authorization/AuthEndpoints.cs @@ -1,7 +1,9 @@ +using System.Diagnostics.CodeAnalysis; using Microsoft.AspNetCore.Mvc; namespace MicCheck.Api.Common.Security.Authorization; +[ExcludeFromCodeCoverage(Justification = "Minimal-API route registration; requires a live HTTP pipeline to exercise, which CLAUDE.md disallows (no WebApplicationFactory/InMemory). Branch logic is covered via AuthService unit tests.")] public static class AuthEndpoints { public static void MapAuthEndpoints(this WebApplication app) diff --git a/src/api/MicCheck.Api/Features/FeatureUsageController.cs b/src/api/MicCheck.Api/Features/FeatureUsageController.cs index 82c9f37..09bbc95 100644 --- a/src/api/MicCheck.Api/Features/FeatureUsageController.cs +++ b/src/api/MicCheck.Api/Features/FeatureUsageController.cs @@ -13,10 +13,7 @@ namespace MicCheck.Api.Features; public class FeatureUsageController(FeatureUsageQueryService queryService, IMemoryCache cache) : ControllerBase { [HttpGet] - public async Task> GetDashboardUsage( - int environmentId, - [FromQuery] int days = 14, - CancellationToken ct = default) + public async Task> GetDashboardUsage(int environmentId, [FromQuery] int days = 14, CancellationToken ct = default) { days = Math.Clamp(days, 1, 90); var cacheKey = $"usage-dashboard:{environmentId}:{days}"; 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/src/api/MicCheck.Api/Organizations/OrganizationService.cs b/src/api/MicCheck.Api/Organizations/OrganizationService.cs index 8d61e7f..f0c04db 100755 --- a/src/api/MicCheck.Api/Organizations/OrganizationService.cs +++ b/src/api/MicCheck.Api/Organizations/OrganizationService.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using MicCheck.Api.Audit; using MicCheck.Api.Data; using Microsoft.EntityFrameworkCore; @@ -46,6 +47,7 @@ public class OrganizationService(IMicCheckDbContext db, AuditService auditServic return org; } + [ExcludeFromCodeCoverage(Justification = "ExecuteUpdateAsync requires a real EF Core relational query provider; our Mock> LINQ-to-Objects provider can't execute it, and CLAUDE.md disallows the EF InMemory provider as a substitute. The not-a-member early-return branch is covered by OrganizationServiceTests.")] public async Task SetPrimaryAsync(int organizationId, int userId, CancellationToken ct = default) { var member = await db.OrganizationUsers diff --git a/src/api/MicCheck.Api/Webhooks/WebhookBackgroundService.cs b/src/api/MicCheck.Api/Webhooks/WebhookBackgroundService.cs index 01eb755..1471ea9 100755 --- a/src/api/MicCheck.Api/Webhooks/WebhookBackgroundService.cs +++ b/src/api/MicCheck.Api/Webhooks/WebhookBackgroundService.cs @@ -1,8 +1,10 @@ +using System.Diagnostics.CodeAnalysis; using MicCheck.Api.Data; using Microsoft.Extensions.DependencyInjection; namespace MicCheck.Api.Webhooks; +[ExcludeFromCodeCoverage(Justification = "Long-running BackgroundService reading from a Channel and issuing DI-scoped dispatches; exercising it cleanly requires a live DI container and DB, which CLAUDE.md disallows (no WebApplicationFactory/InMemory). Dispatch logic is covered by WebhookDispatcherTests.")] public class WebhookBackgroundService( WebhookQueue queue, IServiceScopeFactory scopeFactory, diff --git a/src/api/MicCheck.Api/Webhooks/WebhookRetryBackgroundService.cs b/src/api/MicCheck.Api/Webhooks/WebhookRetryBackgroundService.cs index 6a44ed9..f91a827 100755 --- a/src/api/MicCheck.Api/Webhooks/WebhookRetryBackgroundService.cs +++ b/src/api/MicCheck.Api/Webhooks/WebhookRetryBackgroundService.cs @@ -1,8 +1,10 @@ +using System.Diagnostics.CodeAnalysis; using MicCheck.Api.Data; using Microsoft.EntityFrameworkCore; namespace MicCheck.Api.Webhooks; +[ExcludeFromCodeCoverage(Justification = "Timer-driven BackgroundService that resolves a DI-scoped concrete MicCheckDbContext and WebhookDispatcher; exercising it cleanly requires a live DI container and DB, which CLAUDE.md disallows (no WebApplicationFactory/InMemory). The retry-eligibility query logic is covered by WebhookRetryTests.")] public class WebhookRetryBackgroundService( IServiceScopeFactory scopeFactory, ILogger logger) : BackgroundService diff --git a/tests/api/MicCheck.Api.Tests.Unit/Audit/AuditLogQueryServiceTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Audit/AuditLogQueryServiceTests.cs new file mode 100644 index 0000000..2de531b --- /dev/null +++ b/tests/api/MicCheck.Api.Tests.Unit/Audit/AuditLogQueryServiceTests.cs @@ -0,0 +1,219 @@ +using MicCheck.Api.Audit; +using MicCheck.Api.Data; +using MicCheck.Api.Tests.Unit.TestSupport; +using MicCheck.Api.Users; +using Moq; +using NUnit.Framework; + +namespace MicCheck.Api.Tests.Unit.Audit; + +[TestFixture] +public class AuditLogQueryServiceTests +{ + private Mock _db = null!; + private List _auditLogs = null!; + private List _users = null!; + private AuditLogQueryService _service = null!; + + private const int OrganizationId = 1; + private const int ProjectId = 1; + private const int EnvironmentId = 1; + + [SetUp] + public void SetUp() + { + _db = new Mock(); + _auditLogs = []; + _db.SetupDbSet(c => c.AuditLogs, _auditLogs); + _users = []; + _db.SetupDbSet(c => c.Users, _users); + + _service = new AuditLogQueryService(_db.Object); + } + + private AuditLog AddLog(string resourceType = "Feature", string action = "created", int? projectId = ProjectId, + int? environmentId = EnvironmentId, int? actorUserId = null, DateTimeOffset? createdAt = null) + { + var log = new AuditLog + { + Id = _auditLogs.Count + 1, + ResourceType = resourceType, + ResourceId = "1", + Action = action, + OrganizationId = OrganizationId, + ProjectId = projectId, + EnvironmentId = environmentId, + ActorUserId = actorUserId, + CreatedAt = createdAt ?? DateTimeOffset.UtcNow + }; + _auditLogs.Add(log); + return log; + } + + [Test] + public async Task WhenListingByOrganization_ThenOnlyLogsForThatOrganizationAreReturned() + { + AddLog(); + _auditLogs.Add(new AuditLog { Id = 2, ResourceType = "Feature", ResourceId = "1", Action = "created", OrganizationId = 999, CreatedAt = DateTimeOffset.UtcNow }); + + var result = await _service.ListByOrganizationAsync(OrganizationId, new AuditLogFilter()); + + Assert.That(result.Total, Is.EqualTo(1)); + Assert.That(result.Items, Has.Count.EqualTo(1)); + } + + [Test] + public async Task WhenListingByProject_ThenOnlyLogsForThatProjectAreReturned() + { + AddLog(projectId: ProjectId); + AddLog(projectId: 999); + + var result = await _service.ListByProjectAsync(ProjectId, new AuditLogFilter()); + + Assert.That(result.Total, Is.EqualTo(1)); + } + + [Test] + public async Task WhenListingByEnvironment_ThenOnlyLogsForThatEnvironmentAreReturned() + { + AddLog(environmentId: EnvironmentId); + AddLog(environmentId: 999); + + var result = await _service.ListByEnvironmentAsync(EnvironmentId, new AuditLogFilter()); + + Assert.That(result.Total, Is.EqualTo(1)); + } + + [Test] + public async Task WhenFilteringByFromDate_ThenOnlyLogsOnOrAfterAreReturned() + { + AddLog(createdAt: DateTimeOffset.UtcNow.AddDays(-10)); + AddLog(createdAt: DateTimeOffset.UtcNow); + + var result = await _service.ListByOrganizationAsync(OrganizationId, new AuditLogFilter(From: DateTimeOffset.UtcNow.AddDays(-1))); + + Assert.That(result.Total, Is.EqualTo(1)); + } + + [Test] + public async Task WhenFilteringByToDate_ThenOnlyLogsOnOrBeforeAreReturned() + { + AddLog(createdAt: DateTimeOffset.UtcNow.AddDays(-10)); + AddLog(createdAt: DateTimeOffset.UtcNow); + + var result = await _service.ListByOrganizationAsync(OrganizationId, new AuditLogFilter(To: DateTimeOffset.UtcNow.AddDays(-1))); + + Assert.That(result.Total, Is.EqualTo(1)); + } + + [Test] + public async Task WhenFilteringByResourceType_ThenOnlyMatchingLogsAreReturned() + { + AddLog(resourceType: "Feature"); + AddLog(resourceType: "Segment"); + + var result = await _service.ListByOrganizationAsync(OrganizationId, new AuditLogFilter(ResourceType: "Segment")); + + Assert.That(result.Total, Is.EqualTo(1)); + Assert.That(result.Items[0].ResourceType, Is.EqualTo("Segment")); + } + + [Test] + public async Task WhenFilteringByAction_ThenOnlyMatchingLogsAreReturned() + { + AddLog(action: "created"); + AddLog(action: "deleted"); + + var result = await _service.ListByOrganizationAsync(OrganizationId, new AuditLogFilter(Action: "deleted")); + + Assert.That(result.Total, Is.EqualTo(1)); + Assert.That(result.Items[0].Action, Is.EqualTo("deleted")); + } + + [Test] + public async Task WhenFilteringByProjectIdOnAnOrganizationQuery_ThenOnlyMatchingLogsAreReturned() + { + AddLog(projectId: ProjectId); + AddLog(projectId: 999); + + var result = await _service.ListByOrganizationAsync(OrganizationId, new AuditLogFilter(ProjectId: ProjectId)); + + Assert.That(result.Total, Is.EqualTo(1)); + } + + [Test] + public async Task WhenFilteringByEnvironmentIdOnAnOrganizationQuery_ThenOnlyMatchingLogsAreReturned() + { + AddLog(environmentId: EnvironmentId); + AddLog(environmentId: 999); + + var result = await _service.ListByOrganizationAsync(OrganizationId, new AuditLogFilter(EnvironmentId: EnvironmentId)); + + Assert.That(result.Total, Is.EqualTo(1)); + } + + [Test] + public async Task WhenFilteringByActorUserId_ThenOnlyMatchingLogsAreReturned() + { + AddLog(actorUserId: 1); + AddLog(actorUserId: 2); + + var result = await _service.ListByOrganizationAsync(OrganizationId, new AuditLogFilter(ActorUserId: 1)); + + Assert.That(result.Total, Is.EqualTo(1)); + } + + [Test] + public async Task WhenPageSizeExceedsMaximum_ThenItIsClampedTo100() + { + for (var i = 0; i < 150; i++) + AddLog(); + + var result = await _service.ListByOrganizationAsync(OrganizationId, new AuditLogFilter(PageSize: 1000)); + + Assert.That(result.Items, Has.Count.EqualTo(100)); + } + + [Test] + public async Task WhenPageSizeIsBelowMinimum_ThenItIsClampedTo1() + { + AddLog(); + AddLog(); + + var result = await _service.ListByOrganizationAsync(OrganizationId, new AuditLogFilter(PageSize: 0)); + + Assert.That(result.Items, Has.Count.EqualTo(1)); + } + + [Test] + public async Task WhenResultsAreOrderedByCreationDate_ThenNewestAppearsFirst() + { + AddLog(createdAt: DateTimeOffset.UtcNow.AddDays(-1)); + var newest = AddLog(createdAt: DateTimeOffset.UtcNow); + + var result = await _service.ListByOrganizationAsync(OrganizationId, new AuditLogFilter()); + + Assert.That(result.Items[0].Id, Is.EqualTo(newest.Id)); + } + + [Test] + public async Task WhenALogHasAnActorUser_ThenTheActorUserNameIsPopulated() + { + _users.Add(new User { Id = 7, Email = "alice@example.com", PasswordHash = "hash", FirstName = "Alice", LastName = "Smith", CreatedAt = DateTimeOffset.UtcNow }); + AddLog(actorUserId: 7); + + var result = await _service.ListByOrganizationAsync(OrganizationId, new AuditLogFilter()); + + Assert.That(result.Items[0].ActorUserName, Is.EqualTo("Alice Smith")); + } + + [Test] + public async Task WhenALogHasNoActorUser_ThenTheActorUserNameIsNull() + { + AddLog(); + + var result = await _service.ListByOrganizationAsync(OrganizationId, new AuditLogFilter()); + + Assert.That(result.Items[0].ActorUserName, Is.Null); + } +} diff --git a/tests/api/MicCheck.Api.Tests.Unit/Audit/AuditLogResponseTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Audit/AuditLogResponseTests.cs new file mode 100644 index 0000000..84c91f6 --- /dev/null +++ b/tests/api/MicCheck.Api.Tests.Unit/Audit/AuditLogResponseTests.cs @@ -0,0 +1,76 @@ +using MicCheck.Api.Audit; +using NUnit.Framework; + +namespace MicCheck.Api.Tests.Unit.Audit; + +[TestFixture] +public class AuditLogResponseTests +{ + [Test] + public void WhenMappingWithoutAnActorUserName_ThenItDefaultsToNull() + { + var log = new AuditLog + { + Id = 1, + ResourceType = "Feature", + ResourceId = "42", + Action = "created", + OrganizationId = 1, + CreatedAt = DateTimeOffset.UtcNow + }; + + var response = AuditLogResponse.From(log); + + Assert.That(response.ActorUserName, Is.Null); + } + + [Test] + public void WhenMappingWithAnActorUserName_ThenItIsIncluded() + { + var log = new AuditLog + { + Id = 1, + ResourceType = "Feature", + ResourceId = "42", + Action = "created", + OrganizationId = 1, + ActorUserId = 7, + CreatedAt = DateTimeOffset.UtcNow + }; + + var response = AuditLogResponse.From(log, "Alice Smith"); + + Assert.That(response.ActorUserId, Is.EqualTo(7)); + Assert.That(response.ActorUserName, Is.EqualTo("Alice Smith")); + } + + [Test] + public void WhenMapping_ThenAllFieldsAreCopiedFromTheLog() + { + var createdAt = DateTimeOffset.UtcNow; + var log = new AuditLog + { + Id = 1, + ResourceType = "FeatureState", + ResourceId = "5", + Action = "updated", + Changes = """{"before":{},"after":{}}""", + OrganizationId = 10, + ProjectId = 20, + EnvironmentId = 30, + CreatedAt = createdAt + }; + + var response = AuditLogResponse.From(log); + + Assert.That(response.Id, Is.EqualTo(1)); + Assert.That(response.ResourceType, Is.EqualTo("FeatureState")); + Assert.That(response.ResourceId, Is.EqualTo("5")); + Assert.That(response.Action, Is.EqualTo("updated")); + Assert.That(response.Changes, Is.EqualTo("""{"before":{},"after":{}}""")); + Assert.That(response.OrganizationId, Is.EqualTo(10)); + Assert.That(response.ProjectId, Is.EqualTo(20)); + Assert.That(response.EnvironmentId, Is.EqualTo(30)); + Assert.That(response.CreatedAt, Is.EqualTo(createdAt)); + } +} diff --git a/tests/api/MicCheck.Api.Tests.Unit/Audit/AuditLogsControllerTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Audit/AuditLogsControllerTests.cs new file mode 100644 index 0000000..0ae3d3f --- /dev/null +++ b/tests/api/MicCheck.Api.Tests.Unit/Audit/AuditLogsControllerTests.cs @@ -0,0 +1,118 @@ +using MicCheck.Api.Audit; +using MicCheck.Api.Common; +using MicCheck.Api.Data; +using MicCheck.Api.Environments; +using MicCheck.Api.Organizations; +using MicCheck.Api.Projects; +using MicCheck.Api.Tests.Unit.TestSupport; +using MicCheck.Api.Users; +using Microsoft.AspNetCore.Mvc; +using Moq; +using NUnit.Framework; +using AppEnvironment = MicCheck.Api.Environments.Environment; + +namespace MicCheck.Api.Tests.Unit.Audit; + +[TestFixture] +public class AuditLogsControllerTests +{ + private Mock _db = null!; + private List _organizations = null!; + private List _projects = null!; + private List _environments = null!; + private List _auditLogs = null!; + private AuditLogsController _controller = null!; + + private const int OrganizationId = 1; + private const int ProjectId = 1; + private const int EnvironmentId = 1; + private const string ApiKey = "env-key"; + + [SetUp] + public void SetUp() + { + _db = new Mock(); + + _organizations = [new Organization { Id = OrganizationId, Name = "Org", CreatedAt = DateTimeOffset.UtcNow }]; + _db.SetupDbSet(c => c.Organizations, _organizations); + + _projects = [new Project { Id = ProjectId, Name = "Project", OrganizationId = OrganizationId, CreatedAt = DateTimeOffset.UtcNow }]; + _db.SetupDbSet(c => c.Projects, _projects); + + _environments = [new AppEnvironment { Id = EnvironmentId, Name = "Production", ApiKey = ApiKey, ProjectId = ProjectId, CreatedAt = DateTimeOffset.UtcNow }]; + _db.SetupDbSet(c => c.Environments, _environments); + + _auditLogs = []; + _db.SetupDbSet(c => c.AuditLogs, _auditLogs); + _db.SetupDbSet(c => c.Users, []); + + var auditServiceMock = new Mock(_db.Object, null!, new MicCheck.Api.Webhooks.WebhookQueue()); + var auditService = auditServiceMock.Object; + + _controller = new AuditLogsController( + new AuditLogQueryService(_db.Object), + new OrganizationService(_db.Object, auditService), + new ProjectService(_db.Object, auditService), + new EnvironmentService(_db.Object, auditService)); + } + + [Test] + public async Task WhenListingByOrganizationThatDoesNotExist_ThenNotFoundIsReturned() + { + var result = await _controller.ListByOrganization(999, new AuditLogFilter(), CancellationToken.None); + + Assert.That(result.Result, Is.InstanceOf()); + } + + [Test] + public async Task WhenListingByOrganizationThatExists_ThenMatchingLogsAreReturned() + { + _auditLogs.Add(new AuditLog { Id = 1, ResourceType = "Feature", ResourceId = "1", Action = "created", OrganizationId = OrganizationId, CreatedAt = DateTimeOffset.UtcNow }); + + var result = await _controller.ListByOrganization(OrganizationId, new AuditLogFilter(), 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 WhenListingByProjectThatDoesNotExist_ThenNotFoundIsReturned() + { + var result = await _controller.ListByProject(999, new AuditLogFilter(), CancellationToken.None); + + Assert.That(result.Result, Is.InstanceOf()); + } + + [Test] + public async Task WhenListingByProjectThatExists_ThenMatchingLogsAreReturned() + { + _auditLogs.Add(new AuditLog { Id = 1, ResourceType = "Feature", ResourceId = "1", Action = "created", OrganizationId = OrganizationId, ProjectId = ProjectId, CreatedAt = DateTimeOffset.UtcNow }); + + var result = await _controller.ListByProject(ProjectId, new AuditLogFilter(), 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 WhenListingByEnvironmentThatDoesNotExist_ThenNotFoundIsReturned() + { + var result = await _controller.ListByEnvironment("missing-key", new AuditLogFilter(), CancellationToken.None); + + Assert.That(result.Result, Is.InstanceOf()); + } + + [Test] + public async Task WhenListingByEnvironmentThatExists_ThenMatchingLogsAreReturned() + { + _auditLogs.Add(new AuditLog { Id = 1, ResourceType = "Feature", ResourceId = "1", Action = "created", OrganizationId = OrganizationId, EnvironmentId = EnvironmentId, CreatedAt = DateTimeOffset.UtcNow }); + + var result = await _controller.ListByEnvironment(ApiKey, new AuditLogFilter(), CancellationToken.None); + + var ok = result.Result as OkObjectResult; + var body = ok!.Value as PaginatedResponse; + Assert.That(body!.Count, Is.EqualTo(1)); + } +} diff --git a/tests/api/MicCheck.Api.Tests.Unit/Audit/AuditServiceTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Audit/AuditServiceTests.cs index a1ddf36..1586220 100755 --- a/tests/api/MicCheck.Api.Tests.Unit/Audit/AuditServiceTests.cs +++ b/tests/api/MicCheck.Api.Tests.Unit/Audit/AuditServiceTests.cs @@ -90,6 +90,85 @@ public class AuditServiceTests Assert.That(log.ActorUserId, Is.Null); } + [Test] + public async Task WhenLoggingWithNoChanges_ThenChangesRemainsNull() + { + await _service.LogAsync("Feature", "1", "created", organizationId: 1); + + var log = _auditLogs.First(); + Assert.That(log.Changes, Is.Null); + } + + [Test] + public async Task WhenLoggingWithChanges_ThenChangesAndMetadataArePersisted() + { + await _service.LogAsync("Feature", "2", "updated", organizationId: 5, projectId: 6, environmentId: 7, changes: "raw-json"); + + var log = _auditLogs.First(); + Assert.That(log.ResourceType, Is.EqualTo("Feature")); + Assert.That(log.ResourceId, Is.EqualTo("2")); + Assert.That(log.Action, Is.EqualTo("updated")); + Assert.That(log.Changes, Is.EqualTo("raw-json")); + Assert.That(log.OrganizationId, Is.EqualTo(5)); + Assert.That(log.ProjectId, Is.EqualTo(6)); + Assert.That(log.EnvironmentId, Is.EqualTo(7)); + } + + [Test] + public async Task WhenLogging_ThenAuditLogCreatedEventIsEnqueued() + { + await _service.LogAsync("Feature", "1", "created", organizationId: 1); + + var events = new List(); + var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(100)); + try + { + await foreach (var e in _queue.ReadAllAsync(cts.Token)) + { + events.Add(e); + break; + } + } + catch (OperationCanceledException) { } + + Assert.That(events, Has.Count.EqualTo(1)); + Assert.That(events[0].EventType, Is.EqualTo(WebhookEventTypes.AuditLogCreated)); + } + + [Test] + public async Task WhenTheHttpContextHasAnAuthenticatedUser_ThenTheActorUserIdIsResolvedFromTheClaim() + { + var identity = new System.Security.Claims.ClaimsIdentity( + [new System.Security.Claims.Claim(System.Security.Claims.ClaimTypes.NameIdentifier, "42")], "TestAuth"); + var httpContext = new Microsoft.AspNetCore.Http.DefaultHttpContext + { + User = new System.Security.Claims.ClaimsPrincipal(identity) + }; + var httpContextAccessor = new Mock(); + httpContextAccessor.Setup(x => x.HttpContext).Returns(httpContext); + var service = new AuditService(_db.Object, httpContextAccessor.Object, _queue); + + await service.RecordAsync("Feature", "1", "created", organizationId: 1); + + Assert.That(_auditLogs.First().ActorUserId, Is.EqualTo(42)); + } + + [Test] + public async Task WhenTheHttpContextHasNoNameIdentifierClaim_ThenTheActorUserIdIsNull() + { + var httpContext = new Microsoft.AspNetCore.Http.DefaultHttpContext + { + User = new System.Security.Claims.ClaimsPrincipal(new System.Security.Claims.ClaimsIdentity()) + }; + var httpContextAccessor = new Mock(); + httpContextAccessor.Setup(x => x.HttpContext).Returns(httpContext); + var service = new AuditService(_db.Object, httpContextAccessor.Object, _queue); + + await service.RecordAsync("Feature", "1", "created", organizationId: 1); + + Assert.That(_auditLogs.First().ActorUserId, Is.Null); + } + [Test] public async Task WhenRecording_ThenAuditLogCreatedEventIsEnqueued() { diff --git a/tests/api/MicCheck.Api.Tests.Unit/Common/Security/ApiKeys/ApiKeyServiceTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Common/Security/ApiKeys/ApiKeyServiceTests.cs new file mode 100644 index 0000000..acaa42a --- /dev/null +++ b/tests/api/MicCheck.Api.Tests.Unit/Common/Security/ApiKeys/ApiKeyServiceTests.cs @@ -0,0 +1,97 @@ +using MicCheck.Api.Common.Security.ApiKeys; +using MicCheck.Api.Data; +using MicCheck.Api.Tests.Unit.TestSupport; +using Moq; +using NUnit.Framework; + +namespace MicCheck.Api.Tests.Unit.Common.Security.ApiKeys; + +[TestFixture] +public class ApiKeyServiceTests +{ + private Mock _db = null!; + private List _apiKeys = null!; + private ApiKeyService _service = null!; + private const int OrganizationId = 1; + + [SetUp] + public void SetUp() + { + _db = new Mock(); + _apiKeys = []; + _db.SetupDbSetWithGeneratedIds(c => c.ApiKeys, _apiKeys); + + _service = new ApiKeyService(_db.Object); + } + + [Test] + public async Task WhenCreatingAnApiKey_ThenTheStoredKeyIsHashedAndTheRawKeyIsReturnedOnce() + { + var result = await _service.CreateAsync(OrganizationId, "CI Key", null); + + Assert.That(result.RawKey, Is.Not.Empty); + Assert.That(result.Key.Key, Is.EqualTo(ApiKeyHasher.Hash(result.RawKey))); + Assert.That(result.Key.Key, Is.Not.EqualTo(result.RawKey)); + } + + [Test] + public async Task WhenCreatingAnApiKey_ThenThePrefixIsTheFirstEightCharactersOfTheRawKey() + { + var result = await _service.CreateAsync(OrganizationId, "CI Key", null); + + Assert.That(result.Key.Prefix, Is.EqualTo(result.RawKey[..8])); + } + + [Test] + public async Task WhenCreatingAnApiKey_ThenItIsPersistedAsActiveForTheGivenOrganization() + { + var expiresAt = DateTimeOffset.UtcNow.AddDays(30); + + var result = await _service.CreateAsync(OrganizationId, "CI Key", expiresAt); + + var persisted = _apiKeys.Single(); + Assert.That(persisted.Id, Is.EqualTo(result.Key.Id)); + Assert.That(persisted.Name, Is.EqualTo("CI Key")); + Assert.That(persisted.OrganizationId, Is.EqualTo(OrganizationId)); + Assert.That(persisted.IsActive, Is.True); + Assert.That(persisted.ExpiresAt, Is.EqualTo(expiresAt)); + } + + [Test] + public async Task WhenListingApiKeysForAnOrganization_ThenOnlyThatOrganizationsKeysAreReturned() + { + await _service.CreateAsync(OrganizationId, "Org 1 Key", null); + await _service.CreateAsync(999, "Org 2 Key", null); + + var keys = await _service.ListAsync(OrganizationId); + + Assert.That(keys, Has.Count.EqualTo(1)); + Assert.That(keys[0].Name, Is.EqualTo("Org 1 Key")); + } + + [Test] + public async Task WhenRevokingAnExistingApiKey_ThenItIsMarkedInactive() + { + var created = await _service.CreateAsync(OrganizationId, "CI Key", null); + + await _service.RevokeAsync(OrganizationId, created.Key.Id); + + Assert.That(_apiKeys.Single().IsActive, Is.False); + } + + [Test] + public async Task WhenRevokingAnApiKeyThatBelongsToADifferentOrganization_ThenItIsNotRevoked() + { + var created = await _service.CreateAsync(OrganizationId, "CI Key", null); + + await _service.RevokeAsync(999, created.Key.Id); + + Assert.That(_apiKeys.Single().IsActive, Is.True); + } + + [Test] + public void WhenRevokingAnApiKeyThatDoesNotExist_ThenNoExceptionIsThrown() + { + Assert.DoesNotThrowAsync(() => _service.RevokeAsync(OrganizationId, 999)); + } +} diff --git a/tests/api/MicCheck.Api.Tests.Unit/Common/Security/Authentication/ApiKeyAuthenticationHandlerTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Common/Security/Authentication/ApiKeyAuthenticationHandlerTests.cs index 92b7bad..0754af1 100755 --- a/tests/api/MicCheck.Api.Tests.Unit/Common/Security/Authentication/ApiKeyAuthenticationHandlerTests.cs +++ b/tests/api/MicCheck.Api.Tests.Unit/Common/Security/Authentication/ApiKeyAuthenticationHandlerTests.cs @@ -75,6 +75,14 @@ public class ApiKeyAuthenticationHandlerTests Assert.That(result.None, Is.True); } + [Test] + public async Task WhenApiKeyValueIsEmpty_ThenAuthenticationFails() + { + var result = await AuthenticateAsync("Api-Key "); + + Assert.That(result.Succeeded, Is.False); + } + [Test] public async Task WhenApiKeyIsInvalid_ThenAuthenticationFails() { diff --git a/tests/api/MicCheck.Api.Tests.Unit/Common/Security/Authentication/EnvironmentKeyAuthenticationHandlerTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Common/Security/Authentication/EnvironmentKeyAuthenticationHandlerTests.cs index 15d9d0c..d597735 100644 --- a/tests/api/MicCheck.Api.Tests.Unit/Common/Security/Authentication/EnvironmentKeyAuthenticationHandlerTests.cs +++ b/tests/api/MicCheck.Api.Tests.Unit/Common/Security/Authentication/EnvironmentKeyAuthenticationHandlerTests.cs @@ -51,6 +51,14 @@ public class EnvironmentKeyAuthenticationHandlerTests Assert.That(result.None, Is.True); } + [Test] + public async Task WhenEnvironmentKeyHeaderIsEmpty_ThenAuthenticationFails() + { + var result = await AuthenticateAsync(""); + + Assert.That(result.Succeeded, Is.False); + } + [Test] public async Task WhenEnvironmentKeyIsInvalid_ThenAuthenticationFails() { diff --git a/tests/api/MicCheck.Api.Tests.Unit/Common/Security/Authorization/AuthServiceTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Common/Security/Authorization/AuthServiceTests.cs index 0c117b4..5a50440 100644 --- a/tests/api/MicCheck.Api.Tests.Unit/Common/Security/Authorization/AuthServiceTests.cs +++ b/tests/api/MicCheck.Api.Tests.Unit/Common/Security/Authorization/AuthServiceTests.cs @@ -216,4 +216,10 @@ public class AuthServiceTests Assert.That(response, Is.Null); } + + [Test] + public void WhenLoggingOutWithATokenThatDoesNotExist_ThenNoExceptionIsThrown() + { + Assert.DoesNotThrowAsync(() => _service.LogoutAsync("unknown-token")); + } } 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")); + } +} 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); + } +} diff --git a/tests/api/MicCheck.Api.Tests.Unit/Identities/AdminIdentityResponseTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Identities/AdminIdentityResponseTests.cs new file mode 100644 index 0000000..d33160f --- /dev/null +++ b/tests/api/MicCheck.Api.Tests.Unit/Identities/AdminIdentityResponseTests.cs @@ -0,0 +1,34 @@ +using MicCheck.Api.Identities; +using NUnit.Framework; + +namespace MicCheck.Api.Tests.Unit.Identities; + +[TestFixture] +public class AdminIdentityResponseTests +{ + [Test] + public void WhenMappingAnIdentityWithNoTraits_ThenTheResponseHasAnEmptyTraitsList() + { + var identity = new Identity { Id = 1, Identifier = "user-1", EnvironmentId = 5, CreatedAt = DateTimeOffset.UtcNow }; + + var response = AdminIdentityResponse.From(identity); + + Assert.That(response.Id, Is.EqualTo(1)); + Assert.That(response.Identifier, Is.EqualTo("user-1")); + Assert.That(response.EnvironmentId, Is.EqualTo(5)); + Assert.That(response.Traits, Is.Empty); + } + + [Test] + public void WhenMappingAnIdentityWithTraits_ThenEachTraitIsMapped() + { + var identity = new Identity { Id = 1, Identifier = "user-1", EnvironmentId = 1, CreatedAt = DateTimeOffset.UtcNow }; + identity.Traits.Add(new IdentityTrait { Key = "plan", Value = "premium" }); + + var response = AdminIdentityResponse.From(identity); + + Assert.That(response.Traits, Has.Count.EqualTo(1)); + Assert.That(response.Traits[0].Key, Is.EqualTo("plan")); + Assert.That(response.Traits[0].Value, Is.EqualTo("premium")); + } +} diff --git a/tests/api/MicCheck.Api.Tests.Unit/Identities/AdminIdentityServiceTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Identities/AdminIdentityServiceTests.cs new file mode 100644 index 0000000..48dcafd --- /dev/null +++ b/tests/api/MicCheck.Api.Tests.Unit/Identities/AdminIdentityServiceTests.cs @@ -0,0 +1,251 @@ +using MicCheck.Api.Data; +using MicCheck.Api.Features; +using MicCheck.Api.Identities; +using MicCheck.Api.Tests.Unit.TestSupport; +using Moq; +using NUnit.Framework; + +namespace MicCheck.Api.Tests.Unit.Identities; + +[TestFixture] +public class AdminIdentityServiceTests +{ + private Mock _db = null!; + private List _identities = null!; + private List _traits = null!; + private List _featureStates = null!; + private AdminIdentityService _service = null!; + private const int EnvironmentId = 1; + + [SetUp] + public void SetUp() + { + _db = new Mock(); + _identities = []; + _db.SetupDbSetWithGeneratedIds(c => c.Identities, _identities); + _traits = []; + var traitsSet = MockDbSetFactory.Create(_traits); + traitsSet.Setup(m => m.Add(It.IsAny())).Callback(trait => + { + _traits.Add(trait); + _identities.FirstOrDefault(i => i.Id == trait.IdentityId)?.Traits.Add(trait); + }); + traitsSet.Setup(m => m.Remove(It.IsAny())).Callback(trait => + { + _traits.Remove(trait); + _identities.FirstOrDefault(i => i.Id == trait.IdentityId)?.Traits.Remove(trait); + }); + _db.Setup(c => c.IdentityTraits).Returns(traitsSet.Object); + _featureStates = []; + _db.SetupDbSetWithGeneratedIds(c => c.FeatureStates, _featureStates); + + _service = new AdminIdentityService(_db.Object); + } + + [Test] + public async Task WhenCreatingAnIdentityWithANewIdentifier_ThenItIsPersisted() + { + var identity = await _service.CreateAsync(EnvironmentId, "user-1"); + + Assert.That(identity, Is.Not.Null); + Assert.That(identity!.Identifier, Is.EqualTo("user-1")); + Assert.That(_identities, Has.Count.EqualTo(1)); + } + + [Test] + public async Task WhenCreatingAnIdentityWithAnIdentifierThatAlreadyExistsInTheEnvironment_ThenNullIsReturned() + { + await _service.CreateAsync(EnvironmentId, "user-1"); + + var result = await _service.CreateAsync(EnvironmentId, "user-1"); + + Assert.That(result, Is.Null); + Assert.That(_identities, Has.Count.EqualTo(1)); + } + + [Test] + public async Task WhenCreatingAnIdentityWithTheSameIdentifierInADifferentEnvironment_ThenItIsPersisted() + { + await _service.CreateAsync(EnvironmentId, "user-1"); + + var result = await _service.CreateAsync(999, "user-1"); + + Assert.That(result, Is.Not.Null); + Assert.That(_identities, Has.Count.EqualTo(2)); + } + + [Test] + public async Task WhenListingIdentitiesForAnEnvironment_ThenOnlyThatEnvironmentsIdentitiesAreReturnedWithTheTotalCount() + { + await _service.CreateAsync(EnvironmentId, "user-1"); + await _service.CreateAsync(EnvironmentId, "user-2"); + await _service.CreateAsync(999, "other-env-user"); + + var result = await _service.ListAsync(EnvironmentId, page: 1, pageSize: 20); + + Assert.That(result.Total, Is.EqualTo(2)); + Assert.That(result.Items, Has.Count.EqualTo(2)); + } + + [Test] + public async Task WhenListingIdentitiesWithPagination_ThenTheSecondPageSkipsTheFirstPageSize() + { + for (var i = 0; i < 5; i++) + await _service.CreateAsync(EnvironmentId, $"user-{i}"); + + var result = await _service.ListAsync(EnvironmentId, page: 2, pageSize: 2); + + Assert.That(result.Total, Is.EqualTo(5)); + Assert.That(result.Items, Has.Count.EqualTo(2)); + Assert.That(result.Items[0].Identifier, Is.EqualTo("user-2")); + } + + [Test] + public async Task WhenFindingAnIdentityByIdInTheCorrectEnvironment_ThenItIsReturned() + { + var identity = await _service.CreateAsync(EnvironmentId, "user-1"); + + var found = await _service.FindByIdAsync(identity!.Id, EnvironmentId); + + Assert.That(found, Is.Not.Null); + } + + [Test] + public async Task WhenFindingAnIdentityByIdInADifferentEnvironment_ThenNullIsReturned() + { + var identity = await _service.CreateAsync(EnvironmentId, "user-1"); + + var found = await _service.FindByIdAsync(identity!.Id, 999); + + Assert.That(found, Is.Null); + } + + [Test] + public async Task WhenUpsertingATraitForAnIdentityThatDoesNotExist_ThenNullIsReturned() + { + var result = await _service.UpsertTraitAsync(999, EnvironmentId, "plan", "premium"); + + Assert.That(result, Is.Null); + } + + [Test] + public async Task WhenUpsertingANewTrait_ThenItIsAddedToTheIdentity() + { + var identity = await _service.CreateAsync(EnvironmentId, "user-1"); + + var response = await _service.UpsertTraitAsync(identity!.Id, EnvironmentId, "plan", "premium"); + + Assert.That(response!.Key, Is.EqualTo("plan")); + Assert.That(response.Value, Is.EqualTo("premium")); + Assert.That(_traits, Has.Count.EqualTo(1)); + } + + [Test] + public async Task WhenUpsertingATraitThatAlreadyExists_ThenItsValueIsUpdatedWithoutDuplication() + { + var identity = await _service.CreateAsync(EnvironmentId, "user-1"); + await _service.UpsertTraitAsync(identity!.Id, EnvironmentId, "plan", "free"); + + await _service.UpsertTraitAsync(identity.Id, EnvironmentId, "plan", "premium"); + + Assert.That(_traits, Has.Count.EqualTo(1)); + Assert.That(_traits[0].Value, Is.EqualTo("premium")); + } + + [Test] + public async Task WhenDeletingATraitFromAnIdentityThatDoesNotExist_ThenFalseIsReturned() + { + var result = await _service.DeleteTraitAsync(999, EnvironmentId, "plan"); + + Assert.That(result, Is.False); + } + + [Test] + public async Task WhenDeletingATraitThatDoesNotExistOnTheIdentity_ThenFalseIsReturned() + { + var identity = await _service.CreateAsync(EnvironmentId, "user-1"); + + var result = await _service.DeleteTraitAsync(identity!.Id, EnvironmentId, "unknown"); + + Assert.That(result, Is.False); + } + + [Test] + public async Task WhenDeletingATraitThatExists_ThenItIsRemovedAndTrueIsReturned() + { + var identity = await _service.CreateAsync(EnvironmentId, "user-1"); + await _service.UpsertTraitAsync(identity!.Id, EnvironmentId, "plan", "premium"); + + var result = await _service.DeleteTraitAsync(identity.Id, EnvironmentId, "plan"); + + Assert.That(result, Is.True); + Assert.That(_traits, Is.Empty); + } + + [Test] + public void WhenDeletingAnIdentityThatDoesNotExist_ThenNoExceptionIsThrown() + { + Assert.DoesNotThrowAsync(() => _service.DeleteAsync(999)); + } + + [Test] + public async Task WhenDeletingAnIdentity_ThenItIsRemovedFromTheDatabase() + { + var identity = await _service.CreateAsync(EnvironmentId, "user-1"); + + await _service.DeleteAsync(identity!.Id); + + Assert.That(_identities.Any(i => i.Id == identity.Id), Is.False); + } + + [Test] + public async Task WhenGettingFeatureStatesForAnIdentity_ThenOnlyThatIdentitysStatesInTheEnvironmentAreReturned() + { + _featureStates.Add(new FeatureState { Id = 1, FeatureId = 1, EnvironmentId = EnvironmentId, IdentityId = 1, CreatedAt = DateTimeOffset.UtcNow, UpdatedAt = DateTimeOffset.UtcNow }); + _featureStates.Add(new FeatureState { Id = 2, FeatureId = 2, EnvironmentId = EnvironmentId, IdentityId = 2, CreatedAt = DateTimeOffset.UtcNow, UpdatedAt = DateTimeOffset.UtcNow }); + + var states = await _service.GetFeatureStatesAsync(1, EnvironmentId); + + Assert.That(states, Has.Count.EqualTo(1)); + Assert.That(states[0].FeatureId, Is.EqualTo(1)); + } + + [Test] + public async Task WhenSettingAFeatureStateThatDoesNotExist_ThenANewOverrideIsCreated() + { + var state = await _service.SetFeatureStateAsync(1, EnvironmentId, featureId: 1, enabled: true, value: "on"); + + Assert.That(state.IdentityId, Is.EqualTo(1)); + Assert.That(state.Enabled, Is.True); + Assert.That(_featureStates, Has.Count.EqualTo(1)); + } + + [Test] + public async Task WhenSettingAFeatureStateThatAlreadyExists_ThenItIsUpdatedAndVersionIncremented() + { + var created = await _service.SetFeatureStateAsync(1, EnvironmentId, featureId: 1, enabled: false, value: "off"); + var versionBeforeUpdate = created.Version; + + var updated = await _service.SetFeatureStateAsync(1, EnvironmentId, featureId: 1, enabled: true, value: "on"); + + Assert.That(_featureStates, Has.Count.EqualTo(1)); + Assert.That(updated.Enabled, Is.True); + Assert.That(updated.Version, Is.EqualTo(versionBeforeUpdate + 1)); + } + + [Test] + public void WhenDeletingAFeatureStateThatDoesNotExist_ThenNoExceptionIsThrown() + { + Assert.DoesNotThrowAsync(() => _service.DeleteFeatureStateAsync(1, EnvironmentId, featureId: 1)); + } + + [Test] + public async Task WhenDeletingAFeatureStateThatExists_ThenItIsRemoved() + { + await _service.SetFeatureStateAsync(1, EnvironmentId, featureId: 1, enabled: true, value: "on"); + + await _service.DeleteFeatureStateAsync(1, EnvironmentId, featureId: 1); + + Assert.That(_featureStates, Is.Empty); + } +} diff --git a/tests/api/MicCheck.Api.Tests.Unit/Identities/IdentitiesControllerTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Identities/IdentitiesControllerTests.cs new file mode 100644 index 0000000..7b66443 --- /dev/null +++ b/tests/api/MicCheck.Api.Tests.Unit/Identities/IdentitiesControllerTests.cs @@ -0,0 +1,92 @@ +using System.Security.Claims; +using MicCheck.Api.Data; +using MicCheck.Api.Environments; +using MicCheck.Api.Features; +using MicCheck.Api.Identities; +using MicCheck.Api.Projects; +using MicCheck.Api.Segments; +using MicCheck.Api.Tests.Unit.TestSupport; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Caching.Memory; +using Moq; +using NUnit.Framework; +using AppEnvironment = MicCheck.Api.Environments.Environment; + +namespace MicCheck.Api.Tests.Unit.Identities; + +[TestFixture] +public class IdentitiesControllerTests +{ + private Mock _db = null!; + private List _identities = null!; + private IdentitiesController _controller = null!; + private const int EnvironmentId = 1; + private const int ProjectId = 1; + + [SetUp] + public void SetUp() + { + _db = new Mock(); + + _db.SetupDbSet(c => c.Projects, [ + new Project { Id = ProjectId, Name = "Test Project", OrganizationId = 1, CreatedAt = DateTimeOffset.UtcNow } + ]); + var environments = new List { + new() { Id = EnvironmentId, Name = "Test Env", ApiKey = "test-env-key", ProjectId = ProjectId, CreatedAt = DateTimeOffset.UtcNow } + }; + var environmentsSet = MockDbSetFactory.Create(environments); + environmentsSet.Setup(m => m.FindAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((object[] keys, CancellationToken _) => environments.FirstOrDefault(e => e.Id == (int)keys[0])); + _db.Setup(c => c.Environments).Returns(environmentsSet.Object); + _db.SetupDbSet(c => c.Features, [ + new Feature { Id = 1, Name = "dark_mode", ProjectId = ProjectId, CreatedAt = DateTimeOffset.UtcNow } + ]); + _db.SetupDbSet(c => c.FeatureStates, [ + new FeatureState { FeatureId = 1, EnvironmentId = EnvironmentId, Enabled = true, CreatedAt = DateTimeOffset.UtcNow, UpdatedAt = DateTimeOffset.UtcNow } + ]); + _identities = []; + _db.SetupDbSetWithGeneratedIds(c => c.Identities, _identities); + _db.SetupDbSet(c => c.IdentityTraits, new List()); + _db.SetupDbSetWithGeneratedIds(c => c.Segments, new List()); + _db.SetupDbSet(c => c.SegmentRules, new List()); + _db.SetupDbSet(c => c.SegmentConditions, new List()); + _db.SetupDbSetWithGeneratedIds(c => c.FeatureSegments, new List()); + + var meterFactory = new Mock(); + meterFactory.Setup(f => f.Create(It.IsAny())) + .Returns(new System.Diagnostics.Metrics.Meter("test")); + var usageMetrics = new FeatureUsageMetrics(meterFactory.Object); + var evaluationService = new FeatureEvaluationService(_db.Object, new SegmentEvaluator(), new FlagCache(new MemoryCache(new MemoryCacheOptions())), usageMetrics); + var resolutionService = new IdentityResolutionService(_db.Object); + + _controller = new IdentitiesController(evaluationService, resolutionService); + + var identity = new ClaimsIdentity([new Claim("EnvironmentId", EnvironmentId.ToString())], "EnvironmentKey"); + _controller.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal(identity) } + }; + } + + [Test] + public async Task WhenIdentifierIsMissing_ThenBadRequestIsReturned() + { + var result = await _controller.GetByIdentifier("", CancellationToken.None); + + Assert.That(result.Result, Is.InstanceOf()); + } + + [Test] + public async Task WhenGettingByIdentifier_ThenFlagsAndTraitsAreReturnedForTheResolvedIdentity() + { + var result = await _controller.GetByIdentifier("user-1", CancellationToken.None); + + var ok = result.Result as OkObjectResult; + Assert.That(ok, Is.Not.Null); + var response = (IdentityResponse)ok!.Value!; + Assert.That(response.Flags, Has.Count.EqualTo(1)); + Assert.That(response.Flags[0].Feature.Name, Is.EqualTo("dark_mode")); + Assert.That(_identities.Any(i => i.Identifier == "user-1"), Is.True); + } +} diff --git a/tests/api/MicCheck.Api.Tests.Unit/Identities/TraitInputTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Identities/TraitInputTests.cs new file mode 100644 index 0000000..d148e62 --- /dev/null +++ b/tests/api/MicCheck.Api.Tests.Unit/Identities/TraitInputTests.cs @@ -0,0 +1,125 @@ +using System.Text.Json; +using MicCheck.Api.Identities; +using NUnit.Framework; + +namespace MicCheck.Api.Tests.Unit.Identities; + +[TestFixture] +public class TraitInputTests +{ + private static JsonElement ParseElement(string json) => JsonDocument.Parse(json).RootElement; + + [Test] + public void WhenTraitValueIsAJsonString_ThenGetStringValueReturnsTheString() + { + var trait = new TraitInput("plan", ParseElement("\"premium\"")); + + Assert.That(trait.GetStringValue(), Is.EqualTo("premium")); + Assert.That(trait.GetValueType(), Is.EqualTo(TraitValueType.String)); + } + + [Test] + public void WhenTraitValueIsJsonTrue_ThenGetStringValueReturnsTrueAndTypeIsBoolean() + { + var trait = new TraitInput("beta", ParseElement("true")); + + Assert.That(trait.GetStringValue(), Is.EqualTo("true")); + Assert.That(trait.GetValueType(), Is.EqualTo(TraitValueType.Boolean)); + } + + [Test] + public void WhenTraitValueIsJsonFalse_ThenGetStringValueReturnsFalseAndTypeIsBoolean() + { + var trait = new TraitInput("beta", ParseElement("false")); + + Assert.That(trait.GetStringValue(), Is.EqualTo("false")); + Assert.That(trait.GetValueType(), Is.EqualTo(TraitValueType.Boolean)); + } + + [Test] + public void WhenTraitValueIsJsonNull_ThenGetStringValueReturnsEmptyString() + { + var trait = new TraitInput("plan", ParseElement("null")); + + Assert.That(trait.GetStringValue(), Is.EqualTo(string.Empty)); + } + + [Test] + public void WhenTraitValueIsAJsonInteger_ThenTypeIsInteger() + { + var trait = new TraitInput("age", ParseElement("42")); + + Assert.That(trait.GetStringValue(), Is.EqualTo("42")); + Assert.That(trait.GetValueType(), Is.EqualTo(TraitValueType.Integer)); + } + + [Test] + public void WhenTraitValueIsAJsonFloat_ThenTypeIsFloat() + { + var trait = new TraitInput("score", ParseElement("4.5")); + + Assert.That(trait.GetStringValue(), Is.EqualTo("4.5")); + Assert.That(trait.GetValueType(), Is.EqualTo(TraitValueType.Float)); + } + + [Test] + public void WhenTraitValueIsAJsonArray_ThenGetStringValueFallsBackToToString() + { + var element = ParseElement("[1,2,3]"); + var trait = new TraitInput("list", element); + + Assert.That(trait.GetStringValue(), Is.EqualTo(element.ToString())); + Assert.That(trait.GetValueType(), Is.EqualTo(TraitValueType.String)); + } + + [Test] + public void WhenTraitValueIsNull_ThenGetStringValueReturnsEmptyStringAndTypeIsString() + { + var trait = new TraitInput("plan", null); + + Assert.That(trait.GetStringValue(), Is.EqualTo(string.Empty)); + Assert.That(trait.GetValueType(), Is.EqualTo(TraitValueType.String)); + } + + [Test] + public void WhenTraitValueIsAPlainBool_ThenTypeIsBoolean() + { + var trait = new TraitInput("beta", true); + + Assert.That(trait.GetStringValue(), Is.EqualTo("True")); + Assert.That(trait.GetValueType(), Is.EqualTo(TraitValueType.Boolean)); + } + + [Test] + public void WhenTraitValueIsAPlainInt_ThenTypeIsInteger() + { + var trait = new TraitInput("age", 42); + + Assert.That(trait.GetValueType(), Is.EqualTo(TraitValueType.Integer)); + } + + [Test] + public void WhenTraitValueIsAPlainLong_ThenTypeIsInteger() + { + var trait = new TraitInput("age", 42L); + + Assert.That(trait.GetValueType(), Is.EqualTo(TraitValueType.Integer)); + } + + [Test] + public void WhenTraitValueIsAPlainDouble_ThenTypeIsFloat() + { + var trait = new TraitInput("score", 4.5d); + + Assert.That(trait.GetValueType(), Is.EqualTo(TraitValueType.Float)); + } + + [Test] + public void WhenTraitValueIsAPlainString_ThenTypeIsString() + { + var trait = new TraitInput("plan", "premium"); + + Assert.That(trait.GetStringValue(), Is.EqualTo("premium")); + Assert.That(trait.GetValueType(), Is.EqualTo(TraitValueType.String)); + } +} diff --git a/tests/api/MicCheck.Api.Tests.Unit/Organizations/CreateOrganizationRequestValidatorTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Organizations/CreateOrganizationRequestValidatorTests.cs new file mode 100644 index 0000000..6deca82 --- /dev/null +++ b/tests/api/MicCheck.Api.Tests.Unit/Organizations/CreateOrganizationRequestValidatorTests.cs @@ -0,0 +1,103 @@ +using MicCheck.Api.Organizations; +using NUnit.Framework; + +namespace MicCheck.Api.Tests.Unit.Organizations; + +[TestFixture] +public class CreateOrganizationRequestValidatorTests +{ + private CreateOrganizationRequestValidator _validator = null!; + + [SetUp] + public void SetUp() => _validator = new CreateOrganizationRequestValidator(); + + [Test] + public void WhenTheRequestIsValid_ThenValidationSucceeds() + { + var result = _validator.Validate(new CreateOrganizationRequest("My Org")); + + Assert.That(result.IsValid, Is.True); + } + + [Test] + public void WhenNameIsEmpty_ThenValidationFails() + { + var result = _validator.Validate(new CreateOrganizationRequest("")); + + Assert.That(result.IsValid, Is.False); + } + + [Test] + public void WhenNameExceedsMaximumLength_ThenValidationFails() + { + var result = _validator.Validate(new CreateOrganizationRequest(new string('a', 201))); + + Assert.That(result.IsValid, Is.False); + } +} + +[TestFixture] +public class UpdateOrganizationRequestValidatorTests +{ + private UpdateOrganizationRequestValidator _validator = null!; + + [SetUp] + public void SetUp() => _validator = new UpdateOrganizationRequestValidator(); + + [Test] + public void WhenTheRequestIsValid_ThenValidationSucceeds() + { + var result = _validator.Validate(new UpdateOrganizationRequest("Renamed")); + + Assert.That(result.IsValid, Is.True); + } + + [Test] + public void WhenNameIsEmpty_ThenValidationFails() + { + var result = _validator.Validate(new UpdateOrganizationRequest("")); + + Assert.That(result.IsValid, Is.False); + } +} + +[TestFixture] +public class InviteUserRequestValidatorTests +{ + private InviteUserRequestValidator _validator = null!; + + [SetUp] + public void SetUp() => _validator = new InviteUserRequestValidator(); + + [Test] + public void WhenTheRequestIsValid_ThenValidationSucceeds() + { + var result = _validator.Validate(new InviteUserRequest(1, "Admin")); + + Assert.That(result.IsValid, Is.True); + } + + [Test] + public void WhenUserIdIsZeroOrLess_ThenValidationFails() + { + var result = _validator.Validate(new InviteUserRequest(0, "User")); + + Assert.That(result.IsValid, Is.False); + } + + [Test] + public void WhenRoleIsEmpty_ThenValidationFails() + { + var result = _validator.Validate(new InviteUserRequest(1, "")); + + Assert.That(result.IsValid, Is.False); + } + + [Test] + public void WhenRoleIsNotAValidOrganizationRole_ThenValidationFails() + { + var result = _validator.Validate(new InviteUserRequest(1, "SuperAdmin")); + + Assert.That(result.IsValid, Is.False); + } +} diff --git a/tests/api/MicCheck.Api.Tests.Unit/Organizations/OrganizationResponseTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Organizations/OrganizationResponseTests.cs new file mode 100644 index 0000000..f892c75 --- /dev/null +++ b/tests/api/MicCheck.Api.Tests.Unit/Organizations/OrganizationResponseTests.cs @@ -0,0 +1,58 @@ +using MicCheck.Api.Organizations; +using MicCheck.Api.Users; +using NUnit.Framework; + +namespace MicCheck.Api.Tests.Unit.Organizations; + +[TestFixture] +public class OrganizationResponseTests +{ + [Test] + public void WhenMappingAnOrganizationWithoutSpecifyingPrimary_ThenIsPrimaryDefaultsToFalse() + { + var org = new Organization { Id = 1, Name = "My Org", CreatedAt = DateTimeOffset.UtcNow }; + + var response = OrganizationResponse.From(org); + + Assert.That(response.Id, Is.EqualTo(1)); + Assert.That(response.Name, Is.EqualTo("My Org")); + Assert.That(response.IsPrimary, Is.False); + } + + [Test] + public void WhenMappingAnOrganizationAsPrimary_ThenIsPrimaryIsTrue() + { + var org = new Organization { Id = 1, Name = "My Org", CreatedAt = DateTimeOffset.UtcNow }; + + var response = OrganizationResponse.From(org, isPrimary: true); + + Assert.That(response.IsPrimary, Is.True); + } +} + +[TestFixture] +public class OrganizationMemberResponseTests +{ + [Test] + public void WhenMappingAMember_ThenFieldsAreSourcedFromTheAssociatedUser() + { + var user = new User + { + Id = 1, + Email = "alice@example.com", + PasswordHash = "hashed", + FirstName = "Alice", + LastName = "Smith", + CreatedAt = DateTimeOffset.UtcNow + }; + var member = new OrganizationUser { OrganizationId = 1, UserId = 1, Role = OrganizationRole.Admin, User = user }; + + var response = OrganizationMemberResponse.From(member); + + Assert.That(response.UserId, Is.EqualTo(1)); + Assert.That(response.FirstName, Is.EqualTo("Alice")); + Assert.That(response.LastName, Is.EqualTo("Smith")); + Assert.That(response.Email, Is.EqualTo("alice@example.com")); + Assert.That(response.Role, Is.EqualTo("Admin")); + } +} diff --git a/tests/api/MicCheck.Api.Tests.Unit/Organizations/OrganizationServiceTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Organizations/OrganizationServiceTests.cs new file mode 100644 index 0000000..023fe5d --- /dev/null +++ b/tests/api/MicCheck.Api.Tests.Unit/Organizations/OrganizationServiceTests.cs @@ -0,0 +1,339 @@ +using MicCheck.Api.Audit; +using MicCheck.Api.Data; +using MicCheck.Api.Organizations; +using MicCheck.Api.Tests.Unit.TestSupport; +using MicCheck.Api.Users; +using Moq; +using NUnit.Framework; + +namespace MicCheck.Api.Tests.Unit.Organizations; + +[TestFixture] +public class OrganizationServiceTests +{ + private Mock _db = null!; + private List _organizations = null!; + private List _members = null!; + private List _users = null!; + private OrganizationService _service = null!; + + [SetUp] + public void SetUp() + { + _db = new Mock(); + _organizations = []; + _members = []; + var organizationsSet = MockDbSetFactory.Create(_organizations); + organizationsSet.Setup(m => m.Add(It.IsAny())).Callback(org => + { + typeof(Organization).GetProperty(nameof(Organization.Id))!.SetValue(org, _organizations.Count + 1); + _organizations.Add(org); + // Mimics EF Core's cascade-insert and FK fixup of a loaded, mapped collection navigation on SaveChanges. + foreach (var member in org.Members) + { + typeof(OrganizationUser).GetProperty(nameof(OrganizationUser.OrganizationId))!.SetValue(member, org.Id); + _members.Add(member); + } + }); + _db.Setup(c => c.Organizations).Returns(organizationsSet.Object); + _db.SetupDbSet(c => c.OrganizationUsers, _members); + _users = []; + _db.SetupDbSet(c => c.Users, _users); + + 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); + + _service = new OrganizationService(_db.Object, auditService.Object); + } + + [Test] + public async Task WhenCreatingAnOrganization_ThenItIsPersistedWithTheCreatorAsAdmin() + { + var org = await _service.CreateAsync("My Org", creatorUserId: 1); + + Assert.That(org.Name, Is.EqualTo("My Org")); + Assert.That(_members.Single().UserId, Is.EqualTo(1)); + Assert.That(_members.Single().Role, Is.EqualTo(OrganizationRole.Admin)); + } + + [Test] + public async Task WhenCreatingAUsersFirstOrganization_ThenItIsMarkedAsPrimary() + { + var org = await _service.CreateAsync("First Org", creatorUserId: 1); + + Assert.That(_members.Single(m => m.OrganizationId == org.Id).IsPrimary, Is.True); + } + + [Test] + public async Task WhenCreatingASecondOrganizationForAUser_ThenItIsNotMarkedAsPrimary() + { + await _service.CreateAsync("First Org", creatorUserId: 1); + + var second = await _service.CreateAsync("Second Org", creatorUserId: 1); + + Assert.That(_members.Single(m => m.OrganizationId == second.Id).IsPrimary, Is.False); + } + + [Test] + public async Task WhenListingOrganizationsForAUser_ThenOnlyThatUsersOrganizationsAreReturnedWithPrimaryFlag() + { + var org = await _service.CreateAsync("My Org", creatorUserId: 1); + await _service.CreateAsync("Other User Org", creatorUserId: 2); + + var results = await _service.ListForUserAsync(1); + + Assert.That(results, Has.Count.EqualTo(1)); + Assert.That(results[0].Org.Id, Is.EqualTo(org.Id)); + Assert.That(results[0].IsPrimary, Is.True); + } + + [Test] + public async Task WhenFindingAnOrganizationByIdThatExists_ThenItIsReturned() + { + var org = await _service.CreateAsync("My Org", creatorUserId: 1); + + var found = await _service.FindByIdAsync(org.Id); + + Assert.That(found, Is.Not.Null); + } + + [Test] + public async Task WhenFindingAnOrganizationByIdThatDoesNotExist_ThenNullIsReturned() + { + var found = await _service.FindByIdAsync(999); + + Assert.That(found, Is.Null); + } + + [Test] + public void WhenSettingPrimaryForAUserThatIsNotAMember_ThenKeyNotFoundExceptionIsThrown() + { + Assert.ThrowsAsync(() => _service.SetPrimaryAsync(999, 1)); + } + + [Test] + public void WhenUpdatingAnOrganizationThatDoesNotExist_ThenKeyNotFoundExceptionIsThrown() + { + Assert.ThrowsAsync(() => _service.UpdateAsync(999, "renamed")); + } + + [Test] + public async Task WhenUpdatingAnOrganization_ThenTheNameIsChanged() + { + var org = await _service.CreateAsync("Old Name", creatorUserId: 1); + + var updated = await _service.UpdateAsync(org.Id, "New Name"); + + Assert.That(updated.Name, Is.EqualTo("New Name")); + } + + [Test] + public void WhenDeletingAnOrganizationThatDoesNotExist_ThenNoExceptionIsThrown() + { + Assert.DoesNotThrowAsync(() => _service.DeleteAsync(999)); + } + + [Test] + public async Task WhenDeletingAnOrganization_ThenItIsRemoved() + { + var org = await _service.CreateAsync("My Org", creatorUserId: 1); + + await _service.DeleteAsync(org.Id); + + Assert.That(_organizations.Any(o => o.Id == org.Id), Is.False); + } + + [Test] + public async Task WhenListingMembers_ThenOnlyThatOrganizationsMembersAreReturned() + { + var org = await _service.CreateAsync("My Org", creatorUserId: 1); + await _service.CreateAsync("Other Org", creatorUserId: 2); + + var members = await _service.ListMembersAsync(org.Id); + + Assert.That(members, Has.Count.EqualTo(1)); + Assert.That(members[0].UserId, Is.EqualTo(1)); + } + + [Test] + public async Task WhenInvitingAUserWhoIsNotYetAMember_ThenTheyAreAddedWithTheGivenRole() + { + var org = await _service.CreateAsync("My Org", creatorUserId: 1); + + await _service.InviteUserAsync(org.Id, userId: 2, OrganizationRole.User); + + Assert.That(_members.Single(m => m.UserId == 2).Role, Is.EqualTo(OrganizationRole.User)); + } + + [Test] + public async Task WhenInvitingAUserWhoIsAlreadyAMember_ThenTheirRoleIsUpdatedWithoutDuplication() + { + var org = await _service.CreateAsync("My Org", creatorUserId: 1); + await _service.InviteUserAsync(org.Id, userId: 2, OrganizationRole.User); + + await _service.InviteUserAsync(org.Id, userId: 2, OrganizationRole.Admin); + + Assert.That(_members.Count(m => m.UserId == 2), Is.EqualTo(1)); + Assert.That(_members.Single(m => m.UserId == 2).Role, Is.EqualTo(OrganizationRole.Admin)); + } + + [Test] + public void WhenRemovingAMemberThatDoesNotExist_ThenNoExceptionIsThrown() + { + Assert.DoesNotThrowAsync(() => _service.RemoveMemberAsync(1, 999)); + } + + [Test] + public async Task WhenRemovingAMember_ThenTheyAreNoLongerListed() + { + var org = await _service.CreateAsync("My Org", creatorUserId: 1); + await _service.InviteUserAsync(org.Id, userId: 2, OrganizationRole.User); + + await _service.RemoveMemberAsync(org.Id, 2); + + Assert.That(_members.Any(m => m.UserId == 2), Is.False); + } + + [Test] + public void WhenGettingAnInviteTokenForAnOrganizationThatDoesNotExist_ThenKeyNotFoundExceptionIsThrown() + { + Assert.ThrowsAsync(() => _service.GetOrCreateInviteTokenAsync(999)); + } + + [Test] + public async Task WhenGettingAnInviteTokenForTheFirstTime_ThenANewTokenIsGeneratedAndPersisted() + { + var org = await _service.CreateAsync("My Org", creatorUserId: 1); + + var token = await _service.GetOrCreateInviteTokenAsync(org.Id); + + Assert.That(token, Is.Not.Null.And.Not.Empty); + Assert.That(_organizations.Single(o => o.Id == org.Id).InviteToken, Is.EqualTo(token)); + } + + [Test] + public async Task WhenGettingAnInviteTokenASecondTime_ThenTheSameTokenIsReturned() + { + var org = await _service.CreateAsync("My Org", creatorUserId: 1); + var first = await _service.GetOrCreateInviteTokenAsync(org.Id); + + var second = await _service.GetOrCreateInviteTokenAsync(org.Id); + + Assert.That(second, Is.EqualTo(first)); + } + + [Test] + public void WhenRegeneratingAnInviteTokenForAnOrganizationThatDoesNotExist_ThenKeyNotFoundExceptionIsThrown() + { + Assert.ThrowsAsync(() => _service.RegenerateInviteTokenAsync(999)); + } + + [Test] + public async Task WhenRegeneratingAnInviteToken_ThenANewTokenReplacesTheOld() + { + var org = await _service.CreateAsync("My Org", creatorUserId: 1); + var first = await _service.GetOrCreateInviteTokenAsync(org.Id); + + var regenerated = await _service.RegenerateInviteTokenAsync(org.Id); + + Assert.That(regenerated, Is.Not.EqualTo(first)); + } + + [Test] + public async Task WhenFindingAnOrganizationByAValidInviteToken_ThenItIsReturned() + { + var org = await _service.CreateAsync("My Org", creatorUserId: 1); + var token = await _service.GetOrCreateInviteTokenAsync(org.Id); + + var found = await _service.FindByInviteTokenAsync(token); + + Assert.That(found, Is.Not.Null); + Assert.That(found!.Id, Is.EqualTo(org.Id)); + } + + [Test] + public async Task WhenFindingAnOrganizationByAnInvalidInviteToken_ThenNullIsReturned() + { + var found = await _service.FindByInviteTokenAsync("not-a-real-token"); + + Assert.That(found, Is.Null); + } + + private User AddUser(int id, string email, bool isActive = true) => new() + { + Id = id, + Email = email, + PasswordHash = "hashed", + FirstName = "Test", + LastName = "User", + IsActive = isActive, + CreatedAt = DateTimeOffset.UtcNow + }; + + [Test] + public async Task WhenInvitingByEmailForAUserThatDoesNotExist_ThenTheResultIndicatesFailure() + { + var org = await _service.CreateAsync("My Org", creatorUserId: 1); + + var results = await _service.InviteUsersByEmailAsync(org.Id, [new InviteByEmailEntry("nobody@example.com", "User")]); + + Assert.That(results[0].Success, Is.False); + Assert.That(results[0].Error, Is.Not.Null); + } + + [Test] + public async Task WhenInvitingByEmailWithAnInvalidRole_ThenTheResultIndicatesFailure() + { + var org = await _service.CreateAsync("My Org", creatorUserId: 1); + _users.Add(AddUser(2, "bob@example.com")); + + var results = await _service.InviteUsersByEmailAsync(org.Id, [new InviteByEmailEntry("bob@example.com", "NotARole")]); + + Assert.That(results[0].Success, Is.False); + } + + [Test] + public async Task WhenInvitingByEmailForAnInactiveUser_ThenTheResultIndicatesFailure() + { + var org = await _service.CreateAsync("My Org", creatorUserId: 1); + _users.Add(AddUser(2, "bob@example.com", isActive: false)); + + var results = await _service.InviteUsersByEmailAsync(org.Id, [new InviteByEmailEntry("bob@example.com", "User")]); + + Assert.That(results[0].Success, Is.False); + } + + [Test] + public async Task WhenInvitingByEmailForAValidActiveUser_ThenTheyAreAddedAsAMemberAndSuccessIsReturned() + { + var org = await _service.CreateAsync("My Org", creatorUserId: 1); + _users.Add(AddUser(2, "bob@example.com")); + + var results = await _service.InviteUsersByEmailAsync(org.Id, [new InviteByEmailEntry(" BOB@Example.com ", "Admin")]); + + Assert.That(results[0].Success, Is.True); + Assert.That(_members.Single(m => m.UserId == 2).Role, Is.EqualTo(OrganizationRole.Admin)); + } + + [Test] + public void WhenAcceptingAnInviteWithAnInvalidToken_ThenKeyNotFoundExceptionIsThrown() + { + Assert.ThrowsAsync(() => _service.AcceptInviteAsync("not-a-real-token", 2)); + } + + [Test] + public async Task WhenAcceptingAValidInvite_ThenTheUserIsAddedAsAMemberWithUserRole() + { + var org = await _service.CreateAsync("My Org", creatorUserId: 1); + var token = await _service.GetOrCreateInviteTokenAsync(org.Id); + + await _service.AcceptInviteAsync(token, userId: 2); + + Assert.That(_members.Single(m => m.UserId == 2).Role, Is.EqualTo(OrganizationRole.User)); + } +} diff --git a/tests/api/MicCheck.Api.Tests.Unit/Organizations/OrganizationsControllerTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Organizations/OrganizationsControllerTests.cs new file mode 100644 index 0000000..6c7956a --- /dev/null +++ b/tests/api/MicCheck.Api.Tests.Unit/Organizations/OrganizationsControllerTests.cs @@ -0,0 +1,486 @@ +using System.Security.Claims; +using MicCheck.Api.Audit; +using MicCheck.Api.Common; +using MicCheck.Api.Data; +using MicCheck.Api.Organizations; +using MicCheck.Api.Tests.Unit.TestSupport; +using MicCheck.Api.Users; +using MicCheck.Api.Webhooks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Moq; +using NUnit.Framework; + +namespace MicCheck.Api.Tests.Unit.Organizations; + +[TestFixture] +public class OrganizationsControllerTests +{ + private Mock _db = null!; + private List _organizations = null!; + private List _members = null!; + private List _users = null!; + private List _webhooks = null!; + private OrganizationsController _controller = null!; + private const int UserId = 1; + + [SetUp] + public void SetUp() + { + _db = new Mock(); + _organizations = []; + _members = []; + var organizationsSet = MockDbSetFactory.Create(_organizations); + organizationsSet.Setup(m => m.Add(It.IsAny())).Callback(org => + { + typeof(Organization).GetProperty(nameof(Organization.Id))!.SetValue(org, _organizations.Count + 1); + _organizations.Add(org); + foreach (var member in org.Members) + { + typeof(OrganizationUser).GetProperty(nameof(OrganizationUser.OrganizationId))!.SetValue(member, org.Id); + _members.Add(member); + } + }); + _db.Setup(c => c.Organizations).Returns(organizationsSet.Object); + _db.SetupDbSet(c => c.OrganizationUsers, _members); + _users = []; + _db.SetupDbSet(c => c.Users, _users); + _webhooks = []; + _db.SetupDbSetWithGeneratedIds(c => c.Webhooks, _webhooks); + + var webhookQueue = new 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); + + _controller = new OrganizationsController(new OrganizationService(_db.Object, auditService.Object), new WebhookService(_db.Object)); + + var identity = new ClaimsIdentity([new Claim(ClaimTypes.NameIdentifier, UserId.ToString())], "TestAuth"); + _controller.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal(identity) } + }; + } + + private void Unauthenticate() => + _controller.ControllerContext.HttpContext.User = new ClaimsPrincipal(new ClaimsIdentity()); + + [Test] + public async Task WhenListingWithoutAuthentication_ThenUnauthorizedIsReturned() + { + Unauthenticate(); + + var result = await _controller.List(); + + Assert.That(result.Result, Is.InstanceOf()); + } + + [Test] + public async Task WhenListingOrganizationsForTheCurrentUser_ThenTheyAreReturnedInAPage() + { + await _controller.Create(new CreateOrganizationRequest("Org A"), CancellationToken.None); + await _controller.Create(new CreateOrganizationRequest("Org B"), CancellationToken.None); + + var result = await _controller.List(); + + var ok = result.Result as OkObjectResult; + var page = (PaginatedResponse)ok!.Value!; + Assert.That(page.Count, Is.EqualTo(2)); + } + + [Test] + public async Task WhenCreatingWithoutAuthentication_ThenUnauthorizedIsReturned() + { + Unauthenticate(); + + var result = await _controller.Create(new CreateOrganizationRequest("My Org"), CancellationToken.None); + + Assert.That(result.Result, Is.InstanceOf()); + } + + [Test] + public async Task WhenCreatingAnOrganization_ThenACreatedResultWithTheOrganizationIsReturned() + { + var result = await _controller.Create(new CreateOrganizationRequest("My Org"), CancellationToken.None); + + var created = result.Result as CreatedAtActionResult; + Assert.That(created, Is.Not.Null); + Assert.That(((OrganizationResponse)created!.Value!).Name, Is.EqualTo("My Org")); + } + + [Test] + public async Task WhenGettingAnOrganizationByIdThatDoesNotExist_ThenNotFoundIsReturned() + { + var result = await _controller.GetById(999, CancellationToken.None); + + Assert.That(result.Result, Is.InstanceOf()); + } + + [Test] + public async Task WhenGettingAnOrganizationByIdThatExists_ThenItIsReturned() + { + var created = await _controller.Create(new CreateOrganizationRequest("My Org"), CancellationToken.None); + var orgId = ((OrganizationResponse)((CreatedAtActionResult)created.Result!).Value!).Id; + + var result = await _controller.GetById(orgId, CancellationToken.None); + + Assert.That(result.Result, Is.InstanceOf()); + } + + [Test] + public async Task WhenUpdatingAnOrganizationThatDoesNotExist_ThenNotFoundIsReturned() + { + var result = await _controller.Update(999, new UpdateOrganizationRequest("renamed"), CancellationToken.None); + + Assert.That(result.Result, Is.InstanceOf()); + } + + [Test] + public async Task WhenUpdatingAnOrganization_ThenTheUpdatedOrganizationIsReturned() + { + var created = await _controller.Create(new CreateOrganizationRequest("Old Name"), CancellationToken.None); + var orgId = ((OrganizationResponse)((CreatedAtActionResult)created.Result!).Value!).Id; + + var result = await _controller.Update(orgId, new UpdateOrganizationRequest("New Name"), CancellationToken.None); + + var ok = result.Result as OkObjectResult; + Assert.That(((OrganizationResponse)ok!.Value!).Name, Is.EqualTo("New Name")); + } + + [Test] + public async Task WhenSettingPrimaryWithoutAuthentication_ThenUnauthorizedIsReturned() + { + Unauthenticate(); + + var result = await _controller.SetPrimary(1, CancellationToken.None); + + Assert.That(result, Is.InstanceOf()); + } + + [Test] + public async Task WhenSettingPrimaryForAUserThatIsNotAMember_ThenNotFoundIsReturned() + { + var result = await _controller.SetPrimary(999, CancellationToken.None); + + Assert.That(result, Is.InstanceOf()); + } + + [Test] + public async Task WhenDeletingAnOrganizationThatDoesNotExist_ThenNotFoundIsReturned() + { + var result = await _controller.Delete(999, CancellationToken.None); + + Assert.That(result, Is.InstanceOf()); + } + + [Test] + public async Task WhenDeletingAnOrganization_ThenNoContentIsReturnedAndItIsRemoved() + { + var created = await _controller.Create(new CreateOrganizationRequest("My Org"), CancellationToken.None); + var orgId = ((OrganizationResponse)((CreatedAtActionResult)created.Result!).Value!).Id; + + var result = await _controller.Delete(orgId, CancellationToken.None); + + Assert.That(result, Is.InstanceOf()); + Assert.That(_organizations.Any(o => o.Id == orgId), Is.False); + } + + [Test] + public async Task WhenListingUsersForAnOrganizationThatDoesNotExist_ThenNotFoundIsReturned() + { + var result = await _controller.ListUsers(999, CancellationToken.None); + + Assert.That(result.Result, Is.InstanceOf()); + } + + [Test] + public async Task WhenListingUsersForAnOrganization_ThenMembersAreReturned() + { + var created = await _controller.Create(new CreateOrganizationRequest("My Org"), CancellationToken.None); + var orgId = ((OrganizationResponse)((CreatedAtActionResult)created.Result!).Value!).Id; + var user = new User { Id = UserId, Email = "alice@example.com", PasswordHash = "hashed", FirstName = "Alice", LastName = "Smith", CreatedAt = DateTimeOffset.UtcNow }; + _users.Add(user); + var member = _members.Single(m => m.OrganizationId == orgId); + typeof(OrganizationUser).GetProperty(nameof(OrganizationUser.User))!.SetValue(member, user); + + var result = await _controller.ListUsers(orgId, CancellationToken.None); + + var ok = result.Result as OkObjectResult; + var members = (IReadOnlyList)ok!.Value!; + Assert.That(members, Has.Count.EqualTo(1)); + Assert.That(members[0].Email, Is.EqualTo("alice@example.com")); + } + + [Test] + public async Task WhenInvitingAUserToAnOrganizationThatDoesNotExist_ThenNotFoundIsReturned() + { + var result = await _controller.InviteUser(999, new InviteUserRequest(2, "User"), CancellationToken.None); + + Assert.That(result, Is.InstanceOf()); + } + + [Test] + public async Task WhenInvitingAUser_ThenOkIsReturnedAndTheyBecomeAMember() + { + var created = await _controller.Create(new CreateOrganizationRequest("My Org"), CancellationToken.None); + var orgId = ((OrganizationResponse)((CreatedAtActionResult)created.Result!).Value!).Id; + + var result = await _controller.InviteUser(orgId, new InviteUserRequest(2, "Admin"), CancellationToken.None); + + Assert.That(result, Is.InstanceOf()); + Assert.That(_members.Single(m => m.UserId == 2).Role, Is.EqualTo(OrganizationRole.Admin)); + } + + [Test] + public async Task WhenInvitingUsersByEmailForAnOrganizationThatDoesNotExist_ThenNotFoundIsReturned() + { + var result = await _controller.InviteUsersByEmail(999, new InviteUsersByEmailRequest([]), CancellationToken.None); + + Assert.That(result.Result, Is.InstanceOf()); + } + + [Test] + public async Task WhenInvitingUsersByEmail_ThenResultsAreReturnedForEachEntry() + { + var created = await _controller.Create(new CreateOrganizationRequest("My Org"), CancellationToken.None); + var orgId = ((OrganizationResponse)((CreatedAtActionResult)created.Result!).Value!).Id; + + var result = await _controller.InviteUsersByEmail( + orgId, new InviteUsersByEmailRequest([new InviteByEmailEntry("nobody@example.com", "User")]), CancellationToken.None); + + var ok = result.Result as OkObjectResult; + var results = (IReadOnlyList)ok!.Value!; + Assert.That(results, Has.Count.EqualTo(1)); + Assert.That(results[0].Success, Is.False); + } + + [Test] + public async Task WhenGettingInviteLinkForAnOrganizationThatDoesNotExist_ThenNotFoundIsReturned() + { + var result = await _controller.GetInviteLink(999, CancellationToken.None); + + Assert.That(result.Result, Is.InstanceOf()); + } + + [Test] + public async Task WhenGettingInviteLink_ThenATokenIsReturned() + { + var created = await _controller.Create(new CreateOrganizationRequest("My Org"), CancellationToken.None); + var orgId = ((OrganizationResponse)((CreatedAtActionResult)created.Result!).Value!).Id; + + var result = await _controller.GetInviteLink(orgId, CancellationToken.None); + + var ok = result.Result as OkObjectResult; + Assert.That(((InviteTokenResponse)ok!.Value!).Token, Is.Not.Empty); + } + + [Test] + public async Task WhenRegeneratingInviteLinkForAnOrganizationThatDoesNotExist_ThenNotFoundIsReturned() + { + var result = await _controller.RegenerateInviteLink(999, CancellationToken.None); + + Assert.That(result.Result, Is.InstanceOf()); + } + + [Test] + public async Task WhenRegeneratingInviteLink_ThenANewTokenIsReturned() + { + var created = await _controller.Create(new CreateOrganizationRequest("My Org"), CancellationToken.None); + var orgId = ((OrganizationResponse)((CreatedAtActionResult)created.Result!).Value!).Id; + var first = await _controller.GetInviteLink(orgId, CancellationToken.None); + var firstToken = ((InviteTokenResponse)((OkObjectResult)first.Result!).Value!).Token; + + var result = await _controller.RegenerateInviteLink(orgId, CancellationToken.None); + + var ok = result.Result as OkObjectResult; + Assert.That(((InviteTokenResponse)ok!.Value!).Token, Is.Not.EqualTo(firstToken)); + } + + [Test] + public async Task WhenAcceptingInviteWithoutAuthentication_ThenUnauthorizedIsReturned() + { + Unauthenticate(); + + var result = await _controller.AcceptInvite("some-token", CancellationToken.None); + + Assert.That(result, Is.InstanceOf()); + } + + [Test] + public async Task WhenAcceptingInviteWithAnInvalidToken_ThenNotFoundIsReturned() + { + var result = await _controller.AcceptInvite("not-a-real-token", CancellationToken.None); + + Assert.That(result, Is.InstanceOf()); + } + + [Test] + public async Task WhenAcceptingAValidInvite_ThenOkIsReturnedAndTheUserBecomesAMember() + { + var created = await _controller.Create(new CreateOrganizationRequest("My Org"), CancellationToken.None); + var orgId = ((OrganizationResponse)((CreatedAtActionResult)created.Result!).Value!).Id; + var link = await _controller.GetInviteLink(orgId, CancellationToken.None); + var token = ((InviteTokenResponse)((OkObjectResult)link.Result!).Value!).Token; + + Unauthenticate(); + var identity = new ClaimsIdentity([new Claim(ClaimTypes.NameIdentifier, "2")], "TestAuth"); + _controller.ControllerContext.HttpContext.User = new ClaimsPrincipal(identity); + + var result = await _controller.AcceptInvite(token, CancellationToken.None); + + Assert.That(result, Is.InstanceOf()); + Assert.That(_members.Any(m => m.UserId == 2), Is.True); + } + + [Test] + public async Task WhenRemovingUserFromAnOrganizationThatDoesNotExist_ThenNotFoundIsReturned() + { + var result = await _controller.RemoveUser(999, 2, CancellationToken.None); + + Assert.That(result, Is.InstanceOf()); + } + + [Test] + public async Task WhenRemovingAUser_ThenNoContentIsReturnedAndTheyAreNoLongerAMember() + { + var created = await _controller.Create(new CreateOrganizationRequest("My Org"), CancellationToken.None); + var orgId = ((OrganizationResponse)((CreatedAtActionResult)created.Result!).Value!).Id; + await _controller.InviteUser(orgId, new InviteUserRequest(2, "User"), CancellationToken.None); + + var result = await _controller.RemoveUser(orgId, 2, CancellationToken.None); + + Assert.That(result, Is.InstanceOf()); + Assert.That(_members.Any(m => m.UserId == 2), Is.False); + } + + [Test] + public async Task WhenListingWebhooksForAnOrganizationThatDoesNotExist_ThenNotFoundIsReturned() + { + var result = await _controller.ListWebhooks(999, CancellationToken.None); + + Assert.That(result.Result, Is.InstanceOf()); + } + + [Test] + public async Task WhenCreatingAWebhookForAnOrganizationThatDoesNotExist_ThenNotFoundIsReturned() + { + var result = await _controller.CreateWebhook(999, new CreateWebhookRequest("https://example.com", null, true), CancellationToken.None); + + Assert.That(result.Result, Is.InstanceOf()); + } + + [Test] + public async Task WhenCreatingAWebhook_ThenACreatedResultWithTheWebhookIsReturned() + { + var created = await _controller.Create(new CreateOrganizationRequest("My Org"), CancellationToken.None); + var orgId = ((OrganizationResponse)((CreatedAtActionResult)created.Result!).Value!).Id; + + var result = await _controller.CreateWebhook(orgId, new CreateWebhookRequest("https://example.com", "secret", true), CancellationToken.None); + + var webhookCreated = result.Result as CreatedAtActionResult; + Assert.That(webhookCreated, Is.Not.Null); + Assert.That(((WebhookResponse)webhookCreated!.Value!).Url, Is.EqualTo("https://example.com")); + } + + [Test] + public async Task WhenListingWebhooksForAnOrganization_ThenTheyAreReturned() + { + var created = await _controller.Create(new CreateOrganizationRequest("My Org"), CancellationToken.None); + var orgId = ((OrganizationResponse)((CreatedAtActionResult)created.Result!).Value!).Id; + await _controller.CreateWebhook(orgId, new CreateWebhookRequest("https://example.com", null, true), CancellationToken.None); + + var result = await _controller.ListWebhooks(orgId, CancellationToken.None); + + var ok = result.Result as OkObjectResult; + var webhooks = (IReadOnlyList)ok!.Value!; + Assert.That(webhooks, Has.Count.EqualTo(1)); + } + + [Test] + public async Task WhenUpdatingAWebhookForAnOrganizationThatDoesNotExist_ThenNotFoundIsReturned() + { + var result = await _controller.UpdateWebhook(999, 1, new CreateWebhookRequest("https://example.com", null, true), CancellationToken.None); + + Assert.That(result.Result, Is.InstanceOf()); + } + + [Test] + public async Task WhenUpdatingAWebhookThatDoesNotExist_ThenNotFoundIsReturned() + { + var created = await _controller.Create(new CreateOrganizationRequest("My Org"), CancellationToken.None); + var orgId = ((OrganizationResponse)((CreatedAtActionResult)created.Result!).Value!).Id; + + var result = await _controller.UpdateWebhook(orgId, 999, new CreateWebhookRequest("https://example.com", null, true), CancellationToken.None); + + Assert.That(result.Result, Is.InstanceOf()); + } + + [Test] + public async Task WhenUpdatingAWebhookThatBelongsToADifferentOrganization_ThenNotFoundIsReturned() + { + var org1 = await _controller.Create(new CreateOrganizationRequest("Org 1"), CancellationToken.None); + var org1Id = ((OrganizationResponse)((CreatedAtActionResult)org1.Result!).Value!).Id; + var org2 = await _controller.Create(new CreateOrganizationRequest("Org 2"), CancellationToken.None); + var org2Id = ((OrganizationResponse)((CreatedAtActionResult)org2.Result!).Value!).Id; + var webhook = await _controller.CreateWebhook(org1Id, new CreateWebhookRequest("https://example.com", null, true), CancellationToken.None); + var webhookId = ((WebhookResponse)((CreatedAtActionResult)webhook.Result!).Value!).Id; + + var result = await _controller.UpdateWebhook(org2Id, webhookId, new CreateWebhookRequest("https://other.com", null, false), CancellationToken.None); + + Assert.That(result.Result, Is.InstanceOf()); + } + + [Test] + public async Task WhenUpdatingAWebhook_ThenItIsChanged() + { + var created = await _controller.Create(new CreateOrganizationRequest("My Org"), CancellationToken.None); + var orgId = ((OrganizationResponse)((CreatedAtActionResult)created.Result!).Value!).Id; + var webhook = await _controller.CreateWebhook(orgId, new CreateWebhookRequest("https://example.com", null, true), CancellationToken.None); + var webhookId = ((WebhookResponse)((CreatedAtActionResult)webhook.Result!).Value!).Id; + + var result = await _controller.UpdateWebhook(orgId, webhookId, new CreateWebhookRequest("https://updated.com", null, false), CancellationToken.None); + + var ok = result.Result as OkObjectResult; + Assert.That(((WebhookResponse)ok!.Value!).Url, Is.EqualTo("https://updated.com")); + } + + [Test] + public async Task WhenDeletingAWebhookForAnOrganizationThatDoesNotExist_ThenNotFoundIsReturned() + { + var result = await _controller.DeleteWebhook(999, 1, CancellationToken.None); + + Assert.That(result, Is.InstanceOf()); + } + + [Test] + public async Task WhenDeletingAWebhookThatBelongsToADifferentOrganization_ThenNotFoundIsReturned() + { + var org1 = await _controller.Create(new CreateOrganizationRequest("Org 1"), CancellationToken.None); + var org1Id = ((OrganizationResponse)((CreatedAtActionResult)org1.Result!).Value!).Id; + var org2 = await _controller.Create(new CreateOrganizationRequest("Org 2"), CancellationToken.None); + var org2Id = ((OrganizationResponse)((CreatedAtActionResult)org2.Result!).Value!).Id; + var webhook = await _controller.CreateWebhook(org1Id, new CreateWebhookRequest("https://example.com", null, true), CancellationToken.None); + var webhookId = ((WebhookResponse)((CreatedAtActionResult)webhook.Result!).Value!).Id; + + var result = await _controller.DeleteWebhook(org2Id, webhookId, CancellationToken.None); + + Assert.That(result, Is.InstanceOf()); + } + + [Test] + public async Task WhenDeletingAWebhook_ThenItIsRemoved() + { + var created = await _controller.Create(new CreateOrganizationRequest("My Org"), CancellationToken.None); + var orgId = ((OrganizationResponse)((CreatedAtActionResult)created.Result!).Value!).Id; + var webhook = await _controller.CreateWebhook(orgId, new CreateWebhookRequest("https://example.com", null, true), CancellationToken.None); + var webhookId = ((WebhookResponse)((CreatedAtActionResult)webhook.Result!).Value!).Id; + + var result = await _controller.DeleteWebhook(orgId, webhookId, CancellationToken.None); + + Assert.That(result, Is.InstanceOf()); + Assert.That(_webhooks.Any(w => w.Id == webhookId), Is.False); + } +} diff --git a/tests/api/MicCheck.Api.Tests.Unit/Projects/CreateProjectRequestValidatorTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Projects/CreateProjectRequestValidatorTests.cs new file mode 100644 index 0000000..e03059c --- /dev/null +++ b/tests/api/MicCheck.Api.Tests.Unit/Projects/CreateProjectRequestValidatorTests.cs @@ -0,0 +1,45 @@ +using MicCheck.Api.Projects; +using NUnit.Framework; + +namespace MicCheck.Api.Tests.Unit.Projects; + +[TestFixture] +public class CreateProjectRequestValidatorTests +{ + private CreateProjectRequestValidator _validator = null!; + + [SetUp] + public void SetUp() => _validator = new CreateProjectRequestValidator(); + + [Test] + public void WhenTheRequestIsValid_ThenValidationSucceeds() + { + var result = _validator.Validate(new CreateProjectRequest("My Project", 1)); + + Assert.That(result.IsValid, Is.True); + } + + [Test] + public void WhenNameIsEmpty_ThenValidationFails() + { + var result = _validator.Validate(new CreateProjectRequest("", 1)); + + Assert.That(result.IsValid, Is.False); + } + + [Test] + public void WhenNameExceedsMaximumLength_ThenValidationFails() + { + var result = _validator.Validate(new CreateProjectRequest(new string('a', 201), 1)); + + Assert.That(result.IsValid, Is.False); + } + + [Test] + public void WhenOrganizationIdIsZeroOrLess_ThenValidationFails() + { + var result = _validator.Validate(new CreateProjectRequest("My Project", 0)); + + Assert.That(result.IsValid, Is.False); + } +} diff --git a/tests/api/MicCheck.Api.Tests.Unit/Projects/ProjectResponseTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Projects/ProjectResponseTests.cs new file mode 100644 index 0000000..c9a8354 --- /dev/null +++ b/tests/api/MicCheck.Api.Tests.Unit/Projects/ProjectResponseTests.cs @@ -0,0 +1,45 @@ +using MicCheck.Api.Common.Security.Authorization; +using MicCheck.Api.Projects; +using NUnit.Framework; + +namespace MicCheck.Api.Tests.Unit.Projects; + +[TestFixture] +public class ProjectResponseTests +{ + [Test] + public void WhenMappingAProject_ThenAllFieldsAreCopied() + { + var project = new Project { Id = 1, Name = "My Project", OrganizationId = 5, HideDisabledFlags = true, CreatedAt = DateTimeOffset.UtcNow }; + + var response = ProjectResponse.From(project); + + Assert.That(response.Id, Is.EqualTo(1)); + Assert.That(response.Name, Is.EqualTo("My Project")); + Assert.That(response.OrganizationId, Is.EqualTo(5)); + Assert.That(response.HideDisabledFlags, Is.True); + } +} + +[TestFixture] +public class UserPermissionResponseTests +{ + [Test] + public void WhenMappingAUserProjectPermission_ThenPermissionsAreMappedToStrings() + { + var permission = new UserProjectPermission + { + UserId = 1, + ProjectId = 2, + IsAdmin = true, + Permissions = [ProjectPermission.ViewProject, ProjectPermission.EditFeature] + }; + + var response = UserPermissionResponse.From(permission); + + Assert.That(response.UserId, Is.EqualTo(1)); + Assert.That(response.ProjectId, Is.EqualTo(2)); + Assert.That(response.IsAdmin, Is.True); + Assert.That(response.Permissions, Is.EqualTo(new[] { "ViewProject", "EditFeature" })); + } +} diff --git a/tests/api/MicCheck.Api.Tests.Unit/Projects/ProjectServiceTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Projects/ProjectServiceTests.cs new file mode 100644 index 0000000..1c04bfc --- /dev/null +++ b/tests/api/MicCheck.Api.Tests.Unit/Projects/ProjectServiceTests.cs @@ -0,0 +1,168 @@ +using MicCheck.Api.Audit; +using MicCheck.Api.Common.Security.Authorization; +using MicCheck.Api.Data; +using MicCheck.Api.Organizations; +using MicCheck.Api.Projects; +using MicCheck.Api.Tests.Unit.TestSupport; +using Moq; +using NUnit.Framework; + +namespace MicCheck.Api.Tests.Unit.Projects; + +[TestFixture] +public class ProjectServiceTests +{ + private Mock _db = null!; + private List _projects = null!; + private List _permissions = null!; + private ProjectService _service = null!; + private const int OrganizationId = 1; + + [SetUp] + public void SetUp() + { + _db = new Mock(); + + _db.SetupDbSet(c => c.Organizations, [ + new Organization { Id = OrganizationId, Name = "Test Org", CreatedAt = DateTimeOffset.UtcNow } + ]); + _projects = []; + _db.SetupDbSetWithGeneratedIds(c => c.Projects, _projects); + _permissions = []; + _db.SetupDbSet(c => c.UserProjectPermissions, _permissions); + + 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); + + _service = new ProjectService(_db.Object, auditService.Object); + } + + [Test] + public async Task WhenCreatingAProject_ThenItIsPersistedWithTheGivenOrganization() + { + var project = await _service.CreateAsync(OrganizationId, "My Project"); + + Assert.That(project.Name, Is.EqualTo("My Project")); + Assert.That(project.OrganizationId, Is.EqualTo(OrganizationId)); + Assert.That(_projects, Has.Count.EqualTo(1)); + } + + [Test] + public async Task WhenListingProjectsForAnOrganization_ThenOnlyThatOrganizationsProjectsAreReturned() + { + await _service.CreateAsync(OrganizationId, "Project A"); + await _service.CreateAsync(999, "Other Org Project"); + + var projects = await _service.ListByOrganizationAsync(OrganizationId); + + Assert.That(projects, Has.Count.EqualTo(1)); + Assert.That(projects[0].Name, Is.EqualTo("Project A")); + } + + [Test] + public async Task WhenFindingAProjectByIdThatExists_ThenItIsReturned() + { + var project = await _service.CreateAsync(OrganizationId, "My Project"); + + var found = await _service.FindByIdAsync(project.Id); + + Assert.That(found, Is.Not.Null); + Assert.That(found!.Id, Is.EqualTo(project.Id)); + } + + [Test] + public async Task WhenFindingAProjectByIdThatDoesNotExist_ThenNullIsReturned() + { + var found = await _service.FindByIdAsync(999); + + Assert.That(found, Is.Null); + } + + [Test] + public void WhenUpdatingAProjectThatDoesNotExist_ThenKeyNotFoundExceptionIsThrown() + { + Assert.ThrowsAsync(() => _service.UpdateAsync(999, "renamed", true)); + } + + [Test] + public async Task WhenUpdatingAProject_ThenNameAndHideDisabledFlagsAreChanged() + { + var project = await _service.CreateAsync(OrganizationId, "Old Name"); + + var updated = await _service.UpdateAsync(project.Id, "New Name", true); + + Assert.That(updated.Name, Is.EqualTo("New Name")); + Assert.That(updated.HideDisabledFlags, Is.True); + } + + [Test] + public void WhenDeletingAProjectThatDoesNotExist_ThenNoExceptionIsThrown() + { + Assert.DoesNotThrowAsync(() => _service.DeleteAsync(999)); + } + + [Test] + public async Task WhenDeletingAProject_ThenItIsRemovedFromTheDatabase() + { + var project = await _service.CreateAsync(OrganizationId, "My Project"); + + await _service.DeleteAsync(project.Id); + + Assert.That(_projects.Any(p => p.Id == project.Id), Is.False); + } + + [Test] + public async Task WhenListingUserPermissionsForAProject_ThenOnlyThatProjectsPermissionsAreReturned() + { + _permissions.Add(new UserProjectPermission { ProjectId = 1, UserId = 1 }); + _permissions.Add(new UserProjectPermission { ProjectId = 999, UserId = 2 }); + + var perms = await _service.ListUserPermissionsAsync(1); + + Assert.That(perms, Has.Count.EqualTo(1)); + Assert.That(perms[0].UserId, Is.EqualTo(1)); + } + + [Test] + public async Task WhenSettingUserPermissionsForAUserWithNoExistingPermissions_ThenANewRecordIsCreated() + { + var perm = await _service.SetUserPermissionsAsync(1, userId: 5, isAdmin: true, [ProjectPermission.ViewProject]); + + Assert.That(perm.IsAdmin, Is.True); + Assert.That(perm.Permissions, Does.Contain(ProjectPermission.ViewProject)); + Assert.That(_permissions, Has.Count.EqualTo(1)); + } + + [Test] + public async Task WhenSettingUserPermissionsForAUserThatAlreadyHasPermissions_ThenTheExistingRecordIsUpdated() + { + await _service.SetUserPermissionsAsync(1, userId: 5, isAdmin: false, [ProjectPermission.ViewProject]); + + var updated = await _service.SetUserPermissionsAsync(1, userId: 5, isAdmin: true, [ProjectPermission.DeleteFeature]); + + Assert.That(_permissions, Has.Count.EqualTo(1)); + Assert.That(updated.IsAdmin, Is.True); + Assert.That(updated.Permissions, Is.EqualTo(new[] { ProjectPermission.DeleteFeature })); + } + + [Test] + public void WhenRemovingUserPermissionsThatDoNotExist_ThenNoExceptionIsThrown() + { + Assert.DoesNotThrowAsync(() => _service.RemoveUserPermissionsAsync(1, 5)); + } + + [Test] + public async Task WhenRemovingUserPermissions_ThenTheRecordIsRemoved() + { + await _service.SetUserPermissionsAsync(1, userId: 5, isAdmin: true, [ProjectPermission.ViewProject]); + + await _service.RemoveUserPermissionsAsync(1, 5); + + Assert.That(_permissions, Is.Empty); + } +} diff --git a/tests/api/MicCheck.Api.Tests.Unit/Projects/ProjectsControllerTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Projects/ProjectsControllerTests.cs new file mode 100644 index 0000000..33c67e4 --- /dev/null +++ b/tests/api/MicCheck.Api.Tests.Unit/Projects/ProjectsControllerTests.cs @@ -0,0 +1,236 @@ +using MicCheck.Api.Audit; +using MicCheck.Api.Common; +using MicCheck.Api.Common.Security.Authorization; +using MicCheck.Api.Data; +using MicCheck.Api.Organizations; +using MicCheck.Api.Projects; +using MicCheck.Api.Tests.Unit.TestSupport; +using Microsoft.AspNetCore.Mvc; +using Moq; +using NUnit.Framework; + +namespace MicCheck.Api.Tests.Unit.Projects; + +[TestFixture] +public class ProjectsControllerTests +{ + private Mock _db = null!; + private List _projects = null!; + private List _permissions = null!; + private ProjectsController _controller = null!; + private const int OrganizationId = 1; + + [SetUp] + public void SetUp() + { + _db = new Mock(); + + _db.SetupDbSet(c => c.Organizations, [ + new Organization { Id = OrganizationId, Name = "Test Org", CreatedAt = DateTimeOffset.UtcNow } + ]); + _projects = []; + _db.SetupDbSetWithGeneratedIds(c => c.Projects, _projects); + _permissions = []; + _db.SetupDbSet(c => c.UserProjectPermissions, _permissions); + + 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); + + _controller = new ProjectsController(new ProjectService(_db.Object, auditService.Object)); + } + + [Test] + public async Task WhenListingProjectsForAnOrganization_ThenTheyAreReturnedInAPage() + { + await _controller.Create(new CreateProjectRequest("Project A", OrganizationId), CancellationToken.None); + await _controller.Create(new CreateProjectRequest("Project B", OrganizationId), CancellationToken.None); + + var result = await _controller.List(OrganizationId); + + 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 WhenListingProjectsWithAPageSizeAboveTheMaximum_ThenItIsClampedTo100() + { + for (var i = 0; i < 3; i++) + await _controller.Create(new CreateProjectRequest($"Project {i}", OrganizationId), CancellationToken.None); + + var result = await _controller.List(OrganizationId, 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 WhenCreatingAProject_ThenACreatedResultWithTheProjectIsReturned() + { + var result = await _controller.Create(new CreateProjectRequest("My Project", OrganizationId), CancellationToken.None); + + var created = result.Result as CreatedAtActionResult; + Assert.That(created, Is.Not.Null); + Assert.That(((ProjectResponse)created!.Value!).Name, Is.EqualTo("My Project")); + } + + [Test] + public async Task WhenGettingAProjectByIdThatExists_ThenItIsReturned() + { + var created = await _controller.Create(new CreateProjectRequest("My Project", OrganizationId), CancellationToken.None); + var projectId = ((ProjectResponse)((CreatedAtActionResult)created.Result!).Value!).Id; + + var result = await _controller.GetById(projectId, CancellationToken.None); + + var ok = result.Result as OkObjectResult; + Assert.That(ok, Is.Not.Null); + Assert.That(((ProjectResponse)ok!.Value!).Id, Is.EqualTo(projectId)); + } + + [Test] + public async Task WhenGettingAProjectByIdThatDoesNotExist_ThenNotFoundIsReturned() + { + var result = await _controller.GetById(999, CancellationToken.None); + + Assert.That(result.Result, Is.InstanceOf()); + } + + [Test] + public async Task WhenUpdatingAProjectThatDoesNotExist_ThenNotFoundIsReturned() + { + var result = await _controller.Update(999, new UpdateProjectRequest("renamed", false), CancellationToken.None); + + Assert.That(result.Result, Is.InstanceOf()); + } + + [Test] + public async Task WhenUpdatingAProject_ThenTheUpdatedProjectIsReturned() + { + var created = await _controller.Create(new CreateProjectRequest("Old Name", OrganizationId), CancellationToken.None); + var projectId = ((ProjectResponse)((CreatedAtActionResult)created.Result!).Value!).Id; + + var result = await _controller.Update(projectId, new UpdateProjectRequest("New Name", true), CancellationToken.None); + + var ok = result.Result as OkObjectResult; + var response = (ProjectResponse)ok!.Value!; + Assert.That(response.Name, Is.EqualTo("New Name")); + Assert.That(response.HideDisabledFlags, Is.True); + } + + [Test] + public async Task WhenDeletingAProjectThatDoesNotExist_ThenNotFoundIsReturned() + { + var result = await _controller.Delete(999, CancellationToken.None); + + Assert.That(result, Is.InstanceOf()); + } + + [Test] + public async Task WhenDeletingAProject_ThenNoContentIsReturnedAndItIsRemoved() + { + var created = await _controller.Create(new CreateProjectRequest("My Project", OrganizationId), CancellationToken.None); + var projectId = ((ProjectResponse)((CreatedAtActionResult)created.Result!).Value!).Id; + + var result = await _controller.Delete(projectId, CancellationToken.None); + + Assert.That(result, Is.InstanceOf()); + Assert.That(_projects.Any(p => p.Id == projectId), Is.False); + } + + [Test] + public async Task WhenListingUserPermissionsForAProjectThatDoesNotExist_ThenNotFoundIsReturned() + { + var result = await _controller.ListUserPermissions(999, CancellationToken.None); + + Assert.That(result.Result, Is.InstanceOf()); + } + + [Test] + public async Task WhenListingUserPermissionsForAProject_ThenTheyAreReturned() + { + var created = await _controller.Create(new CreateProjectRequest("My Project", OrganizationId), CancellationToken.None); + var projectId = ((ProjectResponse)((CreatedAtActionResult)created.Result!).Value!).Id; + await _controller.CreateUserPermissions(projectId, new SetUserPermissionsRequest(5, true, ["ViewProject"]), CancellationToken.None); + + var result = await _controller.ListUserPermissions(projectId, CancellationToken.None); + + var ok = result.Result as OkObjectResult; + var perms = (IReadOnlyList)ok!.Value!; + Assert.That(perms, Has.Count.EqualTo(1)); + Assert.That(perms[0].UserId, Is.EqualTo(5)); + } + + [Test] + public async Task WhenCreatingUserPermissionsForAProjectThatDoesNotExist_ThenNotFoundIsReturned() + { + var result = await _controller.CreateUserPermissions(999, new SetUserPermissionsRequest(5, true, []), CancellationToken.None); + + Assert.That(result.Result, Is.InstanceOf()); + } + + [Test] + public async Task WhenCreatingUserPermissions_ThenThePermissionsAreParsedAndPersisted() + { + var created = await _controller.Create(new CreateProjectRequest("My Project", OrganizationId), CancellationToken.None); + var projectId = ((ProjectResponse)((CreatedAtActionResult)created.Result!).Value!).Id; + + var result = await _controller.CreateUserPermissions( + projectId, new SetUserPermissionsRequest(5, true, ["ViewProject", "EditFeature"]), CancellationToken.None); + + var ok = result.Result as OkObjectResult; + var response = (UserPermissionResponse)ok!.Value!; + Assert.That(response.Permissions, Is.EqualTo(new[] { "ViewProject", "EditFeature" })); + } + + [Test] + public async Task WhenUpdatingUserPermissionsForAProjectThatDoesNotExist_ThenNotFoundIsReturned() + { + var result = await _controller.UpdateUserPermissions(999, 5, new SetUserPermissionsRequest(5, true, []), CancellationToken.None); + + Assert.That(result.Result, Is.InstanceOf()); + } + + [Test] + public async Task WhenUpdatingUserPermissions_ThenTheExistingRecordIsChanged() + { + var created = await _controller.Create(new CreateProjectRequest("My Project", OrganizationId), CancellationToken.None); + var projectId = ((ProjectResponse)((CreatedAtActionResult)created.Result!).Value!).Id; + await _controller.CreateUserPermissions(projectId, new SetUserPermissionsRequest(5, false, ["ViewProject"]), CancellationToken.None); + + var result = await _controller.UpdateUserPermissions(projectId, 5, new SetUserPermissionsRequest(5, true, ["DeleteFeature"]), CancellationToken.None); + + var ok = result.Result as OkObjectResult; + var response = (UserPermissionResponse)ok!.Value!; + Assert.That(response.IsAdmin, Is.True); + Assert.That(response.Permissions, Is.EqualTo(new[] { "DeleteFeature" })); + Assert.That(_permissions, Has.Count.EqualTo(1)); + } + + [Test] + public async Task WhenDeletingUserPermissionsForAProjectThatDoesNotExist_ThenNotFoundIsReturned() + { + var result = await _controller.DeleteUserPermissions(999, 5, CancellationToken.None); + + Assert.That(result, Is.InstanceOf()); + } + + [Test] + public async Task WhenDeletingUserPermissions_ThenTheRecordIsRemoved() + { + var created = await _controller.Create(new CreateProjectRequest("My Project", OrganizationId), CancellationToken.None); + var projectId = ((ProjectResponse)((CreatedAtActionResult)created.Result!).Value!).Id; + await _controller.CreateUserPermissions(projectId, new SetUserPermissionsRequest(5, true, ["ViewProject"]), CancellationToken.None); + + var result = await _controller.DeleteUserPermissions(projectId, 5, CancellationToken.None); + + Assert.That(result, Is.InstanceOf()); + Assert.That(_permissions, Is.Empty); + } +} diff --git a/tests/api/MicCheck.Api.Tests.Unit/Projects/SetUserPermissionsRequestValidatorTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Projects/SetUserPermissionsRequestValidatorTests.cs new file mode 100644 index 0000000..4b40f3c --- /dev/null +++ b/tests/api/MicCheck.Api.Tests.Unit/Projects/SetUserPermissionsRequestValidatorTests.cs @@ -0,0 +1,45 @@ +using MicCheck.Api.Projects; +using NUnit.Framework; + +namespace MicCheck.Api.Tests.Unit.Projects; + +[TestFixture] +public class SetUserPermissionsRequestValidatorTests +{ + private SetUserPermissionsRequestValidator _validator = null!; + + [SetUp] + public void SetUp() => _validator = new SetUserPermissionsRequestValidator(); + + [Test] + public void WhenTheRequestIsValid_ThenValidationSucceeds() + { + var result = _validator.Validate(new SetUserPermissionsRequest(1, true, ["ViewProject", "EditFeature"])); + + Assert.That(result.IsValid, Is.True); + } + + [Test] + public void WhenUserIdIsZeroOrLess_ThenValidationFails() + { + var result = _validator.Validate(new SetUserPermissionsRequest(0, true, [])); + + Assert.That(result.IsValid, Is.False); + } + + [Test] + public void WhenAPermissionIsInvalid_ThenValidationFails() + { + var result = _validator.Validate(new SetUserPermissionsRequest(1, false, ["NotAPermission"])); + + Assert.That(result.IsValid, Is.False); + } + + [Test] + public void WhenPermissionsListIsEmpty_ThenValidationSucceeds() + { + var result = _validator.Validate(new SetUserPermissionsRequest(1, true, [])); + + Assert.That(result.IsValid, Is.True); + } +} diff --git a/tests/api/MicCheck.Api.Tests.Unit/Projects/UpdateProjectRequestValidatorTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Projects/UpdateProjectRequestValidatorTests.cs new file mode 100644 index 0000000..04e0362 --- /dev/null +++ b/tests/api/MicCheck.Api.Tests.Unit/Projects/UpdateProjectRequestValidatorTests.cs @@ -0,0 +1,37 @@ +using MicCheck.Api.Projects; +using NUnit.Framework; + +namespace MicCheck.Api.Tests.Unit.Projects; + +[TestFixture] +public class UpdateProjectRequestValidatorTests +{ + private UpdateProjectRequestValidator _validator = null!; + + [SetUp] + public void SetUp() => _validator = new UpdateProjectRequestValidator(); + + [Test] + public void WhenTheRequestIsValid_ThenValidationSucceeds() + { + var result = _validator.Validate(new UpdateProjectRequest("Renamed", true)); + + Assert.That(result.IsValid, Is.True); + } + + [Test] + public void WhenNameIsEmpty_ThenValidationFails() + { + var result = _validator.Validate(new UpdateProjectRequest("", false)); + + Assert.That(result.IsValid, Is.False); + } + + [Test] + public void WhenNameExceedsMaximumLength_ThenValidationFails() + { + var result = _validator.Validate(new UpdateProjectRequest(new string('a', 201), false)); + + Assert.That(result.IsValid, Is.False); + } +} 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); + } +} diff --git a/tests/api/MicCheck.Api.Tests.Unit/Users/UserServiceTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Users/UserServiceTests.cs new file mode 100644 index 0000000..6820337 --- /dev/null +++ b/tests/api/MicCheck.Api.Tests.Unit/Users/UserServiceTests.cs @@ -0,0 +1,161 @@ +using MicCheck.Api.Data; +using MicCheck.Api.Tests.Unit.TestSupport; +using MicCheck.Api.Users; +using Microsoft.AspNetCore.Identity; +using Moq; +using NUnit.Framework; + +namespace MicCheck.Api.Tests.Unit.Users; + +[TestFixture] +public class UserServiceTests +{ + private Mock _db = null!; + private Mock> _passwordHasher = null!; + private List _users = null!; + private UserService _service = null!; + + [SetUp] + public void SetUp() + { + _db = new Mock(); + _users = []; + _db.SetupDbSet(c => c.Users, _users); + + _passwordHasher = new Mock>(); + + _service = new UserService(_db.Object, _passwordHasher.Object); + } + + private User AddUser(int id = 1, bool isActive = true, string email = "alice@example.com") => new() + { + Id = id, + Email = email, + PasswordHash = "hashed", + FirstName = "Alice", + LastName = "Smith", + IsActive = isActive, + CreatedAt = DateTimeOffset.UtcNow + }; + + [Test] + public async Task WhenFindingByIdForAnActiveUser_ThenTheUserIsReturned() + { + var user = AddUser(); + _users.Add(user); + + var result = await _service.FindByIdAsync(user.Id); + + Assert.That(result, Is.SameAs(user)); + } + + [Test] + public async Task WhenFindingByIdForAnInactiveUser_ThenNullIsReturned() + { + var user = AddUser(isActive: false); + _users.Add(user); + + var result = await _service.FindByIdAsync(user.Id); + + Assert.That(result, Is.Null); + } + + [Test] + public async Task WhenFindingByIdForAnUnknownUser_ThenNullIsReturned() + { + var result = await _service.FindByIdAsync(999); + + Assert.That(result, Is.Null); + } + + [Test] + public async Task WhenUpdatingTheProfileOfAnUnknownUser_ThenNullIsReturnedWithoutConflict() + { + var result = await _service.UpdateProfileAsync(999, "Bob", "Jones", "bob@example.com"); + + Assert.That(result.User, Is.Null); + Assert.That(result.EmailConflict, Is.False); + } + + [Test] + public async Task WhenUpdatingTheProfileWithANewUniqueEmail_ThenTheUserFieldsAreUpdated() + { + var user = AddUser(); + _users.Add(user); + + var result = await _service.UpdateProfileAsync(user.Id, "Bob", "Jones", "bob@example.com"); + + Assert.That(result.EmailConflict, Is.False); + Assert.That(result.User!.FirstName, Is.EqualTo("Bob")); + Assert.That(result.User.LastName, Is.EqualTo("Jones")); + Assert.That(result.User.Email, Is.EqualTo("bob@example.com")); + } + + [Test] + public async Task WhenUpdatingTheProfileWithTheSameEmailInDifferentCase_ThenNoConflictIsDetected() + { + var user = AddUser(email: "alice@example.com"); + _users.Add(user); + + var result = await _service.UpdateProfileAsync(user.Id, "Alice", "Smith", "ALICE@EXAMPLE.COM"); + + Assert.That(result.EmailConflict, Is.False); + Assert.That(result.User!.Email, Is.EqualTo("ALICE@EXAMPLE.COM")); + } + + [Test] + public async Task WhenUpdatingTheProfileWithAnEmailAlreadyUsedByAnotherUser_ThenAConflictIsReturnedAndTheUserIsUnchanged() + { + var user = AddUser(id: 1, email: "alice@example.com"); + var otherUser = AddUser(id: 2, email: "taken@example.com"); + _users.Add(user); + _users.Add(otherUser); + + var result = await _service.UpdateProfileAsync(user.Id, "Bob", "Jones", "taken@example.com"); + + Assert.That(result.EmailConflict, Is.True); + Assert.That(result.User, Is.Null); + Assert.That(user.Email, Is.EqualTo("alice@example.com")); + } + + [Test] + public async Task WhenChangingThePasswordForAnUnknownUser_ThenFalseIsReturned() + { + var result = await _service.ChangePasswordAsync(999, "current", "new"); + + Assert.That(result, Is.False); + } + + [Test] + public async Task WhenChangingThePasswordWithAnIncorrectCurrentPassword_ThenFalseIsReturnedAndTheHashIsUnchanged() + { + var user = AddUser(); + _users.Add(user); + _passwordHasher + .Setup(h => h.VerifyHashedPassword(user, user.PasswordHash, "wrong")) + .Returns(PasswordVerificationResult.Failed); + + var result = await _service.ChangePasswordAsync(user.Id, "wrong", "new-password"); + + Assert.That(result, Is.False); + Assert.That(user.PasswordHash, Is.EqualTo("hashed")); + } + + [Test] + public async Task WhenChangingThePasswordWithACorrectCurrentPassword_ThenTheHashIsUpdatedAndTrueIsReturned() + { + var user = AddUser(); + _users.Add(user); + _passwordHasher + .Setup(h => h.VerifyHashedPassword(user, user.PasswordHash, "current")) + .Returns(PasswordVerificationResult.Success); + _passwordHasher + .Setup(h => h.HashPassword(user, "new-password")) + .Returns("new-hashed"); + + var result = await _service.ChangePasswordAsync(user.Id, "current", "new-password"); + + Assert.That(result, Is.True); + Assert.That(user.PasswordHash, Is.EqualTo("new-hashed")); + } +} diff --git a/tests/api/MicCheck.Api.Tests.Unit/Users/UsersControllerTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Users/UsersControllerTests.cs new file mode 100644 index 0000000..14d5da1 --- /dev/null +++ b/tests/api/MicCheck.Api.Tests.Unit/Users/UsersControllerTests.cs @@ -0,0 +1,217 @@ +using System.Security.Claims; +using MicCheck.Api.Data; +using MicCheck.Api.Tests.Unit.TestSupport; +using MicCheck.Api.Users; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Moq; +using NUnit.Framework; + +namespace MicCheck.Api.Tests.Unit.Users; + +[TestFixture] +public class UsersControllerTests +{ + private Mock _db = null!; + private List _users = null!; + private UsersController _controller = null!; + private const int UserId = 1; + + [SetUp] + public void SetUp() + { + _db = new Mock(); + _users = []; + _db.SetupDbSet(c => c.Users, _users); + + var passwordHasher = new Mock>(); + passwordHasher + .Setup(h => h.VerifyHashedPassword(It.IsAny(), "hashed", "correct-password")) + .Returns(PasswordVerificationResult.Success); + passwordHasher + .Setup(h => h.VerifyHashedPassword(It.IsAny(), "hashed", It.Is(p => p != "correct-password"))) + .Returns(PasswordVerificationResult.Failed); + passwordHasher + .Setup(h => h.HashPassword(It.IsAny(), It.IsAny())) + .Returns("new-hashed"); + + var service = new UserService(_db.Object, passwordHasher.Object); + _controller = new UsersController(service); + } + + private User AddUser(int id = UserId, string email = "alice@example.com") => new() + { + Id = id, + Email = email, + PasswordHash = "hashed", + FirstName = "Alice", + LastName = "Smith", + IsActive = true, + CreatedAt = DateTimeOffset.UtcNow + }; + + private void AuthenticateAs(int? userId) + { + var claims = userId is null ? [] : new[] { new Claim(ClaimTypes.NameIdentifier, userId.Value.ToString()) }; + var identity = new ClaimsIdentity(claims, userId is null ? null : "TestAuth"); + _controller.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal(identity) } + }; + } + + [Test] + public async Task WhenGettingAUserById_ThenTheMatchingUserIsReturned() + { + var user = AddUser(); + _users.Add(user); + + var result = await _controller.GetById(user.Id, CancellationToken.None); + + var ok = result.Result as OkObjectResult; + Assert.That(ok, Is.Not.Null); + Assert.That(((UserResponse)ok!.Value!).Id, Is.EqualTo(user.Id)); + } + + [Test] + public async Task WhenGettingAUserByIdThatDoesNotExist_ThenNotFoundIsReturned() + { + var result = await _controller.GetById(999, CancellationToken.None); + + Assert.That(result.Result, Is.InstanceOf()); + } + + [Test] + public async Task WhenGettingTheCurrentUserWithoutAuthentication_ThenUnauthorizedIsReturned() + { + AuthenticateAs(null); + + var result = await _controller.GetMe(CancellationToken.None); + + Assert.That(result.Result, Is.InstanceOf()); + } + + [Test] + public async Task WhenGettingTheCurrentUser_ThenTheAuthenticatedUsersProfileIsReturned() + { + var user = AddUser(); + _users.Add(user); + AuthenticateAs(user.Id); + + var result = await _controller.GetMe(CancellationToken.None); + + var ok = result.Result as OkObjectResult; + Assert.That(ok, Is.Not.Null); + Assert.That(((UserResponse)ok!.Value!).Email, Is.EqualTo(user.Email)); + } + + [Test] + public async Task WhenUpdatingTheProfileWithoutAuthentication_ThenUnauthorizedIsReturned() + { + AuthenticateAs(null); + + var result = await _controller.UpdateMe(new UpdateProfileRequest("Bob", "Jones", "bob@example.com"), CancellationToken.None); + + Assert.That(result.Result, Is.InstanceOf()); + } + + [Test] + public async Task WhenUpdatingTheProfileWithAMissingField_ThenBadRequestIsReturned() + { + var user = AddUser(); + _users.Add(user); + AuthenticateAs(user.Id); + + var result = await _controller.UpdateMe(new UpdateProfileRequest("", "Jones", "bob@example.com"), CancellationToken.None); + + Assert.That(result.Result, Is.InstanceOf()); + } + + [Test] + public async Task WhenUpdatingTheProfileWithAnEmailTakenByAnotherUser_ThenConflictIsReturned() + { + var user = AddUser(id: 1, email: "alice@example.com"); + var otherUser = AddUser(id: 2, email: "taken@example.com"); + _users.Add(user); + _users.Add(otherUser); + AuthenticateAs(user.Id); + + var result = await _controller.UpdateMe(new UpdateProfileRequest("Alice", "Smith", "taken@example.com"), CancellationToken.None); + + Assert.That(result.Result, Is.InstanceOf()); + } + + [Test] + public async Task WhenUpdatingTheProfileForAUserThatNoLongerExists_ThenNotFoundIsReturned() + { + AuthenticateAs(999); + + var result = await _controller.UpdateMe(new UpdateProfileRequest("Bob", "Jones", "bob@example.com"), CancellationToken.None); + + Assert.That(result.Result, Is.InstanceOf()); + } + + [Test] + public async Task WhenUpdatingTheProfileWithValidData_ThenTheUpdatedProfileIsReturned() + { + var user = AddUser(); + _users.Add(user); + AuthenticateAs(user.Id); + + var result = await _controller.UpdateMe(new UpdateProfileRequest(" Bob ", " Jones ", " bob@example.com "), CancellationToken.None); + + var ok = result.Result as OkObjectResult; + Assert.That(ok, Is.Not.Null); + var response = (UserResponse)ok!.Value!; + Assert.That(response.FirstName, Is.EqualTo("Bob")); + Assert.That(response.LastName, Is.EqualTo("Jones")); + Assert.That(response.Email, Is.EqualTo("bob@example.com")); + } + + [Test] + public async Task WhenChangingPasswordWithoutAuthentication_ThenUnauthorizedIsReturned() + { + AuthenticateAs(null); + + var result = await _controller.ChangePassword(new ChangePasswordRequest("current", "new"), CancellationToken.None); + + Assert.That(result, Is.InstanceOf()); + } + + [Test] + public async Task WhenChangingPasswordWithAMissingField_ThenBadRequestIsReturned() + { + var user = AddUser(); + _users.Add(user); + AuthenticateAs(user.Id); + + var result = await _controller.ChangePassword(new ChangePasswordRequest("", "new"), CancellationToken.None); + + Assert.That(result, Is.InstanceOf()); + } + + [Test] + public async Task WhenChangingPasswordWithAnIncorrectCurrentPassword_ThenBadRequestIsReturned() + { + var user = AddUser(); + _users.Add(user); + AuthenticateAs(user.Id); + + var result = await _controller.ChangePassword(new ChangePasswordRequest("wrong-password", "new-password"), CancellationToken.None); + + Assert.That(result, Is.InstanceOf()); + } + + [Test] + public async Task WhenChangingPasswordWithACorrectCurrentPassword_ThenNoContentIsReturned() + { + var user = AddUser(); + _users.Add(user); + AuthenticateAs(user.Id); + + var result = await _controller.ChangePassword(new ChangePasswordRequest("correct-password", "new-password"), CancellationToken.None); + + Assert.That(result, Is.InstanceOf()); + } +} diff --git a/tests/api/MicCheck.Api.Tests.Unit/Webhooks/CreateWebhookRequestValidatorTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Webhooks/CreateWebhookRequestValidatorTests.cs new file mode 100644 index 0000000..cd54f26 --- /dev/null +++ b/tests/api/MicCheck.Api.Tests.Unit/Webhooks/CreateWebhookRequestValidatorTests.cs @@ -0,0 +1,46 @@ +using MicCheck.Api.Webhooks; +using NUnit.Framework; + +namespace MicCheck.Api.Tests.Unit.Webhooks; + +[TestFixture] +public class CreateWebhookRequestValidatorTests +{ + private CreateWebhookRequestValidator _validator = null!; + + [SetUp] + public void SetUp() => _validator = new CreateWebhookRequestValidator(); + + [Test] + public void WhenTheRequestIsValid_ThenValidationSucceeds() + { + var result = _validator.Validate(new CreateWebhookRequest("https://example.com/hook", "secret", true)); + + Assert.That(result.IsValid, Is.True); + } + + [Test] + public void WhenUrlIsEmpty_ThenValidationFails() + { + var result = _validator.Validate(new CreateWebhookRequest("", null, true)); + + Assert.That(result.IsValid, Is.False); + } + + [Test] + public void WhenUrlExceedsMaximumLength_ThenValidationFails() + { + var longUrl = "https://example.com/" + new string('a', 500); + var result = _validator.Validate(new CreateWebhookRequest(longUrl, null, true)); + + Assert.That(result.IsValid, Is.False); + } + + [Test] + public void WhenUrlIsNotAnAbsoluteUri_ThenValidationFails() + { + var result = _validator.Validate(new CreateWebhookRequest("not a valid url", null, true)); + + Assert.That(result.IsValid, Is.False); + } +} diff --git a/tests/api/MicCheck.Api.Tests.Unit/Webhooks/WebhookDispatcherTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Webhooks/WebhookDispatcherTests.cs index b919d49..df7cb8b 100755 --- a/tests/api/MicCheck.Api.Tests.Unit/Webhooks/WebhookDispatcherTests.cs +++ b/tests/api/MicCheck.Api.Tests.Unit/Webhooks/WebhookDispatcherTests.cs @@ -163,6 +163,95 @@ public class WebhookDispatcherTests Assert.That(log.ResponseStatusCode, Is.EqualTo(500)); } + [Test] + public async Task WhenAnEnvironmentScopedEventIsDispatched_ThenOrganizationScopedWebhooksForThatOrgAlsoReceiveIt() + { + var (db, webhooks, deliveryLogs, factory, _) = SetUpDispatcher(HttpStatusCode.OK); + + const int orgId = 1; + const int envId = 5; + webhooks.Add(new Webhook { Url = "https://env.example.com", Scope = WebhookScope.Environment, EnvironmentId = envId, Enabled = true, CreatedAt = DateTimeOffset.UtcNow }); + webhooks.Add(new Webhook { Url = "https://org.example.com", Scope = WebhookScope.Organization, OrganizationId = orgId, Enabled = true, CreatedAt = DateTimeOffset.UtcNow }); + webhooks.Add(new Webhook { Url = "https://other-env.example.com", Scope = WebhookScope.Environment, EnvironmentId = 999, Enabled = true, CreatedAt = DateTimeOffset.UtcNow }); + + var dispatcher = new WebhookDispatcher(db.Object, factory, NullLogger.Instance); + await dispatcher.DispatchAsync(new WebhookEvent + { + EventType = WebhookEventTypes.FlagUpdated, + EnvironmentId = envId, + OrganizationId = orgId, + Data = new { } + }); + + Assert.That(deliveryLogs, Has.Count.EqualTo(2)); + } + + [Test] + public async Task WhenAnOrganizationScopedEventIsDispatched_ThenEnvironmentScopedWebhooksAreNotIncluded() + { + var (db, webhooks, deliveryLogs, factory, _) = SetUpDispatcher(HttpStatusCode.OK); + + const int orgId = 1; + webhooks.Add(new Webhook { Url = "https://env.example.com", Scope = WebhookScope.Environment, EnvironmentId = 5, Enabled = true, CreatedAt = DateTimeOffset.UtcNow }); + webhooks.Add(new Webhook { Url = "https://org.example.com", Scope = WebhookScope.Organization, OrganizationId = orgId, Enabled = true, CreatedAt = DateTimeOffset.UtcNow }); + + var dispatcher = new WebhookDispatcher(db.Object, factory, NullLogger.Instance); + await dispatcher.DispatchAsync(new WebhookEvent + { + EventType = WebhookEventTypes.AuditLogCreated, + EnvironmentId = null, + OrganizationId = orgId, + Data = new { } + }); + + Assert.That(deliveryLogs, Has.Count.EqualTo(1)); + } + + [Test] + public async Task WhenAWebhookIsDisabled_ThenItIsExcludedFromDispatch() + { + var (db, webhooks, deliveryLogs, factory, _) = SetUpDispatcher(HttpStatusCode.OK); + + const int orgId = 1; + webhooks.Add(new Webhook { Url = "https://example.com", Scope = WebhookScope.Organization, OrganizationId = orgId, Enabled = false, CreatedAt = DateTimeOffset.UtcNow }); + + var dispatcher = new WebhookDispatcher(db.Object, factory, NullLogger.Instance); + await dispatcher.DispatchAsync(new WebhookEvent + { + EventType = WebhookEventTypes.FlagUpdated, + OrganizationId = orgId, + Data = new { } + }); + + Assert.That(deliveryLogs, Is.Empty); + } + + [Test] + public async Task WhenTheHttpClientThrows_ThenTheDeliveryLogRecordsTheErrorMessage() + { + var db = new Mock(); + var webhooks = new List { new() { Url = "https://example.com", Scope = WebhookScope.Organization, OrganizationId = 1, Enabled = true, CreatedAt = DateTimeOffset.UtcNow } }; + db.SetupDbSetWithGeneratedIds(c => c.Webhooks, webhooks); + var deliveryLogs = new List(); + db.SetupDbSet(c => c.WebhookDeliveryLogs, deliveryLogs); + + var handler = new Mock(); + handler.Protected() + .Setup>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .ThrowsAsync(new HttpRequestException("Connection refused")); + var httpClient = new HttpClient(handler.Object); + var factory = new Mock(); + factory.Setup(f => f.CreateClient(It.IsAny())).Returns(httpClient); + + var dispatcher = new WebhookDispatcher(db.Object, factory.Object, NullLogger.Instance); + await dispatcher.DispatchAsync(new WebhookEvent { EventType = WebhookEventTypes.FlagUpdated, OrganizationId = 1, Data = new { } }); + + var log = deliveryLogs.First(); + Assert.That(log.Success, Is.False); + Assert.That(log.ErrorMessage, Is.EqualTo("Connection refused")); + Assert.That(log.ResponseStatusCode, Is.Null); + } + [Test] public async Task WhenNoWebhooksAreRegistered_ThenNoDeliveryLogsAreCreated() { diff --git a/tests/api/MicCheck.Api.Tests.Unit/Webhooks/WebhookQueueTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Webhooks/WebhookQueueTests.cs new file mode 100644 index 0000000..f6c9a9e --- /dev/null +++ b/tests/api/MicCheck.Api.Tests.Unit/Webhooks/WebhookQueueTests.cs @@ -0,0 +1,48 @@ +using MicCheck.Api.Webhooks; +using NUnit.Framework; + +namespace MicCheck.Api.Tests.Unit.Webhooks; + +[TestFixture] +public class WebhookQueueTests +{ + [Test] + public async Task WhenAnEventIsEnqueued_ThenItCanBeReadBack() + { + var queue = new WebhookQueue(); + var webhookEvent = new WebhookEvent + { + EventType = WebhookEventTypes.FlagUpdated, + OrganizationId = 1, + Data = new { } + }; + + await queue.EnqueueAsync(webhookEvent); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(1)); + await using var enumerator = queue.ReadAllAsync(cts.Token).GetAsyncEnumerator(cts.Token); + await enumerator.MoveNextAsync(); + + Assert.That(enumerator.Current, Is.SameAs(webhookEvent)); + } + + [Test] + public async Task WhenMultipleEventsAreEnqueued_ThenTheyAreReadInOrder() + { + var queue = new WebhookQueue(); + var first = new WebhookEvent { EventType = WebhookEventTypes.FlagUpdated, OrganizationId = 1, Data = new { } }; + var second = new WebhookEvent { EventType = WebhookEventTypes.FlagDeleted, OrganizationId = 1, Data = new { } }; + + await queue.EnqueueAsync(first); + await queue.EnqueueAsync(second); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(1)); + await using var enumerator = queue.ReadAllAsync(cts.Token).GetAsyncEnumerator(cts.Token); + + await enumerator.MoveNextAsync(); + Assert.That(enumerator.Current, Is.SameAs(first)); + + await enumerator.MoveNextAsync(); + Assert.That(enumerator.Current, Is.SameAs(second)); + } +} diff --git a/tests/api/MicCheck.Api.Tests.Unit/Webhooks/WebhookResponseTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Webhooks/WebhookResponseTests.cs new file mode 100644 index 0000000..1b216e2 --- /dev/null +++ b/tests/api/MicCheck.Api.Tests.Unit/Webhooks/WebhookResponseTests.cs @@ -0,0 +1,110 @@ +using MicCheck.Api.Webhooks; +using NUnit.Framework; + +namespace MicCheck.Api.Tests.Unit.Webhooks; + +[TestFixture] +public class WebhookResponseTests +{ + [Test] + public void WhenMappingAnEnvironmentScopedWebhook_ThenScopeIsSerializedAsItsName() + { + var webhook = new Webhook + { + Id = 1, + Url = "https://example.com/hook", + Secret = "s3cr3t", + Scope = WebhookScope.Environment, + EnvironmentId = 5, + Enabled = true, + CreatedAt = DateTimeOffset.UtcNow + }; + + var response = WebhookResponse.From(webhook); + + Assert.That(response.Id, Is.EqualTo(1)); + Assert.That(response.Url, Is.EqualTo("https://example.com/hook")); + Assert.That(response.Secret, Is.EqualTo("s3cr3t")); + Assert.That(response.Scope, Is.EqualTo("Environment")); + Assert.That(response.Enabled, Is.True); + Assert.That(response.EnvironmentId, Is.EqualTo(5)); + Assert.That(response.OrganizationId, Is.Null); + } + + [Test] + public void WhenMappingAnOrganizationScopedWebhook_ThenScopeIsSerializedAsItsName() + { + var webhook = new Webhook + { + Id = 2, + Url = "https://example.com/hook", + Scope = WebhookScope.Organization, + OrganizationId = 9, + Enabled = false, + CreatedAt = DateTimeOffset.UtcNow + }; + + var response = WebhookResponse.From(webhook); + + Assert.That(response.Scope, Is.EqualTo("Organization")); + Assert.That(response.OrganizationId, Is.EqualTo(9)); + Assert.That(response.EnvironmentId, Is.Null); + } +} + +[TestFixture] +public class WebhookDeliveryLogResponseTests +{ + [Test] + public void WhenMappingASuccessfulDelivery_ThenAllFieldsAreCopied() + { + var log = new WebhookDeliveryLog + { + Id = 1, + WebhookId = 2, + EventType = "FLAG_UPDATED", + PayloadJson = "{}", + ResponseStatusCode = 200, + ResponseBody = "ok", + Success = true, + AttemptNumber = 1, + AttemptedAt = DateTimeOffset.UtcNow, + Duration = TimeSpan.FromMilliseconds(42) + }; + + var response = WebhookDeliveryLogResponse.From(log); + + Assert.That(response.Id, Is.EqualTo(1)); + Assert.That(response.WebhookId, Is.EqualTo(2)); + Assert.That(response.EventType, Is.EqualTo("FLAG_UPDATED")); + Assert.That(response.Success, Is.True); + Assert.That(response.ResponseStatusCode, Is.EqualTo(200)); + Assert.That(response.ResponseBody, Is.EqualTo("ok")); + Assert.That(response.ErrorMessage, Is.Null); + Assert.That(response.AttemptNumber, Is.EqualTo(1)); + Assert.That(response.Duration, Is.EqualTo(TimeSpan.FromMilliseconds(42))); + } + + [Test] + public void WhenMappingAFailedDelivery_ThenErrorMessageIsCopied() + { + var log = new WebhookDeliveryLog + { + Id = 1, + WebhookId = 2, + EventType = "FLAG_UPDATED", + PayloadJson = "{}", + Success = false, + ErrorMessage = "Connection refused", + AttemptNumber = 2, + AttemptedAt = DateTimeOffset.UtcNow, + Duration = TimeSpan.Zero + }; + + var response = WebhookDeliveryLogResponse.From(log); + + Assert.That(response.Success, Is.False); + Assert.That(response.ErrorMessage, Is.EqualTo("Connection refused")); + Assert.That(response.ResponseStatusCode, Is.Null); + } +} diff --git a/tests/api/MicCheck.Api.Tests.Unit/coverlet.runsettings b/tests/api/MicCheck.Api.Tests.Unit/coverlet.runsettings new file mode 100644 index 0000000..1ff4db3 --- /dev/null +++ b/tests/api/MicCheck.Api.Tests.Unit/coverlet.runsettings @@ -0,0 +1,12 @@ + + + + + + + [MicCheck.Api]MicCheck.Api.Migrations.*,[MicCheck.Api]MicCheck.Api.Data.* + + + + +