Compare commits

..

12 Commits

Author SHA1 Message Date
James Wampler
9e8e81ba83 Add unit tests for Audit (59% to 100%) and Webhooks (62% to 91%) namespaces
Covers AuditLogQueryService filtering/paging/actor-name join,
AuditLogResponse mapping, AuditLogsController, AuditService.LogAsync
and actor-claim resolution, WebhookResponse/WebhookDeliveryLogResponse
mapping, CreateWebhookRequestValidator, WebhookQueue, and additional
WebhookDispatcher branch coverage (scope routing, disabled webhooks,
delivery exceptions).

Excludes WebhookBackgroundService and WebhookRetryBackgroundService
from coverage: both resolve a DI-scoped concrete MicCheckDbContext via
IServiceScopeFactory, which requires a live DI container/DB per
CLAUDE.md's no-InMemory/WebApplicationFactory rule. Their query and
dispatch logic is covered by WebhookRetryTests/WebhookDispatcherTests.
2026-07-05 15:04:03 -07:00
James Wampler
68d426253d Add unit tests for Environments namespace (0% to 99.5% coverage)
Covers EnvironmentService (CRUD, clone, list, find-by-key), the
EnvironmentsController orchestration layer (webhooks, audit logs,
identities/traits, feature states, feature segments, identity
segments), EnvironmentDocumentService/Controller, response mappers,
and request validators.
2026-07-05 15:04:03 -07:00
James Wampler
5b3050a222 Reformat and tweaking claude.md 2026-07-05 15:04:03 -07:00
James Wampler
51539d24d8 Add unit tests for MicCheck.Api.Organizations and MicCheck.Api.Projects
Both namespaces had only entity tests. Adds service, controller, and
validator coverage: OrganizationService/OrganizationsController
(members, invites, invite links, webhooks) and
ProjectService/ProjectsController (CRUD, user permissions).

OrganizationService.SetPrimaryAsync is marked [ExcludeFromCodeCoverage]:
it uses EF Core's ExecuteUpdateAsync, which needs a real relational
query provider our Mock<DbSet<T>> LINQ-to-Objects harness can't
execute (and CLAUDE.md disallows EF InMemory as a substitute). Its
early-return branch is still covered.

Raises Organizations from 3.5% to 98.8% and Projects from 19.6% to
99.3%.
2026-07-05 15:04:03 -07:00
James Wampler
67d7de0362 Add unit tests for MicCheck.Api.Identities
Covers AdminIdentityService (list/create/find/upsert-trait/delete-trait/
delete/feature-state get-set-delete, all previously 0%), the
AdminIdentityResponse mapping, TraitInput's JSON/plain-type branch
logic, and IdentitiesController.GetByIdentifier.

Raises the namespace from 31% to 97.5%.
2026-07-05 15:04:03 -07:00
James Wampler
7d4ab7991c Exclude Data namespace from dotnet code coverage
DbContext/EF configuration classes are infra plumbing with no
business logic to unit test; counting them against the coverage
target only dilutes the signal, same rationale as Migrations.
2026-07-05 15:04:03 -07:00
James Wampler
145e1f1be9 Add unit tests for MicCheck.Api.Features
Covers validators, response mapping, TagService/TagsController,
FeaturesController (CRUD + tag assignment), FeatureSegmentService,
FeatureStateService (incl. webhook dispatch), and
FeatureUsageController. Fills gaps in FeatureService
(FindByIdAsync, not-found exceptions).

FeatureUsageFlushBackgroundService is marked [ExcludeFromCodeCoverage]:
timer-driven, issues raw SQL through a DI-scoped concrete DbContext,
can't be exercised cleanly without a live DB (CLAUDE.md disallows
WebApplicationFactory/InMemory).

Raises the namespace from 44% to 97%.
2026-07-05 15:04:03 -07:00
James Wampler
611557ce81 Add unit tests for MicCheck.Api.Common, exclude untestable endpoint glue
ApiKeyService had zero coverage despite real logic (key generation,
hashing, org-scoped revoke). Also filled branch gaps in AuthService
(logout with unknown token) and the auth handlers (empty header
values).

ApiKeyEndpoints/AuthEndpoints are marked [ExcludeFromCodeCoverage]:
they're minimal-API route registration that needs a live HTTP
pipeline to exercise, which CLAUDE.md disallows (no
WebApplicationFactory/InMemory). Their branch logic is already
covered via the underlying service unit tests.

Raises the namespace from 66% to 91%. Remaining gap is plain
positional records with no logic (LoginRequest, PagedResult, etc.) -
left untested per CLAUDE.md's guidance against coverage-only tests.
2026-07-05 15:04:03 -07:00
James Wampler
b59c81f3b2 Compress CLAUDE.md into caveman-speak to cut context tokens
Original kept as CLAUDE.original.md for human reference.
2026-07-05 15:04:03 -07:00
James Wampler
b8ce405068 Add unit tests for MicCheck.Api.Segments
Covers CreateSegmentRequestValidator, response mapping (incl. nested
rules), SegmentsController, and gaps in SegmentService/SegmentEvaluator
(FindByIdAsync, not-found paths, nested rules, modulo/percentage edge
cases), raising the namespace from 66% to 98% coverage.
2026-07-05 15:04:03 -07:00
James Wampler
2759314f63 Add unit tests for MicCheck.Api.Users
Covers UserService (profile update, email conflict, password change)
and UsersController (auth/validation/not-found/conflict branches),
raising the namespace from 3% coverage.
2026-07-05 15:04:03 -07:00
James Wampler
06e5359674 Exclude Migrations namespace from dotnet code coverage
EF migrations are generated scaffolding with no meaningful logic to
test; counting them against the coverage target only dilutes the
signal.
2026-07-05 15:04:03 -07:00
92 changed files with 411 additions and 727 deletions

View File

@@ -61,7 +61,6 @@ jobs:
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
JWT_SECRET_KEY: ${{ secrets.JWT_SECRET_KEY }}
POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
QA_ADMIN_PORT: ${{ vars.QA_ADMIN_PORT }}
steps:
# actions/checkout@v4 is a Node-based action; this runner has no node
@@ -82,7 +81,6 @@ jobs:
runs-on: [self-hosted, qa]
env:
QA_ADMIN_PORT: ${{ vars.QA_ADMIN_PORT }}
POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
steps:
# actions/checkout@v4 is a Node-based action; this runner has no node
# in PATH, so checkout plain git instead of via marketplace action.

View File

@@ -3,6 +3,7 @@
<File Path=".editorconfig" />
<File Path=".gitignore" />
<File Path="CLAUDE.md" />
<File Path="docker-compose.yml" />
<File Path="readme.md" />
</Folder>
<Folder Name="/src/">

View File

@@ -123,7 +123,7 @@
<text x="53" y="15" fill="#010101" fill-opacity=".3">Coverage</text>
<text x="53" y="14" fill="#fff">Coverage</text>
<text class="" x="132.5" y="15" fill="#010101" fill-opacity=".3">68.9%</text><text class="" x="132.5" y="14">68.9%</text>
<text class="" x="132.5" y="15" fill="#010101" fill-opacity=".3">29.8%</text><text class="" x="132.5" y="14">29.8%</text>

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

View File

@@ -9,5 +9,4 @@ REGISTRY_TOKEN=changeme
# QA environment
JWT_SECRET_KEY=change-this-to-a-random-32-plus-char-secret
POSTGRES_PASSWORD=change-this-to-a-random-password
QA_ADMIN_PORT=3001

View File

@@ -13,7 +13,7 @@ services:
environment:
POSTGRES_DB: miccheck
POSTGRES_USER: miccheck
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
POSTGRES_PASSWORD: password
ports:
- "${DB_BIND_HOST:-127.0.0.1}:55432:5432"
volumes:
@@ -31,7 +31,7 @@ services:
environment:
ASPNETCORE_ENVIRONMENT: Development
ASPNETCORE_URLS: http://+:8080
ConnectionStrings__miccheck: "Host=db;Database=miccheck;Username=miccheck;Password=${POSTGRES_PASSWORD}"
ConnectionStrings__miccheck: "Host=db;Database=miccheck;Username=miccheck;Password=password"
Jwt__SecretKey: ${JWT_SECRET_KEY}
Jwt__Issuer: MicCheck
Jwt__Audience: MicCheck

View File

@@ -3,19 +3,27 @@ set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
echo "Building solution..."
dotnet build "$SCRIPT_DIR/MicCheck.slnx"
build_api() {
echo "Building API..."
dotnet publish "$SCRIPT_DIR/src/api/MicCheck.Api/MicCheck.Api.csproj" -c Release -o "$SCRIPT_DIR/src/api/MicCheck.Api/publish"
docker compose -f "$SCRIPT_DIR/docker-compose.yml" restart api
echo "API done."
}
echo "Installing admin dependencies..."
npm --prefix "$SCRIPT_DIR/src/admin" ci --silent
build_admin() {
echo "Building admin..."
npm --prefix "$SCRIPT_DIR/src/admin" ci --silent
npm --prefix "$SCRIPT_DIR/src/admin" run build
docker compose -f "$SCRIPT_DIR/docker-compose.yml" restart admin
echo "Admin done."
}
echo "Building admin..."
npm --prefix "$SCRIPT_DIR/src/admin" run build
echo "Running .NET unit tests..."
dotnet test "$SCRIPT_DIR/tests/api/MicCheck.Api.Tests.Unit/MicCheck.Api.Tests.Unit.csproj"
echo "Running Jest tests..."
npm --prefix "$SCRIPT_DIR/src/admin" test
echo "Build and tests complete."
case "${1:-all}" in
api) build_api ;;
admin) build_admin ;;
all) build_api && build_admin ;;
*)
echo "Usage: $0 [api|admin|all]"
exit 1
;;
esac

60
docker-compose.yml Executable file
View File

@@ -0,0 +1,60 @@
services:
# ── PostgreSQL ───────────────────────────────────────────────────────────────
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: miccheck
POSTGRES_USER: miccheck
POSTGRES_PASSWORD: password
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U miccheck -d miccheck"]
interval: 5s
timeout: 5s
retries: 10
# ── .NET API ─────────────────────────────────────────────────────────────────
api:
build:
context: .
dockerfile: src/api/MicCheck.Api/Dockerfile
ports:
- "8080:8080"
volumes:
- ./src/api/MicCheck.Api/publish:/app
environment:
ASPNETCORE_ENVIRONMENT: Development
ASPNETCORE_URLS: http://+:8080
ConnectionStrings__DefaultConnection: "Host=db;Database=miccheck;Username=miccheck;Password=password"
Jwt__SecretKey: "miccheck-dev-secret-key-change-in-production!!"
Jwt__Issuer: MicCheck
Jwt__Audience: MicCheck
depends_on:
db:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/api/v1/health 2>/dev/null || exit 0"]
interval: 10s
timeout: 5s
retries: 5
start_period: 20s
# ── Vue Admin Site (nginx) ───────────────────────────────────────────────────
admin:
build:
context: src/admin
dockerfile: Dockerfile
ports:
- "3000:80"
volumes:
- ./src/admin/dist:/usr/share/nginx/html
depends_on:
api:
condition: service_started
volumes:
postgres_data:

View File

@@ -29,11 +29,10 @@ Code in the API is organized by feature area (e.g. `Features`, `Segments`, `Iden
## Running locally
`MicCheck.AppHost` (.NET Aspire) orchestrates the API, admin app, and supporting services for local development.
`docker-compose.yml` provides supporting services. `MicCheck.AppHost` (.NET Aspire) orchestrates the API and admin app for local development.
```sh
./dev-build.sh
aspire run --project src/MicCheck.AppHost
```
## Testing

View File

@@ -11,7 +11,6 @@ registry_login
export API_IMAGE ADMIN_IMAGE
export JWT_SECRET_KEY="${JWT_SECRET_KEY:?JWT_SECRET_KEY env var is required}"
export POSTGRES_PASSWORD="${POSTGRES_PASSWORD:?POSTGRES_PASSWORD env var is required}"
export QA_ADMIN_PORT="${QA_ADMIN_PORT:-3001}"
# Bind narrowly to docker's bridge gateway IP rather than 0.0.0.0: reachable

View File

@@ -22,7 +22,7 @@ BASE_URL="http://${CI_HOST}:${QA_ADMIN_PORT}"
log "Resolved docker host as $CI_HOST for reaching the QA stack's published ports"
export MICCHECK_API_BASE_URL="$BASE_URL"
export MICCHECK_DB_CONNECTION_STRING="${MICCHECK_DB_CONNECTION_STRING:-Host=$CI_HOST;Port=55432;Database=miccheck;Username=miccheck;Password=${POSTGRES_PASSWORD:?POSTGRES_PASSWORD env var is required}}"
export MICCHECK_DB_CONNECTION_STRING="${MICCHECK_DB_CONNECTION_STRING:-Host=$CI_HOST;Port=55432;Database=miccheck;Username=miccheck;Password=password}"
log "Running API integration suite against $BASE_URL"
dotnet test tests/api/MicCheck.Api.Tests.Integration/MicCheck.Api.Tests.Integration.csproj -c Release --logger trx

View File

@@ -6012,15 +6012,16 @@
}
},
"node_modules/form-data": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
"integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.4",
"mime-types": "^2.1.35"
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
@@ -8429,10 +8430,11 @@
"license": "MIT"
},
"node_modules/js-yaml": {
"version": "3.15.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.0.tgz",
"integrity": "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==",
"version": "3.14.2",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz",
"integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==",
"dev": true,
"license": "MIT",
"dependencies": {
"argparse": "^1.0.7",
"esprima": "^4.0.0"

View File

@@ -1,6 +1,6 @@
namespace MicCheck.Api.Audit;
public record AuditLog
public class AuditLog
{
public int Id { get; init; }
public required string ResourceType { get; init; }

View File

@@ -6,25 +6,29 @@ namespace MicCheck.Api.Audit;
public class AuditLogQueryService(IMicCheckDbContext db)
{
public async Task<PagedResult<AuditLogResponse>> ListByOrganizationAsync(int organizationId, AuditLogFilter filter, CancellationToken ct = default)
public async Task<PagedResult<AuditLogResponse>> ListByOrganizationAsync(
int organizationId, AuditLogFilter filter, CancellationToken ct = default)
{
var query = db.AuditLogs.Where(l => l.OrganizationId == organizationId);
return await ApplyFilterAndPageAsync(query, filter, ct);
}
public async Task<PagedResult<AuditLogResponse>> ListByProjectAsync(int projectId, AuditLogFilter filter, CancellationToken ct = default)
public async Task<PagedResult<AuditLogResponse>> ListByProjectAsync(
int projectId, AuditLogFilter filter, CancellationToken ct = default)
{
var query = db.AuditLogs.Where(l => l.ProjectId == projectId);
return await ApplyFilterAndPageAsync(query, filter, ct);
}
public async Task<PagedResult<AuditLogResponse>> ListByEnvironmentAsync(int environmentId, AuditLogFilter filter, CancellationToken ct = default)
public async Task<PagedResult<AuditLogResponse>> ListByEnvironmentAsync(
int environmentId, AuditLogFilter filter, CancellationToken ct = default)
{
var query = db.AuditLogs.Where(l => l.EnvironmentId == environmentId);
return await ApplyFilterAndPageAsync(query, filter, ct);
}
private async Task<PagedResult<AuditLogResponse>> ApplyFilterAndPageAsync(IQueryable<AuditLog> query, AuditLogFilter filter, CancellationToken ct)
private async Task<PagedResult<AuditLogResponse>> ApplyFilterAndPageAsync(
IQueryable<AuditLog> query, AuditLogFilter filter, CancellationToken ct)
{
if (filter.From.HasValue)
query = query.Where(l => l.CreatedAt >= filter.From.Value);
@@ -62,4 +66,4 @@ public class AuditLogQueryService(IMicCheckDbContext db)
return new PagedResult<AuditLogResponse>(total, items);
}
}
}

View File

@@ -4,7 +4,7 @@ using MicCheck.Api.Webhooks;
namespace MicCheck.Api.Audit;
public class AuditService(IMicCheckDbContext db, IHttpContextAccessor httpContextAccessor, WebhookQueue webhookQueue) : IAuditService
public class AuditService(IMicCheckDbContext db, IHttpContextAccessor httpContextAccessor, WebhookQueue webhookQueue)
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
@@ -12,7 +12,7 @@ public class AuditService(IMicCheckDbContext db, IHttpContextAccessor httpContex
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
public async Task RecordAsync(
public virtual async Task RecordAsync(
string resourceType,
string resourceId,
string action,
@@ -33,7 +33,7 @@ public class AuditService(IMicCheckDbContext db, IHttpContextAccessor httpContex
}, JsonOptions);
}
var actorUserId = GetCurrentUserId();
var actorUserId = ResolveActorUserId();
var log = new AuditLog
{
@@ -68,7 +68,7 @@ public class AuditService(IMicCheckDbContext db, IHttpContextAccessor httpContex
}
// Backward-compatible overload used by existing callers
public async Task LogAsync(
public virtual async Task LogAsync(
string resourceType,
string resourceId,
string action,
@@ -78,7 +78,7 @@ public class AuditService(IMicCheckDbContext db, IHttpContextAccessor httpContex
string? changes = null,
CancellationToken ct = default)
{
var actorUserId = GetCurrentUserId();
var actorUserId = ResolveActorUserId();
var log = new AuditLog
{
@@ -112,33 +112,9 @@ public class AuditService(IMicCheckDbContext db, IHttpContextAccessor httpContex
});
}
private int? GetCurrentUserId()
private int? ResolveActorUserId()
{
var claim = httpContextAccessor.HttpContext?.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
return int.TryParse(claim, out var id) ? id : null;
}
}
public interface IAuditService
{
Task RecordAsync(
string resourceType,
string resourceId,
string action,
int organizationId,
int? projectId = null,
int? environmentId = null,
object? before = null,
object? after = null,
CancellationToken ct = default);
Task LogAsync(
string resourceType,
string resourceId,
string action,
int organizationId,
int? projectId = null,
int? environmentId = null,
string? changes = null,
CancellationToken ct = default);
}

View File

@@ -1,12 +0,0 @@
namespace MicCheck.Api.Audit;
public static class DependencyRegistration
{
public static IServiceCollection AddAuditServices(this IServiceCollection services)
{
services.AddScoped<IAuditService, AuditService>();
services.AddScoped<AuditLogQueryService>();
return services;
}
}

View File

@@ -1,65 +0,0 @@
using System.Text;
using MicCheck.Api.Common.Security.ApiKeys;
using MicCheck.Api.Common.Security.Authentication;
using MicCheck.Api.Common.Security.Authorization;
using MicCheck.Api.Common.Validation;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.IdentityModel.Tokens;
namespace MicCheck.Api.Common;
public static class DependencyRegistration
{
public static IServiceCollection AddCommonServices(this IServiceCollection services, IConfiguration configuration)
{
services.AddAuthentication()
.AddScheme<AuthenticationSchemeOptions, EnvironmentKeyAuthenticationHandler>(
EnvironmentKeyAuthenticationHandler.SchemeName, _ => { })
.AddScheme<AuthenticationSchemeOptions, ApiKeyAuthenticationHandler>(
ApiKeyAuthenticationHandler.SchemeName, _ => { })
.AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateIssuerSigningKey = true,
ValidIssuer = configuration["Jwt:Issuer"],
ValidAudience = configuration["Jwt:Audience"],
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(configuration["Jwt:SecretKey"]!))
};
});
services.AddAuthorization(options =>
{
options.AddPolicy(AuthorizationPolicies.FlagsApiAccess, policy =>
policy.AddAuthenticationSchemes(EnvironmentKeyAuthenticationHandler.SchemeName)
.RequireClaim("EnvironmentId"));
options.AddPolicy(AuthorizationPolicies.AdminApiAccess, policy =>
policy.AddAuthenticationSchemes(ApiKeyAuthenticationHandler.SchemeName, JwtBearerDefaults.AuthenticationScheme)
.RequireAuthenticatedUser());
options.AddPolicy(AuthorizationPolicies.OrganizationAdmin, policy =>
policy.AddAuthenticationSchemes(ApiKeyAuthenticationHandler.SchemeName, JwtBearerDefaults.AuthenticationScheme)
.RequireClaim("OrganizationRole", "Admin"));
});
services.AddScoped<ITokenService, TokenService>();
services.AddScoped<AuthService>();
services.AddScoped<ApiKeyService>();
services.AddScoped<IAuthorizationHandler, ProjectPermissionRequirementHandler>();
services.AddModelValidatorsFromAssemblyContaining<Program>();
services.Configure<ApiBehaviorOptions>(options =>
{
options.InvalidModelStateResponseFactory = context => ValidationProblemResponseFactory.Create(context.ModelState);
});
return services;
}
}

View File

@@ -1,40 +0,0 @@
namespace MicCheck.Api.Common;
public static class Guard
{
public static void Null<T>(T t, string parameterName) where T : class
{
if (t is null)
throw new ArgumentNullException(parameterName, $"{nameof(parameterName)} can not be null");
}
public static void Empty(string value, string parameterName)
{
if (string.IsNullOrWhiteSpace(value))
throw new ArgumentException($"{parameterName} can not be empty", parameterName);
}
public static void Empty<T>(IEnumerable<T> collection, string parameterName)
{
if (collection == null || !collection.Any())
throw new ArgumentException($"{parameterName} can not be empty", parameterName);
}
public static void Negative(int value, string parameterName)
{
if (value < 0)
throw new ArgumentOutOfRangeException(parameterName, $"{parameterName} must be a positive number or zero");
}
public static void NegativeOrZero(int value, string parameterName)
{
if (value <= 0)
throw new ArgumentOutOfRangeException(parameterName, $"{nameof(parameterName)} must be a positive number greater then zero");
}
public static void Default<T>(T value, string parameterName)
{
if (EqualityComparer<T>.Default.Equals(value, default))
throw new ArgumentException($"{parameterName} can not be a default value", parameterName);
}
}

View File

@@ -2,4 +2,9 @@ using Microsoft.AspNetCore.Authorization;
namespace MicCheck.Api.Common.Security.Authorization;
public record ProjectPermissionRequirement(ProjectPermission Permission) : IAuthorizationRequirement;
public class ProjectPermissionRequirement : IAuthorizationRequirement
{
public ProjectPermission Permission { get; }
public ProjectPermissionRequirement(ProjectPermission permission) => Permission = permission;
}

View File

@@ -1,13 +0,0 @@
namespace MicCheck.Api.Common.Validation;
public interface IModelValidator
{
ValidationResult Validate(object model);
}
public interface IModelValidator<in T> : IModelValidator
{
ValidationResult Validate(T model);
ValidationResult IModelValidator.Validate(object model) => Validate((T)model);
}

View File

@@ -1,28 +0,0 @@
using Microsoft.AspNetCore.Mvc.Filters;
namespace MicCheck.Api.Common.Validation;
public class ModelValidationActionFilter : IActionFilter
{
public void OnActionExecuting(ActionExecutingContext context)
{
foreach (var argument in context.ActionArguments.Values)
{
if (argument is null) continue;
var validatorType = typeof(IModelValidator<>).MakeGenericType(argument.GetType());
if (context.HttpContext.RequestServices.GetService(validatorType) is not IModelValidator validator) continue;
var result = validator.Validate(argument);
foreach (var error in result.Errors)
context.ModelState.AddModelError(error.PropertyName, error.Message);
}
if (!context.ModelState.IsValid)
context.Result = ValidationProblemResponseFactory.Create(context.ModelState);
}
public void OnActionExecuted(ActionExecutedContext context)
{
}
}

View File

@@ -1,20 +0,0 @@
using Microsoft.Extensions.DependencyInjection;
namespace MicCheck.Api.Common.Validation;
public static class ModelValidatorServiceCollectionExtensions
{
public static IServiceCollection AddModelValidatorsFromAssemblyContaining<TMarker>(this IServiceCollection services)
{
var registrations = typeof(TMarker).Assembly.GetTypes()
.Where(type => !type.IsAbstract && !type.IsInterface)
.SelectMany(type => type.GetInterfaces()
.Where(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IModelValidator<>))
.Select(i => (Interface: i, Implementation: type)));
foreach (var (@interface, implementation) in registrations)
services.AddScoped(@interface, implementation);
return services;
}
}

View File

@@ -1,18 +0,0 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ModelBinding;
namespace MicCheck.Api.Common.Validation;
public static class ValidationProblemResponseFactory
{
public static IActionResult Create(ModelStateDictionary modelState)
{
var errors = modelState
.Where(e => e.Value?.Errors.Count > 0)
.ToDictionary(
kvp => kvp.Key,
kvp => kvp.Value!.Errors.Select(e => e.ErrorMessage).ToArray());
return new UnprocessableEntityObjectResult(new { errors });
}
}

View File

@@ -1,14 +0,0 @@
namespace MicCheck.Api.Common.Validation;
public record ValidationError(string PropertyName, string Message);
public class ValidationResult
{
private readonly List<ValidationError> _errors = [];
public IReadOnlyList<ValidationError> Errors => _errors;
public bool IsValid => _errors.Count == 0;
public bool IsInvalid => _errors.Count > 0;
public void AddError(string propertyName, string message) => _errors.Add(new ValidationError(propertyName, message));
}

View File

@@ -1,4 +1,4 @@
using MicCheck.Api.Features.Usage;
using MicCheck.Api.Features;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;

View File

@@ -1,21 +0,0 @@
using Microsoft.EntityFrameworkCore;
namespace MicCheck.Api.Data;
public static class DependencyRegistration
{
public static IServiceCollection AddDataServices(this IServiceCollection services, IConfiguration configuration)
{
services.AddScoped<DatabaseSeeder>();
var connectionString = configuration.GetConnectionString("miccheck")
?? (System.Environment.GetEnvironmentVariable("DATABASE_URL") is { } databaseUrl
? DatabaseUrlParser.ToNpgsqlConnectionString(databaseUrl)
: configuration.GetConnectionString("DefaultConnection")!);
services.AddDbContext<MicCheckDbContext>(options => options.UseNpgsql(connectionString));
services.AddScoped<IMicCheckDbContext>(sp => sp.GetRequiredService<MicCheckDbContext>());
return services;
}
}

View File

@@ -2,7 +2,6 @@ using MicCheck.Api.Common.Security.ApiKeys;
using MicCheck.Api.Audit;
using MicCheck.Api.Common.Security.Authorization;
using MicCheck.Api.Features;
using MicCheck.Api.Features.Usage;
using MicCheck.Api.Identities;
using MicCheck.Api.Organizations;
using MicCheck.Api.Projects;

View File

@@ -1,20 +1,13 @@
using MicCheck.Api.Common.Validation;
using FluentValidation;
namespace MicCheck.Api.Environments;
public record CloneEnvironmentRequest(string Name);
public class CloneEnvironmentRequestValidator : IModelValidator<CloneEnvironmentRequest>
public class CloneEnvironmentRequestValidator : AbstractValidator<CloneEnvironmentRequest>
{
public ValidationResult Validate(CloneEnvironmentRequest model)
public CloneEnvironmentRequestValidator()
{
var result = new ValidationResult();
if (string.IsNullOrEmpty(model.Name))
result.AddError(nameof(model.Name), "'Name' must not be empty.");
else if (model.Name.Length > 200)
result.AddError(nameof(model.Name), "'Name' must be 200 characters or fewer.");
return result;
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
}
}

View File

@@ -1,23 +1,14 @@
using MicCheck.Api.Common.Validation;
using FluentValidation;
namespace MicCheck.Api.Environments;
public record CreateEnvironmentRequest(string Name, int ProjectId);
public class CreateEnvironmentRequestValidator : IModelValidator<CreateEnvironmentRequest>
public class CreateEnvironmentRequestValidator : AbstractValidator<CreateEnvironmentRequest>
{
public ValidationResult Validate(CreateEnvironmentRequest model)
public CreateEnvironmentRequestValidator()
{
var result = new ValidationResult();
if (string.IsNullOrEmpty(model.Name))
result.AddError(nameof(model.Name), "'Name' must not be empty.");
else if (model.Name.Length > 200)
result.AddError(nameof(model.Name), "'Name' must be 200 characters or fewer.");
if (model.ProjectId <= 0)
result.AddError(nameof(model.ProjectId), "'Project Id' must be greater than 0.");
return result;
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
RuleFor(x => x.ProjectId).GreaterThan(0);
}
}

View File

@@ -1,12 +0,0 @@
namespace MicCheck.Api.Environments;
public static class DependencyRegistration
{
public static IServiceCollection AddEnvironmentsServices(this IServiceCollection services)
{
services.AddScoped<EnvironmentService>();
services.AddScoped<EnvironmentDocumentService>();
return services;
}
}

View File

@@ -7,7 +7,7 @@ using AppEnvironment = MicCheck.Api.Environments.Environment;
namespace MicCheck.Api.Environments;
public class EnvironmentService(IMicCheckDbContext db, IAuditService auditService)
public class EnvironmentService(IMicCheckDbContext db, AuditService auditService)
{
public async Task<IReadOnlyList<AppEnvironment>> ListByProjectAsync(int projectId, CancellationToken ct = default)
{

View File

@@ -1,20 +1,13 @@
using MicCheck.Api.Common.Validation;
using FluentValidation;
namespace MicCheck.Api.Environments;
public record UpdateEnvironmentRequest(string Name);
public class UpdateEnvironmentRequestValidator : IModelValidator<UpdateEnvironmentRequest>
public class UpdateEnvironmentRequestValidator : AbstractValidator<UpdateEnvironmentRequest>
{
public ValidationResult Validate(UpdateEnvironmentRequest model)
public UpdateEnvironmentRequestValidator()
{
var result = new ValidationResult();
if (string.IsNullOrEmpty(model.Name))
result.AddError(nameof(model.Name), "'Name' must not be empty.");
else if (model.Name.Length > 200)
result.AddError(nameof(model.Name), "'Name' must be 200 characters or fewer.");
return result;
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
}
}

View File

@@ -1,28 +1,26 @@
using System.Text.RegularExpressions;
using MicCheck.Api.Common.Validation;
using FluentValidation;
namespace MicCheck.Api.Features;
public record CreateFeatureRequest(string Name, FeatureType Type, string? InitialValue, string? Description);
public record CreateFeatureRequest(
string Name,
FeatureType Type,
string? InitialValue,
string? Description
);
public class CreateFeatureRequestValidator : IModelValidator<CreateFeatureRequest>
public class CreateFeatureRequestValidator : AbstractValidator<CreateFeatureRequest>
{
private static readonly Regex NamePattern = new("^[a-zA-Z0-9_-]+$");
public ValidationResult Validate(CreateFeatureRequest model)
public CreateFeatureRequestValidator()
{
var result = new ValidationResult();
RuleFor(x => x.Name)
.NotEmpty()
.MaximumLength(150)
.Matches("^[a-zA-Z0-9_-]+$")
.WithMessage("Name may only contain letters, digits, underscores, and hyphens.");
if (string.IsNullOrEmpty(model.Name))
result.AddError(nameof(model.Name), "'Name' must not be empty.");
else if (model.Name.Length > 150)
result.AddError(nameof(model.Name), "'Name' must be 150 characters or fewer.");
else if (!NamePattern.IsMatch(model.Name))
result.AddError(nameof(model.Name), "Name may only contain letters, digits, underscores, and hyphens.");
if (model.InitialValue is not null && model.InitialValue.Length > 20_000)
result.AddError(nameof(model.InitialValue), "'Initial Value' must be 20000 characters or fewer.");
return result;
RuleFor(x => x.InitialValue)
.MaximumLength(20_000)
.When(x => x.InitialValue is not null);
}
}

View File

@@ -1,30 +1,16 @@
using System.Text.RegularExpressions;
using MicCheck.Api.Common.Validation;
using FluentValidation;
namespace MicCheck.Api.Features;
public record CreateTagRequest(string Label, string Color);
public class CreateTagRequestValidator : IModelValidator<CreateTagRequest>
public class CreateTagRequestValidator : AbstractValidator<CreateTagRequest>
{
private static readonly Regex ColorPattern = new("^#[0-9A-Fa-f]{3,6}$");
public ValidationResult Validate(CreateTagRequest model)
public CreateTagRequestValidator()
{
var result = new ValidationResult();
if (string.IsNullOrEmpty(model.Label))
result.AddError(nameof(model.Label), "'Label' must not be empty.");
else if (model.Label.Length > 100)
result.AddError(nameof(model.Label), "'Label' must be 100 characters or fewer.");
if (string.IsNullOrEmpty(model.Color))
result.AddError(nameof(model.Color), "'Color' must not be empty.");
else if (model.Color.Length > 20)
result.AddError(nameof(model.Color), "'Color' must be 20 characters or fewer.");
else if (!ColorPattern.IsMatch(model.Color))
result.AddError(nameof(model.Color), "Color must be a valid hex color (e.g. #FF0000).");
return result;
RuleFor(x => x.Label).NotEmpty().MaximumLength(100);
RuleFor(x => x.Color).NotEmpty().MaximumLength(20)
.Matches("^#[0-9A-Fa-f]{3,6}$")
.WithMessage("Color must be a valid hex color (e.g. #FF0000).");
}
}

View File

@@ -1,20 +0,0 @@
using MicCheck.Api.Features.Usage;
namespace MicCheck.Api.Features;
public static class DependencyRegistration
{
public static IServiceCollection AddFeaturesServices(this IServiceCollection services)
{
services.AddScoped<FeatureEvaluationService>();
services.AddSingleton<FlagCache>();
services.AddScoped<FeatureService>();
services.AddScoped<FeatureStateService>();
services.AddScoped<FeatureSegmentService>();
services.AddScoped<TagService>();
services.AddFeatureUsageServices();
return services;
}
}

View File

@@ -1,5 +1,4 @@
using MicCheck.Api.Data;
using MicCheck.Api.Features.Usage;
using MicCheck.Api.Identities;
using MicCheck.Api.Segments;
using Microsoft.EntityFrameworkCore;

View File

@@ -6,7 +6,7 @@ using Microsoft.EntityFrameworkCore;
namespace MicCheck.Api.Features;
public class FeatureService(IMicCheckDbContext db, IAuditService auditService, WebhookQueue webhookQueue)
public class FeatureService(IMicCheckDbContext db, AuditService auditService, WebhookQueue webhookQueue)
{
private const int MaxFeaturesPerProject = 400;
@@ -23,7 +23,13 @@ public class FeatureService(IMicCheckDbContext db, IAuditService auditService, W
return await db.Features.Include(f => f.Tags).FirstOrDefaultAsync(f => f.Id == id, ct);
}
public async Task<Feature> CreateAsync(int projectId, string name, FeatureType type, string? initialValue, string? description, CancellationToken ct = default)
public async Task<Feature> CreateAsync(
int projectId,
string name,
FeatureType type,
string? initialValue,
string? description,
CancellationToken ct = default)
{
var count = await db.Features.CountAsync(f => f.ProjectId == projectId, ct);
if (count >= MaxFeaturesPerProject)
@@ -72,7 +78,8 @@ public class FeatureService(IMicCheckDbContext db, IAuditService auditService, W
return feature;
}
public async Task<Feature> UpdateAsync(int id, string name, string? description, CancellationToken ct = default)
public async Task<Feature> UpdateAsync(
int id, string name, string? description, CancellationToken ct = default)
{
var feature = await db.Features.Include(f => f.Tags).FirstOrDefaultAsync(f => f.Id == id, ct)
?? throw new KeyNotFoundException($"Feature {id} not found.");
@@ -155,7 +162,11 @@ public class FeatureService(IMicCheckDbContext db, IAuditService auditService, W
EventType = WebhookEventTypes.FlagDeleted,
EnvironmentId = env.Id,
OrganizationId = project.OrganizationId,
Data = new FlagDeletedData(null, DateTimeOffset.UtcNow, new FeatureSummary(feature.Id, feature.Name)) });
Data = new FlagDeletedData(
null,
DateTimeOffset.UtcNow,
new FeatureSummary(feature.Id, feature.Name))
});
}
}
}

View File

@@ -1,6 +1,6 @@
namespace MicCheck.Api.Features;
public record FeatureStateResult
public class FeatureStateResult
{
public required Feature Feature { get; init; }
public bool Enabled { get; init; }

View File

@@ -5,7 +5,7 @@ using Microsoft.EntityFrameworkCore;
namespace MicCheck.Api.Features;
public class FeatureStateService(IMicCheckDbContext db, WebhookQueue webhookQueue, IAuditService auditService)
public class FeatureStateService(IMicCheckDbContext db, WebhookQueue webhookQueue, AuditService auditService)
{
public async Task<IReadOnlyList<FeatureState>> ListByEnvironmentAsync(int environmentId, CancellationToken ct = default)
{

View File

@@ -1,3 +1,3 @@
namespace MicCheck.Api.Features.Usage;
namespace MicCheck.Api.Features;
public record struct FeatureUsageBucketKey(int EnvironmentId, int FeatureId, string FeatureName, DateOnly UsageDate);

View File

@@ -4,7 +4,7 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.Extensions.Caching.Memory;
namespace MicCheck.Api.Features.Usage;
namespace MicCheck.Api.Features;
[ApiController]
[Route("api/v1/environment/{environmentId}/usage")]

View File

@@ -0,0 +1,12 @@
namespace MicCheck.Api.Features;
public class FeatureUsageDaily
{
public int Id { get; init; }
public int EnvironmentId { get; init; }
public int FeatureId { get; init; }
public required string FeatureName { get; set; }
public DateOnly UsageDate { get; init; }
public long Count { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
}

View File

@@ -2,7 +2,7 @@ using System.Diagnostics.CodeAnalysis;
using MicCheck.Api.Data;
using Microsoft.EntityFrameworkCore;
namespace MicCheck.Api.Features.Usage;
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(

View File

@@ -1,7 +1,7 @@
using System.Collections.Concurrent;
using System.Diagnostics.Metrics;
namespace MicCheck.Api.Features.Usage;
namespace MicCheck.Api.Features;
public class FeatureUsageMetrics : IDisposable
{

View File

@@ -1,7 +1,7 @@
using MicCheck.Api.Data;
using Microsoft.EntityFrameworkCore;
namespace MicCheck.Api.Features.Usage;
namespace MicCheck.Api.Features;
public class FeatureUsageQueryService(IMicCheckDbContext db)
{

View File

@@ -1,7 +1,9 @@
namespace MicCheck.Api.Features.Usage;
namespace MicCheck.Api.Features;
public record TopFeatureUsage(int FeatureId, string FeatureName, long Count);
public record DailyUsage(DateOnly Date, long TotalCount, IReadOnlyList<TopFeatureUsage> Features);
public record DashboardUsageResponse(IReadOnlyList<TopFeatureUsage> TopFeaturesLastDay, IReadOnlyList<DailyUsage> DailyUsage);
public record DashboardUsageResponse(
IReadOnlyList<TopFeatureUsage> TopFeaturesLastDay,
IReadOnlyList<DailyUsage> DailyUsage);

View File

@@ -12,9 +12,11 @@ public class FlagCache(IMemoryCache cache)
return flags;
}
public void Set(int environmentId, IReadOnlyList<FeatureStateResult> flags) => cache.Set(CacheKey(environmentId), flags, CacheDuration);
public void Set(int environmentId, IReadOnlyList<FeatureStateResult> flags)
=> cache.Set(CacheKey(environmentId), flags, CacheDuration);
public void Invalidate(int environmentId) => cache.Remove(CacheKey(environmentId));
public void Invalidate(int environmentId)
=> cache.Remove(CacheKey(environmentId));
private static string CacheKey(int environmentId) => $"flags:{environmentId}";
}

View File

@@ -1,9 +1,9 @@
namespace MicCheck.Api.Features;
public record Tag
public class Tag
{
public int Id { get; init; }
public required string Label { get; init; }
public required string Color { get; init; }
public required string Label { get; set; }
public required string Color { get; set; }
public int ProjectId { get; init; }
}

View File

@@ -8,24 +8,23 @@ namespace MicCheck.Api.Features;
[ApiController]
[Authorize(Policy = AuthorizationPolicies.AdminApiAccess)]
[EnableRateLimiting("AdminApi")]
[Route("api/v1/project/{projectId}")]
public class TagsController(TagService tagService) : ControllerBase
{
[HttpGet("tags")]
[HttpGet("api/v1/project/{projectId}/tags")]
public async Task<ActionResult<IReadOnlyList<TagResponse>>> List(int projectId, CancellationToken ct)
{
var tags = await tagService.ListByProjectAsync(projectId, ct);
return Ok(tags.Select(TagResponse.From).ToList());
}
[HttpPost("tags")]
[HttpPost("api/v1/project/{projectId}/tags")]
public async Task<ActionResult<TagResponse>> Create(int projectId, CreateTagRequest request, CancellationToken ct)
{
var tag = await tagService.CreateAsync(projectId, request.Label, request.Color, ct);
return CreatedAtAction(nameof(List), new { projectId }, TagResponse.From(tag));
}
[HttpDelete("tag/{id}")]
[HttpDelete("api/v1/project/{projectId}/tag/{id}")]
public async Task<IActionResult> Delete(int projectId, int id, CancellationToken ct)
{
var tag = await tagService.FindByIdAsync(id, ct);

View File

@@ -1,25 +1,20 @@
using System.Text.RegularExpressions;
using MicCheck.Api.Common.Validation;
using FluentValidation;
namespace MicCheck.Api.Features;
public record UpdateFeatureRequest(string Name, string? Description);
public record UpdateFeatureRequest(
string Name,
string? Description
);
public class UpdateFeatureRequestValidator : IModelValidator<UpdateFeatureRequest>
public class UpdateFeatureRequestValidator : AbstractValidator<UpdateFeatureRequest>
{
private static readonly Regex NamePattern = new("^[a-zA-Z0-9_-]+$");
public ValidationResult Validate(UpdateFeatureRequest model)
public UpdateFeatureRequestValidator()
{
var result = new ValidationResult();
if (string.IsNullOrEmpty(model.Name))
result.AddError(nameof(model.Name), "'Name' must not be empty.");
else if (model.Name.Length > 150)
result.AddError(nameof(model.Name), "'Name' must be 150 characters or fewer.");
else if (!NamePattern.IsMatch(model.Name))
result.AddError(nameof(model.Name), "Name may only contain letters, digits, underscores, and hyphens.");
return result;
RuleFor(x => x.Name)
.NotEmpty()
.MaximumLength(150)
.Matches("^[a-zA-Z0-9_-]+$")
.WithMessage("Name may only contain letters, digits, underscores, and hyphens.");
}
}

View File

@@ -1,13 +0,0 @@
namespace MicCheck.Api.Features.Usage;
public static class DependencyRegistration
{
public static IServiceCollection AddFeatureUsageServices(this IServiceCollection services)
{
services.AddSingleton<FeatureUsageMetrics>();
services.AddScoped<FeatureUsageQueryService>();
services.AddHostedService<FeatureUsageFlushBackgroundService>();
return services;
}
}

View File

@@ -1,12 +0,0 @@
namespace MicCheck.Api.Features.Usage;
public record FeatureUsageDaily
{
public int Id { get; init; }
public int EnvironmentId { get; init; }
public int FeatureId { get; init; }
public required string FeatureName { get; init; }
public DateOnly UsageDate { get; init; }
public long Count { get; init; }
public DateTimeOffset UpdatedAt { get; init; }
}

View File

@@ -1,12 +0,0 @@
namespace MicCheck.Api.Identities;
public static class DependencyRegistration
{
public static IServiceCollection AddIdentitiesServices(this IServiceCollection services)
{
services.AddScoped<IdentityResolutionService>();
services.AddScoped<AdminIdentityService>();
return services;
}
}

View File

@@ -9,6 +9,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.5" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.5" />
<PackageReference Include="Microsoft.OpenApi" Version="2.9.0" />

View File

@@ -305,7 +305,7 @@ namespace MicCheck.Api.Migrations
b.ToTable("FeatureStates");
});
modelBuilder.Entity("MicCheck.Api.Features.Usage.FeatureUsageDaily", b =>
modelBuilder.Entity("MicCheck.Api.Features.FeatureUsageDaily", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()

View File

@@ -302,7 +302,7 @@ namespace MicCheck.Api.Migrations
b.ToTable("FeatureStates");
});
modelBuilder.Entity("MicCheck.Api.Features.Usage.FeatureUsageDaily", b =>
modelBuilder.Entity("MicCheck.Api.Features.FeatureUsageDaily", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()

View File

@@ -1,20 +1,13 @@
using MicCheck.Api.Common.Validation;
using FluentValidation;
namespace MicCheck.Api.Organizations;
public record CreateOrganizationRequest(string Name);
public class CreateOrganizationRequestValidator : IModelValidator<CreateOrganizationRequest>
public class CreateOrganizationRequestValidator : AbstractValidator<CreateOrganizationRequest>
{
public ValidationResult Validate(CreateOrganizationRequest model)
public CreateOrganizationRequestValidator()
{
var result = new ValidationResult();
if (string.IsNullOrEmpty(model.Name))
result.AddError(nameof(model.Name), "'Name' must not be empty.");
else if (model.Name.Length > 200)
result.AddError(nameof(model.Name), "'Name' must be 200 characters or fewer.");
return result;
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
}
}

View File

@@ -1,11 +0,0 @@
namespace MicCheck.Api.Organizations;
public static class DependencyRegistration
{
public static IServiceCollection AddOrganizationsServices(this IServiceCollection services)
{
services.AddScoped<OrganizationService>();
return services;
}
}

View File

@@ -1,23 +1,15 @@
using MicCheck.Api.Common.Validation;
using FluentValidation;
namespace MicCheck.Api.Organizations;
public record InviteUserRequest(int UserId, string Role);
public class InviteUserRequestValidator : IModelValidator<InviteUserRequest>
public class InviteUserRequestValidator : AbstractValidator<InviteUserRequest>
{
public ValidationResult Validate(InviteUserRequest model)
public InviteUserRequestValidator()
{
var result = new ValidationResult();
if (model.UserId <= 0)
result.AddError(nameof(model.UserId), "'User Id' must be greater than 0.");
if (string.IsNullOrEmpty(model.Role))
result.AddError(nameof(model.Role), "'Role' must not be empty.");
else if (!Enum.TryParse<OrganizationRole>(model.Role, true, out _))
result.AddError(nameof(model.Role), "Role must be 'User' or 'Admin'.");
return result;
RuleFor(x => x.UserId).GreaterThan(0);
RuleFor(x => x.Role).NotEmpty().Must(r => Enum.TryParse<OrganizationRole>(r, true, out _))
.WithMessage("Role must be 'User' or 'Admin'.");
}
}

View File

@@ -5,7 +5,7 @@ using Microsoft.EntityFrameworkCore;
namespace MicCheck.Api.Organizations;
public class OrganizationService(IMicCheckDbContext db, IAuditService auditService)
public class OrganizationService(IMicCheckDbContext db, AuditService auditService)
{
public async Task<IReadOnlyList<(Organization Org, bool IsPrimary)>> ListForUserAsync(int userId, CancellationToken ct = default)
{

View File

@@ -1,20 +1,13 @@
using MicCheck.Api.Common.Validation;
using FluentValidation;
namespace MicCheck.Api.Organizations;
public record UpdateOrganizationRequest(string Name);
public class UpdateOrganizationRequestValidator : IModelValidator<UpdateOrganizationRequest>
public class UpdateOrganizationRequestValidator : AbstractValidator<UpdateOrganizationRequest>
{
public ValidationResult Validate(UpdateOrganizationRequest model)
public UpdateOrganizationRequestValidator()
{
var result = new ValidationResult();
if (string.IsNullOrEmpty(model.Name))
result.AddError(nameof(model.Name), "'Name' must not be empty.");
else if (model.Name.Length > 200)
result.AddError(nameof(model.Name), "'Name' must be 200 characters or fewer.");
return result;
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
}
}

View File

@@ -1,9 +1,11 @@
using System.Text;
using System.Threading.RateLimiting;
using MicCheck.Api.Audit;
using MicCheck.Api.Common;
using FluentValidation;
using FluentValidation.AspNetCore;
using MicCheck.Api.Common.Security.ApiKeys;
using MicCheck.Api.Audit;
using MicCheck.Api.Common.Security.Authentication;
using MicCheck.Api.Common.Security.Authorization;
using MicCheck.Api.Common.Validation;
using MicCheck.Api.Data;
using MicCheck.Api.Environments;
using MicCheck.Api.Features;
@@ -13,7 +15,14 @@ using MicCheck.Api.Projects;
using MicCheck.Api.Segments;
using MicCheck.Api.Users;
using MicCheck.Api.Webhooks;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using Scalar.AspNetCore;
using Serilog;
@@ -33,12 +42,44 @@ try
.WriteTo.Console());
builder.Services.AddOpenApi();
builder.Services.AddControllers(options => options.Filters.Add<ModelValidationActionFilter>())
builder.Services.AddControllers()
.AddJsonOptions(options =>
options.JsonSerializerOptions.Converters.Add(
new System.Text.Json.Serialization.JsonStringEnumConverter()));
builder.Services.AddCommonServices(builder.Configuration);
builder.Services.AddAuthentication()
.AddScheme<AuthenticationSchemeOptions, EnvironmentKeyAuthenticationHandler>(
EnvironmentKeyAuthenticationHandler.SchemeName, _ => { })
.AddScheme<AuthenticationSchemeOptions, ApiKeyAuthenticationHandler>(
ApiKeyAuthenticationHandler.SchemeName, _ => { })
.AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateIssuerSigningKey = true,
ValidIssuer = builder.Configuration["Jwt:Issuer"],
ValidAudience = builder.Configuration["Jwt:Audience"],
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(builder.Configuration["Jwt:SecretKey"]!))
};
});
builder.Services.AddAuthorization(options =>
{
options.AddPolicy(AuthorizationPolicies.FlagsApiAccess, policy =>
policy.AddAuthenticationSchemes(EnvironmentKeyAuthenticationHandler.SchemeName)
.RequireClaim("EnvironmentId"));
options.AddPolicy(AuthorizationPolicies.AdminApiAccess, policy =>
policy.AddAuthenticationSchemes(ApiKeyAuthenticationHandler.SchemeName, JwtBearerDefaults.AuthenticationScheme)
.RequireAuthenticatedUser());
options.AddPolicy(AuthorizationPolicies.OrganizationAdmin, policy =>
policy.AddAuthenticationSchemes(ApiKeyAuthenticationHandler.SchemeName, JwtBearerDefaults.AuthenticationScheme)
.RequireClaim("OrganizationRole", "Admin"));
});
builder.Services.AddHttpContextAccessor();
@@ -51,20 +92,67 @@ try
limiter.QueueLimit = 0;
}));
builder.Services.AddFluentValidationAutoValidation();
builder.Services.AddValidatorsFromAssemblyContaining<Program>();
builder.Services.Configure<ApiBehaviorOptions>(options =>
{
options.InvalidModelStateResponseFactory = context =>
{
var errors = context.ModelState
.Where(e => e.Value?.Errors.Count > 0)
.ToDictionary(
kvp => kvp.Key,
kvp => kvp.Value!.Errors.Select(e => e.ErrorMessage).ToArray());
return new UnprocessableEntityObjectResult(new { errors });
};
});
builder.Services.AddMemoryCache();
builder.Services.AddMetrics();
builder.Services.AddAuditServices();
builder.Services.AddIdentitiesServices();
builder.Services.AddEnvironmentsServices();
builder.Services.AddSegmentsServices();
builder.Services.AddOrganizationsServices();
builder.Services.AddProjectsServices();
builder.Services.AddFeaturesServices();
builder.Services.AddUsersServices();
builder.Services.AddWebhooksServices();
builder.Services.AddScoped<ITokenService, TokenService>();
builder.Services.AddScoped<AuthService>();
builder.Services.AddScoped<ApiKeyService>();
builder.Services.AddScoped<DatabaseSeeder>();
builder.Services.AddScoped<IPasswordHasher<User>, PasswordHasher<User>>();
builder.Services.AddScoped<IAuthorizationHandler, ProjectPermissionRequirementHandler>();
builder.Services.AddScoped<FeatureEvaluationService>();
builder.Services.AddScoped<IdentityResolutionService>();
builder.Services.AddScoped<EnvironmentDocumentService>();
builder.Services.AddSingleton<SegmentEvaluator>();
builder.Services.AddSingleton<FlagCache>();
builder.Services.AddScoped<AuditService>();
builder.Services.AddScoped<AuditLogQueryService>();
builder.Services.AddScoped<OrganizationService>();
builder.Services.AddScoped<ProjectService>();
builder.Services.AddScoped<EnvironmentService>();
builder.Services.AddScoped<FeatureService>();
builder.Services.AddScoped<FeatureStateService>();
builder.Services.AddScoped<FeatureSegmentService>();
builder.Services.AddScoped<SegmentService>();
builder.Services.AddScoped<TagService>();
builder.Services.AddScoped<WebhookService>();
builder.Services.AddScoped<WebhookDispatcher>();
builder.Services.AddScoped<AdminIdentityService>();
builder.Services.AddScoped<UserService>();
builder.Services.AddSingleton<WebhookQueue>();
builder.Services.AddHostedService<WebhookBackgroundService>();
builder.Services.AddHostedService<WebhookRetryBackgroundService>();
builder.Services.AddSingleton<FeatureUsageMetrics>();
builder.Services.AddScoped<FeatureUsageQueryService>();
builder.Services.AddHostedService<FeatureUsageFlushBackgroundService>();
builder.Services.AddHttpClient("Webhooks", client =>
client.DefaultRequestHeaders.Add("User-Agent", "MicCheck-Webhook/1.0"));
builder.Services.AddDataServices(builder.Configuration);
var connectionString = builder.Configuration.GetConnectionString("miccheck")
?? (System.Environment.GetEnvironmentVariable("DATABASE_URL") is { } databaseUrl
? DatabaseUrlParser.ToNpgsqlConnectionString(databaseUrl)
: builder.Configuration.GetConnectionString("DefaultConnection")!);
builder.Services.AddDbContext<MicCheckDbContext>(options =>
options.UseNpgsql(connectionString));
builder.Services.AddScoped<IMicCheckDbContext>(sp => sp.GetRequiredService<MicCheckDbContext>());
var app = builder.Build();

View File

@@ -1,23 +1,14 @@
using MicCheck.Api.Common.Validation;
using FluentValidation;
namespace MicCheck.Api.Projects;
public record CreateProjectRequest(string Name, int OrganizationId);
public class CreateProjectRequestValidator : IModelValidator<CreateProjectRequest>
public class CreateProjectRequestValidator : AbstractValidator<CreateProjectRequest>
{
public ValidationResult Validate(CreateProjectRequest model)
public CreateProjectRequestValidator()
{
var result = new ValidationResult();
if (string.IsNullOrEmpty(model.Name))
result.AddError(nameof(model.Name), "'Name' must not be empty.");
else if (model.Name.Length > 200)
result.AddError(nameof(model.Name), "'Name' must be 200 characters or fewer.");
if (model.OrganizationId <= 0)
result.AddError(nameof(model.OrganizationId), "'Organization Id' must be greater than 0.");
return result;
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
RuleFor(x => x.OrganizationId).GreaterThan(0);
}
}

View File

@@ -1,11 +0,0 @@
namespace MicCheck.Api.Projects;
public static class DependencyRegistration
{
public static IServiceCollection AddProjectsServices(this IServiceCollection services)
{
services.AddScoped<ProjectService>();
return services;
}
}

View File

@@ -5,7 +5,7 @@ using Microsoft.EntityFrameworkCore;
namespace MicCheck.Api.Projects;
public class ProjectService(IMicCheckDbContext db, IAuditService auditService)
public class ProjectService(IMicCheckDbContext db, AuditService auditService)
{
public async Task<IReadOnlyList<Project>> ListByOrganizationAsync(int organizationId, CancellationToken ct = default)
{

View File

@@ -1,25 +1,17 @@
using MicCheck.Api.Common.Security.Authorization;
using MicCheck.Api.Common.Validation;
namespace MicCheck.Api.Projects;
public record SetUserPermissionsRequest(int UserId, bool IsAdmin, List<string> Permissions);
public class SetUserPermissionsRequestValidator : IModelValidator<SetUserPermissionsRequest>
{
public ValidationResult Validate(SetUserPermissionsRequest model)
{
var result = new ValidationResult();
if (model.UserId <= 0)
result.AddError(nameof(model.UserId), "'User Id' must be greater than 0.");
foreach (var permission in model.Permissions)
{
if (!Enum.TryParse<ProjectPermission>(permission, true, out _))
result.AddError(nameof(model.Permissions), "Invalid permission value.");
}
return result;
}
}
using FluentValidation;
using MicCheck.Api.Common.Security.Authorization;
namespace MicCheck.Api.Projects;
public record SetUserPermissionsRequest(int UserId, bool IsAdmin, List<string> Permissions);
public class SetUserPermissionsRequestValidator : AbstractValidator<SetUserPermissionsRequest>
{
public SetUserPermissionsRequestValidator()
{
RuleFor(x => x.UserId).GreaterThan(0);
RuleForEach(x => x.Permissions)
.Must(p => Enum.TryParse<ProjectPermission>(p, true, out _))
.WithMessage("Invalid permission value.");
}
}

View File

@@ -1,20 +1,13 @@
using MicCheck.Api.Common.Validation;
using FluentValidation;
namespace MicCheck.Api.Projects;
public record UpdateProjectRequest(string Name, bool HideDisabledFlags);
public class UpdateProjectRequestValidator : IModelValidator<UpdateProjectRequest>
public class UpdateProjectRequestValidator : AbstractValidator<UpdateProjectRequest>
{
public ValidationResult Validate(UpdateProjectRequest model)
public UpdateProjectRequestValidator()
{
var result = new ValidationResult();
if (string.IsNullOrEmpty(model.Name))
result.AddError(nameof(model.Name), "'Name' must not be empty.");
else if (model.Name.Length > 200)
result.AddError(nameof(model.Name), "'Name' must be 200 characters or fewer.");
return result;
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
}
}

View File

@@ -1,4 +1,4 @@
using MicCheck.Api.Common.Validation;
using FluentValidation;
namespace MicCheck.Api.Segments;
@@ -16,38 +16,24 @@ public record CreateSegmentRequest(
string Name,
IReadOnlyList<CreateSegmentRuleRequest> Rules);
public class CreateSegmentRequestValidator : IModelValidator<CreateSegmentRequest>
public class CreateSegmentRequestValidator : AbstractValidator<CreateSegmentRequest>
{
public ValidationResult Validate(CreateSegmentRequest model)
public CreateSegmentRequestValidator()
{
var result = new ValidationResult();
if (string.IsNullOrEmpty(model.Name))
result.AddError(nameof(model.Name), "'Name' must not be empty.");
else if (model.Name.Length > 200)
result.AddError(nameof(model.Name), "'Name' must be 200 characters or fewer.");
if (model.Rules is null)
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
RuleFor(x => x.Rules).NotNull();
RuleForEach(x => x.Rules).ChildRules(rule =>
{
result.AddError(nameof(model.Rules), "'Rules' must not be empty.");
return result;
}
foreach (var rule in model.Rules)
{
if (!Enum.TryParse<SegmentRuleType>(rule.Type, true, out _))
result.AddError(nameof(model.Rules), "Rule type must be 'All', 'Any', or 'None'.");
foreach (var condition in rule.Conditions)
rule.RuleFor(r => r.Type)
.Must(t => Enum.TryParse<SegmentRuleType>(t, true, out _))
.WithMessage("Rule type must be 'All', 'Any', or 'None'.");
rule.RuleForEach(r => r.Conditions).ChildRules(cond =>
{
if (string.IsNullOrEmpty(condition.Property))
result.AddError(nameof(model.Rules), "'Property' must not be empty.");
if (!Enum.TryParse<SegmentConditionOperator>(condition.Operator, true, out _))
result.AddError(nameof(model.Rules), "Invalid operator.");
}
}
return result;
cond.RuleFor(c => c.Property).NotEmpty();
cond.RuleFor(c => c.Operator)
.Must(o => Enum.TryParse<SegmentConditionOperator>(o, true, out _))
.WithMessage("Invalid operator.");
});
});
}
}

View File

@@ -1,12 +0,0 @@
namespace MicCheck.Api.Segments;
public static class DependencyRegistration
{
public static IServiceCollection AddSegmentsServices(this IServiceCollection services)
{
services.AddSingleton<SegmentEvaluator>();
services.AddScoped<SegmentService>();
return services;
}
}

View File

@@ -9,7 +9,7 @@ public record SegmentConditionDefinition(string Property, SegmentConditionOperat
public record SegmentRuleDefinition(SegmentRuleType Type, IReadOnlyList<SegmentConditionDefinition> Conditions, IReadOnlyList<SegmentRuleDefinition>? ChildRules = null);
public class SegmentService(IMicCheckDbContext db, IAuditService auditService)
public class SegmentService(IMicCheckDbContext db, AuditService auditService)
{
private const int MaxSegmentsPerProject = 100;
private const int MaxConditionsPerSegment = 100;

View File

@@ -1,14 +0,0 @@
using Microsoft.AspNetCore.Identity;
namespace MicCheck.Api.Users;
public static class DependencyRegistration
{
public static IServiceCollection AddUsersServices(this IServiceCollection services)
{
services.AddScoped<UserService>();
services.AddScoped<IPasswordHasher<User>, PasswordHasher<User>>();
return services;
}
}

View File

@@ -1,22 +1,14 @@
using MicCheck.Api.Common.Validation;
using FluentValidation;
namespace MicCheck.Api.Webhooks;
public record CreateWebhookRequest(string Url, string? Secret, bool Enabled);
public class CreateWebhookRequestValidator : IModelValidator<CreateWebhookRequest>
public class CreateWebhookRequestValidator : AbstractValidator<CreateWebhookRequest>
{
public ValidationResult Validate(CreateWebhookRequest model)
public CreateWebhookRequestValidator()
{
var result = new ValidationResult();
if (string.IsNullOrEmpty(model.Url))
result.AddError(nameof(model.Url), "'Url' must not be empty.");
else if (model.Url.Length > 500)
result.AddError(nameof(model.Url), "'Url' must be 500 characters or fewer.");
else if (!Uri.TryCreate(model.Url, UriKind.Absolute, out _))
result.AddError(nameof(model.Url), "Url must be a valid absolute URL.");
return result;
RuleFor(x => x.Url).NotEmpty().MaximumLength(500).Must(u => Uri.TryCreate(u, UriKind.Absolute, out _))
.WithMessage("Url must be a valid absolute URL.");
}
}

View File

@@ -1,17 +0,0 @@
namespace MicCheck.Api.Webhooks;
public static class DependencyRegistration
{
public static IServiceCollection AddWebhooksServices(this IServiceCollection services)
{
services.AddScoped<WebhookService>();
services.AddScoped<WebhookDispatcher>();
services.AddSingleton<WebhookQueue>();
services.AddHostedService<WebhookBackgroundService>();
services.AddHostedService<WebhookRetryBackgroundService>();
services.AddHttpClient("Webhooks", client =>
client.DefaultRequestHeaders.Add("User-Agent", "MicCheck-Webhook/1.0"));
return services;
}
}

View File

@@ -1,6 +1,6 @@
namespace MicCheck.Api.Webhooks;
public record WebhookEvent
public class WebhookEvent
{
public required string EventType { get; init; }
public int? EnvironmentId { get; init; }

View File

@@ -23,7 +23,7 @@ public class AdminApiIntegrationTests
private List<FeatureState> _featureStates = null!;
private List<AppEnvironment> _environments = null!;
private List<Segment> _segments = null!;
private Mock<IAuditService> _auditService = null!;
private Mock<AuditService> _auditService = null!;
private int _organizationId;
[SetUp]
@@ -50,7 +50,7 @@ public class AdminApiIntegrationTests
_db.SetupDbSetWithGeneratedIds(c => c.SegmentConditions, []);
var webhookQueue = new WebhookQueue();
_auditService = new Mock<IAuditService>();
_auditService = new Mock<AuditService>(_db.Object, null!, webhookQueue);
_auditService.Setup(a => a.LogAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<int>(), It.IsAny<int?>(), It.IsAny<int?>(),

View File

@@ -46,7 +46,7 @@ public class AuditLogsControllerTests
_db.SetupDbSet(c => c.AuditLogs, _auditLogs);
_db.SetupDbSet(c => c.Users, []);
var auditServiceMock = new Mock<IAuditService>();
var auditServiceMock = new Mock<AuditService>(_db.Object, null!, new MicCheck.Api.Webhooks.WebhookQueue());
var auditService = auditServiceMock.Object;
_controller = new AuditLogsController(

View File

@@ -45,7 +45,7 @@ public class EnvironmentServiceTests
_db.SetupDbSet(c => c.Identities, _identities);
var webhookQueue = new MicCheck.Api.Webhooks.WebhookQueue();
var auditService = new Mock<IAuditService>();
var auditService = new Mock<AuditService>(_db.Object, null!, webhookQueue);
auditService.Setup(a => a.LogAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<int>(), It.IsAny<int?>(), It.IsAny<int?>(),

View File

@@ -113,7 +113,7 @@ public class EnvironmentsControllerTests
_db.SetupDbSet(c => c.Users, []);
var webhookQueue = new WebhookQueue();
var auditServiceMock = new Mock<IAuditService>();
var auditServiceMock = new Mock<AuditService>(_db.Object, null!, webhookQueue);
auditServiceMock.Setup(a => a.LogAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<int>(), It.IsAny<int?>(), It.IsAny<int?>(),

View File

@@ -1,7 +1,6 @@
using System.Diagnostics.Metrics;
using MicCheck.Api.Data;
using MicCheck.Api.Features;
using MicCheck.Api.Features.Usage;
using MicCheck.Api.Identities;
using MicCheck.Api.Segments;
using MicCheck.Api.Tests.Unit.TestSupport;

View File

@@ -45,7 +45,7 @@ public class FeatureServiceTests
_db.SetupDbSetWithGeneratedIds(c => c.Tags, _tags);
var webhookQueue = new MicCheck.Api.Webhooks.WebhookQueue();
var auditService = new Mock<IAuditService>();
var auditService = new Mock<AuditService>(_db.Object, null!, webhookQueue);
auditService.Setup(a => a.RecordAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<int>(), It.IsAny<int?>(), It.IsAny<int?>(),

View File

@@ -43,7 +43,7 @@ public class FeatureStateServiceTests
_db.SetupDbSetWithGeneratedIds(c => c.FeatureStates, _featureStates);
var webhookQueue = new WebhookQueue();
var auditService = new Mock<IAuditService>();
var auditService = new Mock<AuditService>(_db.Object, null!, webhookQueue);
auditService.Setup(a => a.RecordAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<int>(), It.IsAny<int?>(), It.IsAny<int?>(),
@@ -154,7 +154,7 @@ public class FeatureStateServiceTests
var state = AddState();
var queue = new WebhookQueue();
var auditService = new Mock<IAuditService>();
var auditService = new Mock<AuditService>(_db.Object, null!, queue);
auditService.Setup(a => a.RecordAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<int>(), It.IsAny<int?>(), It.IsAny<int?>(),

View File

@@ -1,12 +1,12 @@
using MicCheck.Api.Data;
using MicCheck.Api.Features.Usage;
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.Usage;
namespace MicCheck.Api.Tests.Unit.Features;
[TestFixture]
public class FeatureUsageControllerTests

View File

@@ -1,9 +1,9 @@
using System.Diagnostics.Metrics;
using MicCheck.Api.Features.Usage;
using MicCheck.Api.Features;
using Moq;
using NUnit.Framework;
namespace MicCheck.Api.Tests.Unit.Features.Usage;
namespace MicCheck.Api.Tests.Unit.Features;
[TestFixture]
public class FeatureUsageMetricsTests

View File

@@ -1,10 +1,10 @@
using MicCheck.Api.Data;
using MicCheck.Api.Features.Usage;
using MicCheck.Api.Features;
using MicCheck.Api.Tests.Unit.TestSupport;
using Moq;
using NUnit.Framework;
namespace MicCheck.Api.Tests.Unit.Features.Usage;
namespace MicCheck.Api.Tests.Unit.Features;
[TestFixture]
public class FeatureUsageQueryServiceTests

View File

@@ -41,7 +41,7 @@ public class FeaturesControllerTests
_db.SetupDbSetWithGeneratedIds(c => c.Tags, _tags);
var webhookQueue = new MicCheck.Api.Webhooks.WebhookQueue();
var auditService = new Mock<IAuditService>();
var auditService = new Mock<AuditService>(_db.Object, null!, webhookQueue);
auditService.Setup(a => a.RecordAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<int>(), It.IsAny<int?>(), It.IsAny<int?>(),

View File

@@ -2,7 +2,6 @@ using System.Security.Claims;
using MicCheck.Api.Data;
using MicCheck.Api.Environments;
using MicCheck.Api.Features;
using MicCheck.Api.Features.Usage;
using MicCheck.Api.Identities;
using MicCheck.Api.Projects;
using MicCheck.Api.Segments;

View File

@@ -2,7 +2,6 @@ using System.Security.Claims;
using MicCheck.Api.Data;
using MicCheck.Api.Environments;
using MicCheck.Api.Features;
using MicCheck.Api.Features.Usage;
using MicCheck.Api.Identities;
using MicCheck.Api.Projects;
using MicCheck.Api.Segments;

View File

@@ -41,7 +41,7 @@ public class OrganizationServiceTests
_db.SetupDbSet(c => c.Users, _users);
var webhookQueue = new MicCheck.Api.Webhooks.WebhookQueue();
var auditService = new Mock<IAuditService>();
var auditService = new Mock<AuditService>(_db.Object, null!, webhookQueue);
auditService.Setup(a => a.LogAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<int>(), It.IsAny<int?>(), It.IsAny<int?>(),

View File

@@ -49,7 +49,7 @@ public class OrganizationsControllerTests
_db.SetupDbSetWithGeneratedIds(c => c.Webhooks, _webhooks);
var webhookQueue = new WebhookQueue();
var auditService = new Mock<IAuditService>();
var auditService = new Mock<AuditService>(_db.Object, null!, webhookQueue);
auditService.Setup(a => a.LogAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<int>(), It.IsAny<int?>(), It.IsAny<int?>(),

View File

@@ -32,7 +32,7 @@ public class ProjectServiceTests
_db.SetupDbSet(c => c.UserProjectPermissions, _permissions);
var webhookQueue = new MicCheck.Api.Webhooks.WebhookQueue();
var auditService = new Mock<IAuditService>();
var auditService = new Mock<AuditService>(_db.Object, null!, webhookQueue);
auditService.Setup(a => a.LogAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<int>(), It.IsAny<int?>(), It.IsAny<int?>(),

View File

@@ -34,7 +34,7 @@ public class ProjectsControllerTests
_db.SetupDbSet(c => c.UserProjectPermissions, _permissions);
var webhookQueue = new MicCheck.Api.Webhooks.WebhookQueue();
var auditService = new Mock<IAuditService>();
var auditService = new Mock<AuditService>(_db.Object, null!, webhookQueue);
auditService.Setup(a => a.LogAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<int>(), It.IsAny<int?>(), It.IsAny<int?>(),

View File

@@ -40,7 +40,7 @@ public class SegmentServiceTests
_db.SetupDbSetWithGeneratedIds(c => c.SegmentConditions, _segmentConditions);
var webhookQueue = new MicCheck.Api.Webhooks.WebhookQueue();
var auditService = new Mock<IAuditService>();
var auditService = new Mock<AuditService>(_db.Object, null!, webhookQueue);
auditService.Setup(a => a.LogAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<int>(), It.IsAny<int?>(), It.IsAny<int?>(),

View File

@@ -41,7 +41,7 @@ public class SegmentsControllerTests
_db.SetupDbSetWithGeneratedIds(c => c.SegmentConditions, _segmentConditions);
var webhookQueue = new MicCheck.Api.Webhooks.WebhookQueue();
var auditService = new Mock<IAuditService>();
var auditService = new Mock<AuditService>(_db.Object, null!, webhookQueue);
auditService.Setup(a => a.LogAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<int>(), It.IsAny<int?>(), It.IsAny<int?>(),