Compare commits

..
6 Commits
Author SHA1 Message Date
James Wampler aa611feaff WIP
CI / build-and-push (push) Has been cancelled
CI / deploy-qa (push) Has been cancelled
CI / smoke-qa (push) Has been cancelled
2026-07-22 14:02:59 -07:00
James Wampler 3eebef56a0 Add MicCheck.Client library with FeatureClient.IsEnabledAsync
Client-side SDK for checking feature flag state against the flags API,
with unit tests mocking HttpMessageHandler.
2026-07-09 12:48:21 -07:00
James Wampler 8262cd2f61 Drop docker-compose from dev workflow; run full build/test and Aspire locally
CI / build-and-push (push) Successful in 1m57s
CI / deploy-qa (push) Successful in 14s
CI / smoke-qa (push) Successful in 22s
dev-build.sh now builds the whole solution and runs .NET + Jest unit
tests instead of restarting docker-compose services. readme documents
the aspire run command for local orchestration; slnx solution-items
reference to the removed docker-compose.yml is gone.
2026-07-06 10:44:11 -07:00
James Wampler 83441b6c69 Removing old docker-compose file. Use Aspire instead
CI / build-and-push (push) Successful in 51s
CI / deploy-qa (push) Successful in 12s
CI / smoke-qa (push) Successful in 22s
2026-07-06 10:33:02 -07:00
James Wampler 283ca4f148 Move QA Postgres password out of docker-compose into Gitea secret
Follows the same pattern already used for JWT_SECRET_KEY: interpolated
from a required env var, sourced from a Gitea Actions secret in CI.
2026-07-06 10:32:04 -07:00
James Wamplerandmiccheck-ci 7a3e2167c2 Remove FluentValidation; validator/DI cleanup (#7)
CI / build-and-push (push) Successful in 1m0s
CI / deploy-qa (push) Successful in 11s
CI / smoke-qa (push) Successful in 26s
## Summary
- Remove FluentValidation dependency; replace with plain IModelValidator<T>/ValidationResult pattern (ported from BuyEngine's Guard/validation approach), wired via a ModelValidationActionFilter
- Move FeatureUsage code into MicCheck.Api.Features.Usage namespace
- Convert logic-free data classes to records per CLAUDE.md (WebhookEvent, FeatureStateResult, ProjectPermissionRequirement, AuditLog, Tag, FeatureUsageDaily)
- Extract IAuditService interface, drop virtual-method mock seam on AuditService
- Split Program.cs DI registrations into per-namespace DependencyRegistration classes

## Test plan
- [x] dotnet build (API + tests) clean
- [x] dotnet test MicCheck.Api.Tests.Unit — 646/646 passing
- [x] pre-push hook (full solution build/test + admin build) passed

Co-authored-by: miccheck-ci <ci@miccheck.local>
Reviewed-on: #7
2026-07-05 22:54:30 -07:00
38 changed files with 322 additions and 140 deletions
+2
View File
@@ -61,6 +61,7 @@ jobs:
REGISTRY_USER: ${{ secrets.REGISTRY_USER }} REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
JWT_SECRET_KEY: ${{ secrets.JWT_SECRET_KEY }} JWT_SECRET_KEY: ${{ secrets.JWT_SECRET_KEY }}
POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
QA_ADMIN_PORT: ${{ vars.QA_ADMIN_PORT }} QA_ADMIN_PORT: ${{ vars.QA_ADMIN_PORT }}
steps: steps:
# actions/checkout@v4 is a Node-based action; this runner has no node # actions/checkout@v4 is a Node-based action; this runner has no node
@@ -81,6 +82,7 @@ jobs:
runs-on: [self-hosted, qa] runs-on: [self-hosted, qa]
env: env:
QA_ADMIN_PORT: ${{ vars.QA_ADMIN_PORT }} QA_ADMIN_PORT: ${{ vars.QA_ADMIN_PORT }}
POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
steps: steps:
# actions/checkout@v4 is a Node-based action; this runner has no node # actions/checkout@v4 is a Node-based action; this runner has no node
# in PATH, so checkout plain git instead of via marketplace action. # in PATH, so checkout plain git instead of via marketplace action.
+2 -1
View File
@@ -3,7 +3,6 @@
<File Path=".editorconfig" /> <File Path=".editorconfig" />
<File Path=".gitignore" /> <File Path=".gitignore" />
<File Path="CLAUDE.md" /> <File Path="CLAUDE.md" />
<File Path="docker-compose.yml" />
<File Path="readme.md" /> <File Path="readme.md" />
</Folder> </Folder>
<Folder Name="/src/"> <Folder Name="/src/">
@@ -13,10 +12,12 @@
</Project> </Project>
<Project Path="src/api/MicCheck.Api/MicCheck.Api.csproj" /> <Project Path="src/api/MicCheck.Api/MicCheck.Api.csproj" />
<Project Path="src/MicCheck.AppHost/MicCheck.AppHost.csproj" /> <Project Path="src/MicCheck.AppHost/MicCheck.AppHost.csproj" />
<Project Path="src/MicCheck.Client/MicCheck.Client.csproj" />
<Project Path="src/MicCheck.ServiceDefaults/MicCheck.ServiceDefaults.csproj" /> <Project Path="src/MicCheck.ServiceDefaults/MicCheck.ServiceDefaults.csproj" />
</Folder> </Folder>
<Folder Name="/tests/"> <Folder Name="/tests/">
<Project Path="tests/api/MicCheck.Api.Tests.Unit/MicCheck.Api.Tests.Unit.csproj" /> <Project Path="tests/api/MicCheck.Api.Tests.Unit/MicCheck.Api.Tests.Unit.csproj" />
<Project Path="tests/api/MicCheck.Api.Tests.Integration/MicCheck.Api.Tests.Integration.csproj" /> <Project Path="tests/api/MicCheck.Api.Tests.Integration/MicCheck.Api.Tests.Integration.csproj" />
<Project Path="tests/client/MicCheck.Client.Tests.Unit/MicCheck.Client.Tests.Unit.csproj" />
</Folder> </Folder>
</Solution> </Solution>
+1 -1
View File
@@ -123,7 +123,7 @@
<text x="53" y="15" fill="#010101" fill-opacity=".3">Coverage</text> <text x="53" y="15" fill="#010101" fill-opacity=".3">Coverage</text>
<text x="53" y="14" fill="#fff">Coverage</text> <text x="53" y="14" fill="#fff">Coverage</text>
<text class="" x="132.5" y="15" fill="#010101" fill-opacity=".3">69.2%</text><text class="" x="132.5" y="14">69.2%</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>

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

+1
View File
@@ -9,4 +9,5 @@ REGISTRY_TOKEN=changeme
# QA environment # QA environment
JWT_SECRET_KEY=change-this-to-a-random-32-plus-char-secret JWT_SECRET_KEY=change-this-to-a-random-32-plus-char-secret
POSTGRES_PASSWORD=change-this-to-a-random-password
QA_ADMIN_PORT=3001 QA_ADMIN_PORT=3001
+2 -2
View File
@@ -13,7 +13,7 @@ services:
environment: environment:
POSTGRES_DB: miccheck POSTGRES_DB: miccheck
POSTGRES_USER: miccheck POSTGRES_USER: miccheck
POSTGRES_PASSWORD: password POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
ports: ports:
- "${DB_BIND_HOST:-127.0.0.1}:55432:5432" - "${DB_BIND_HOST:-127.0.0.1}:55432:5432"
volumes: volumes:
@@ -31,7 +31,7 @@ services:
environment: environment:
ASPNETCORE_ENVIRONMENT: Development ASPNETCORE_ENVIRONMENT: Development
ASPNETCORE_URLS: http://+:8080 ASPNETCORE_URLS: http://+:8080
ConnectionStrings__miccheck: "Host=db;Database=miccheck;Username=miccheck;Password=password" ConnectionStrings__miccheck: "Host=db;Database=miccheck;Username=miccheck;Password=${POSTGRES_PASSWORD}"
Jwt__SecretKey: ${JWT_SECRET_KEY} Jwt__SecretKey: ${JWT_SECRET_KEY}
Jwt__Issuer: MicCheck Jwt__Issuer: MicCheck
Jwt__Audience: MicCheck Jwt__Audience: MicCheck
+13 -21
View File
@@ -3,27 +3,19 @@ set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
build_api() { echo "Building solution..."
echo "Building API..." dotnet build "$SCRIPT_DIR/MicCheck.slnx"
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."
}
build_admin() { echo "Installing admin dependencies..."
echo "Building admin..."
npm --prefix "$SCRIPT_DIR/src/admin" ci --silent 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."
}
case "${1:-all}" in echo "Building admin..."
api) build_api ;; npm --prefix "$SCRIPT_DIR/src/admin" run build
admin) build_admin ;;
all) build_api && build_admin ;; echo "Running .NET unit tests..."
*) dotnet test "$SCRIPT_DIR/tests/api/MicCheck.Api.Tests.Unit/MicCheck.Api.Tests.Unit.csproj"
echo "Usage: $0 [api|admin|all]"
exit 1 echo "Running Jest tests..."
;; npm --prefix "$SCRIPT_DIR/src/admin" test
esac
echo "Build and tests complete."
-60
View File
@@ -1,60 +0,0 @@
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:
+59
View File
@@ -0,0 +1,59 @@
# System Design — QA Physical Architecture
Physical deployment topology for the QA environment. Source: `deploy/qa/docker-compose.qa.yml`, `scripts/ci/*.sh`, `.github/workflows/ci.yml`.
```mermaid
flowchart TB
subgraph CI["Self-hosted CI runner (qa)"]
Pipeline["deploy-qa.sh / smoke-qa.sh"]
end
subgraph Registry["Gitea Container Registry"]
ApiImage["miccheck-api:qa"]
AdminImage["miccheck-admin:qa"]
end
subgraph QAHost["QA Docker host — network: miccheck-qa-net (bridge)"]
subgraph AdminC["admin container<br/>nginx:1.27-alpine"]
Nginx["nginx<br/>serves Vue SPA<br/>proxies /api/, /health"]
end
subgraph ApiC["api container<br/>aspnet:10.0"]
Api["MicCheck.Api<br/>ASPNETCORE_URLS=http://+:8080"]
end
subgraph DbC["db container<br/>postgres:16-alpine"]
Db[("miccheck DB")]
end
end
Browser["Browser / QA tester"]
Pipeline -->|docker build/push| Registry
Registry -->|pull :qa| AdminC
Registry -->|pull :qa| ApiC
Browser -->|":3001 (QA_ADMIN_PORT)"| Nginx
Nginx -->|"http://api:8080 (internal)"| Api
Api -->|"Host=db;5432 (internal)"| Db
Pipeline -.->|"127.0.0.1:55432 (bridge-gateway bound)"| Db
```
## Components
| Component | Image | Exposure | Notes |
|---|---|---|---|
| `admin` | `nginx:1.27-alpine` (built from `src/admin/Dockerfile.ci`, `node:22-alpine` build stage) | `${QA_ADMIN_PORT:-3001}:80` published on host | Serves Vue/Vite SPA; nginx (`src/admin/nginx.conf`) reverse-proxies `/api/` and `/health` to `api:8080` |
| `api` | `mcr.microsoft.com/dotnet/aspnet:10.0` (built from `src/api/MicCheck.Api/Dockerfile.ci`, `sdk:10.0` build stage) | no published host port — internal only, reached via `admin`'s nginx proxy | `ASPNETCORE_URLS=http://+:8080`; JWT config (`Jwt__SecretKey`/`Issuer=MicCheck`/`Audience=MicCheck`) from `JWT_SECRET_KEY` secret; healthcheck `curl localhost:8080/health` |
| `db` | `postgres:16-alpine` | `${DB_BIND_HOST:-127.0.0.1}:55432 → 5432`, bound to docker bridge gateway IP (not `0.0.0.0`) — reachable only from sibling CI containers, not off-box | DB `miccheck`, user `miccheck`, password from `POSTGRES_PASSWORD` secret; volume `miccheck-qa-pgdata` |
All three services run on an isolated bridge network `miccheck-qa-net` (project `miccheck-qa`), separate from the local dev Aspire stack.
## Deploy flow
1. `build-and-push` job builds `api` and `admin` images, tags with git SHA and `qa`, pushes to Gitea registry as `$REGISTRY/$REGISTRY_OWNER/miccheck-api` / `miccheck-admin`.
2. `deploy-qa` job (self-hosted runner, `main` branch only) pulls `:qa` images and runs `docker-compose.qa.yml` via `deploy-qa.sh`.
3. `smoke-qa` job hits the deployed stack (`smoke-qa.sh`) to verify health.
Secrets used: `REGISTRY`, `REGISTRY_OWNER`, `REGISTRY_USER`, `REGISTRY_TOKEN`, `JWT_SECRET_KEY`, `POSTGRES_PASSWORD`. Vars: `QA_ADMIN_PORT`.
+2 -1
View File
@@ -29,10 +29,11 @@ Code in the API is organized by feature area (e.g. `Features`, `Segments`, `Iden
## Running locally ## Running locally
`docker-compose.yml` provides supporting services. `MicCheck.AppHost` (.NET Aspire) orchestrates the API and admin app for local development. `MicCheck.AppHost` (.NET Aspire) orchestrates the API, admin app, and supporting services for local development.
```sh ```sh
./dev-build.sh ./dev-build.sh
aspire run --project src/MicCheck.AppHost
``` ```
## Testing ## Testing
+1
View File
@@ -11,6 +11,7 @@ registry_login
export API_IMAGE ADMIN_IMAGE export API_IMAGE ADMIN_IMAGE
export JWT_SECRET_KEY="${JWT_SECRET_KEY:?JWT_SECRET_KEY env var is required}" 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}" export QA_ADMIN_PORT="${QA_ADMIN_PORT:-3001}"
# Bind narrowly to docker's bridge gateway IP rather than 0.0.0.0: reachable # Bind narrowly to docker's bridge gateway IP rather than 0.0.0.0: reachable
+1 -1
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" log "Resolved docker host as $CI_HOST for reaching the QA stack's published ports"
export MICCHECK_API_BASE_URL="$BASE_URL" 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=password}" 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}}"
log "Running API integration suite against $BASE_URL" 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 dotnet test tests/api/MicCheck.Api.Tests.Integration/MicCheck.Api.Tests.Integration.csproj -c Release --logger trx
+40
View File
@@ -0,0 +1,40 @@
namespace MicCheck.Common;
internal 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);
}
}
+22
View File
@@ -0,0 +1,22 @@
using System.Net.Http.Json;
using MicCheck.Common;
namespace MicCheck;
public class FeatureClient(HttpClient httpClient) : IFeatureClient
{
public async Task<bool> IsEnabledAsync(string featureName, bool defaultValue = false, CancellationToken cancellationToken = default)
{
Guard.Empty(featureName, nameof(featureName));
var flags = await httpClient.GetFromJsonAsync<List<FlagResult>>("api/v1/flags", cancellationToken);
var match = flags?.FirstOrDefault(flag => string.Equals(flag.Feature.Name, featureName, StringComparison.OrdinalIgnoreCase));
return match?.Enabled ?? defaultValue;
}
}
public interface IFeatureClient
{
Task<bool> IsEnabledAsync(string featureName, bool defaultValue = false, CancellationToken cancellationToken = default);
}
+5
View File
@@ -0,0 +1,5 @@
namespace MicCheck;
public record FlagResult(int Id, FeatureSummary Feature, bool Enabled, string? FeatureStateValue);
public record FeatureSummary(int Id, string Name, string Type);
@@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<RootNamespace>MicCheck</RootNamespace>
</PropertyGroup>
</Project>
@@ -1,5 +1,8 @@
using MicCheck.Api.Common.Security.Authorization; using MicCheck.Api.Common.Security.Authorization;
using MicCheck.Api.Common; using MicCheck.Api.Common;
using MicCheck.Api.Features;
using MicCheck.Api.Identities;
using MicCheck.Api.Segments;
using MicCheck.Api.Webhooks; using MicCheck.Api.Webhooks;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@@ -146,9 +149,9 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho
} }
[HttpGet("api/v1/environment/{apiKey}/identities")] [HttpGet("api/v1/environment/{apiKey}/identities")]
public async Task<ActionResult<PaginatedResponse<Identities.AdminIdentityResponse>>> ListIdentities( public async Task<ActionResult<PaginatedResponse<AdminIdentityResponse>>> ListIdentities(
string apiKey, string apiKey,
[FromServices] Identities.AdminIdentityService adminIdentityService, [FromServices] AdminIdentityService adminIdentityService,
[FromQuery] int page = 1, [FromQuery] int page = 1,
[FromQuery] int pageSize = 20, [FromQuery] int pageSize = 20,
CancellationToken ct = default) CancellationToken ct = default)
@@ -158,15 +161,15 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho
if (environment is null) return NotFound(); if (environment is null) return NotFound();
var result = await adminIdentityService.ListAsync(environment.Id, page, pageSize, ct); var result = await adminIdentityService.ListAsync(environment.Id, page, pageSize, ct);
var results = result.Items.Select(Identities.AdminIdentityResponse.From).ToList(); var results = result.Items.Select(AdminIdentityResponse.From).ToList();
return Ok(new PaginatedResponse<Identities.AdminIdentityResponse>(result.Total, null, null, results)); return Ok(new PaginatedResponse<AdminIdentityResponse>(result.Total, null, null, results));
} }
[HttpPost("api/v1/environment/{apiKey}/identities")] [HttpPost("api/v1/environment/{apiKey}/identities")]
public async Task<ActionResult<Identities.AdminIdentityResponse>> CreateIdentity( public async Task<ActionResult<AdminIdentityResponse>> CreateIdentity(
string apiKey, string apiKey,
[FromBody] Identities.CreateIdentityRequest request, [FromBody] CreateIdentityRequest request,
[FromServices] Identities.AdminIdentityService adminIdentityService, [FromServices] AdminIdentityService adminIdentityService,
CancellationToken ct) CancellationToken ct)
{ {
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct); var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
@@ -177,13 +180,13 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho
return Conflict(new { error = "An identity with this identifier already exists in the environment." }); return Conflict(new { error = "An identity with this identifier already exists in the environment." });
return CreatedAtAction(nameof(GetIdentity), new { apiKey, id = identity.Id }, return CreatedAtAction(nameof(GetIdentity), new { apiKey, id = identity.Id },
Identities.AdminIdentityResponse.From(identity)); AdminIdentityResponse.From(identity));
} }
[HttpGet("api/v1/environment/{apiKey}/identity/{id}")] [HttpGet("api/v1/environment/{apiKey}/identity/{id}")]
public async Task<ActionResult<Identities.AdminIdentityResponse>> GetIdentity( public async Task<ActionResult<AdminIdentityResponse>> GetIdentity(
string apiKey, int id, string apiKey, int id,
[FromServices] Identities.AdminIdentityService adminIdentityService, [FromServices] AdminIdentityService adminIdentityService,
CancellationToken ct) CancellationToken ct)
{ {
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct); var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
@@ -191,13 +194,13 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho
var identity = await adminIdentityService.FindByIdAsync(id, environment.Id, ct); var identity = await adminIdentityService.FindByIdAsync(id, environment.Id, ct);
if (identity is null) return NotFound(); if (identity is null) return NotFound();
return Ok(Identities.AdminIdentityResponse.From(identity)); return Ok(AdminIdentityResponse.From(identity));
} }
[HttpDelete("api/v1/environment/{apiKey}/identity/{id}")] [HttpDelete("api/v1/environment/{apiKey}/identity/{id}")]
public async Task<IActionResult> DeleteIdentity( public async Task<IActionResult> DeleteIdentity(
string apiKey, int id, string apiKey, int id,
[FromServices] Identities.AdminIdentityService adminIdentityService, [FromServices] AdminIdentityService adminIdentityService,
CancellationToken ct) CancellationToken ct)
{ {
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct); var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
@@ -211,10 +214,10 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho
} }
[HttpPut("api/v1/environment/{apiKey}/identity/{id}/trait/{key}")] [HttpPut("api/v1/environment/{apiKey}/identity/{id}/trait/{key}")]
public async Task<ActionResult<Identities.TraitResponse>> UpsertIdentityTrait( public async Task<ActionResult<TraitResponse>> UpsertIdentityTrait(
string apiKey, int id, string key, string apiKey, int id, string key,
[FromBody] Identities.UpsertTraitRequest request, [FromBody] UpsertTraitRequest request,
[FromServices] Identities.AdminIdentityService adminIdentityService, [FromServices] AdminIdentityService adminIdentityService,
CancellationToken ct) CancellationToken ct)
{ {
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct); var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
@@ -228,7 +231,7 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho
[HttpDelete("api/v1/environment/{apiKey}/identity/{id}/trait/{key}")] [HttpDelete("api/v1/environment/{apiKey}/identity/{id}/trait/{key}")]
public async Task<IActionResult> DeleteIdentityTrait( public async Task<IActionResult> DeleteIdentityTrait(
string apiKey, int id, string key, string apiKey, int id, string key,
[FromServices] Identities.AdminIdentityService adminIdentityService, [FromServices] AdminIdentityService adminIdentityService,
CancellationToken ct) CancellationToken ct)
{ {
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct); var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
@@ -240,9 +243,9 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho
} }
[HttpGet("api/v1/environment/{apiKey}/identity/{id}/featurestates")] [HttpGet("api/v1/environment/{apiKey}/identity/{id}/featurestates")]
public async Task<ActionResult<IReadOnlyList<Features.FeatureStateResponse>>> GetIdentityFeatureStates( public async Task<ActionResult<IReadOnlyList<FeatureStateResponse>>> GetIdentityFeatureStates(
string apiKey, int id, string apiKey, int id,
[FromServices] Identities.AdminIdentityService adminIdentityService, [FromServices] AdminIdentityService adminIdentityService,
CancellationToken ct) CancellationToken ct)
{ {
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct); var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
@@ -252,14 +255,14 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho
if (identity is null) return NotFound(); if (identity is null) return NotFound();
var states = await adminIdentityService.GetFeatureStatesAsync(id, environment.Id, ct); var states = await adminIdentityService.GetFeatureStatesAsync(id, environment.Id, ct);
return Ok(states.Select(Features.FeatureStateResponse.From).ToList()); return Ok(states.Select(FeatureStateResponse.From).ToList());
} }
[HttpPut("api/v1/environment/{apiKey}/identity/{id}/featurestate/{featureId}")] [HttpPut("api/v1/environment/{apiKey}/identity/{id}/featurestate/{featureId}")]
public async Task<ActionResult<Features.FeatureStateResponse>> SetIdentityFeatureState( public async Task<ActionResult<FeatureStateResponse>> SetIdentityFeatureState(
string apiKey, int id, int featureId, string apiKey, int id, int featureId,
Features.UpdateFeatureStateRequest request, UpdateFeatureStateRequest request,
[FromServices] Identities.AdminIdentityService adminIdentityService, [FromServices] AdminIdentityService adminIdentityService,
CancellationToken ct) CancellationToken ct)
{ {
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct); var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
@@ -269,13 +272,13 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho
if (identity is null) return NotFound(); if (identity is null) return NotFound();
var state = await adminIdentityService.SetFeatureStateAsync(id, environment.Id, featureId, request.Enabled, request.Value, ct); var state = await adminIdentityService.SetFeatureStateAsync(id, environment.Id, featureId, request.Enabled, request.Value, ct);
return Ok(Features.FeatureStateResponse.From(state)); return Ok(FeatureStateResponse.From(state));
} }
[HttpDelete("api/v1/environment/{apiKey}/identity/{id}/featurestate/{featureId}")] [HttpDelete("api/v1/environment/{apiKey}/identity/{id}/featurestate/{featureId}")]
public async Task<IActionResult> DeleteIdentityFeatureState( public async Task<IActionResult> DeleteIdentityFeatureState(
string apiKey, int id, int featureId, string apiKey, int id, int featureId,
[FromServices] Identities.AdminIdentityService adminIdentityService, [FromServices] AdminIdentityService adminIdentityService,
CancellationToken ct) CancellationToken ct)
{ {
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct); var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
@@ -289,22 +292,22 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho
} }
[HttpGet("api/v1/environment/{apiKey}/featurestates")] [HttpGet("api/v1/environment/{apiKey}/featurestates")]
public async Task<ActionResult<IReadOnlyList<Features.FeatureStateResponse>>> ListFeatureStates( public async Task<ActionResult<IReadOnlyList<FeatureStateResponse>>> ListFeatureStates(
string apiKey, string apiKey,
[FromServices] Features.FeatureStateService featureStateService, [FromServices] FeatureStateService featureStateService,
CancellationToken ct) CancellationToken ct)
{ {
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct); var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
if (environment is null) return NotFound(); if (environment is null) return NotFound();
var states = await featureStateService.ListByEnvironmentAsync(environment.Id, ct); var states = await featureStateService.ListByEnvironmentAsync(environment.Id, ct);
return Ok(states.Select(Features.FeatureStateResponse.From).ToList()); return Ok(states.Select(FeatureStateResponse.From).ToList());
} }
[HttpGet("api/v1/environment/{apiKey}/featurestate/{id}")] [HttpGet("api/v1/environment/{apiKey}/featurestate/{id}")]
public async Task<ActionResult<Features.FeatureStateResponse>> GetFeatureState( public async Task<ActionResult<FeatureStateResponse>> GetFeatureState(
string apiKey, int id, string apiKey, int id,
[FromServices] Features.FeatureStateService featureStateService, [FromServices] FeatureStateService featureStateService,
CancellationToken ct) CancellationToken ct)
{ {
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct); var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
@@ -312,14 +315,14 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho
var state = await featureStateService.FindByIdAsync(id, environment.Id, ct); var state = await featureStateService.FindByIdAsync(id, environment.Id, ct);
if (state is null) return NotFound(); if (state is null) return NotFound();
return Ok(Features.FeatureStateResponse.From(state)); return Ok(FeatureStateResponse.From(state));
} }
[HttpPut("api/v1/environment/{apiKey}/featurestate/{id}")] [HttpPut("api/v1/environment/{apiKey}/featurestate/{id}")]
public async Task<ActionResult<Features.FeatureStateResponse>> UpdateFeatureState( public async Task<ActionResult<FeatureStateResponse>> UpdateFeatureState(
string apiKey, int id, string apiKey, int id,
Features.UpdateFeatureStateRequest request, UpdateFeatureStateRequest request,
[FromServices] Features.FeatureStateService featureStateService, [FromServices] FeatureStateService featureStateService,
CancellationToken ct) CancellationToken ct)
{ {
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct); var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
@@ -329,14 +332,14 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho
if (state is null) return NotFound(); if (state is null) return NotFound();
var updated = await featureStateService.UpdateAsync(id, request.Enabled, request.Value, ct); var updated = await featureStateService.UpdateAsync(id, request.Enabled, request.Value, ct);
return Ok(Features.FeatureStateResponse.From(updated)); return Ok(FeatureStateResponse.From(updated));
} }
[HttpPatch("api/v1/environment/{apiKey}/featurestate/{id}")] [HttpPatch("api/v1/environment/{apiKey}/featurestate/{id}")]
public async Task<ActionResult<Features.FeatureStateResponse>> PatchFeatureState( public async Task<ActionResult<FeatureStateResponse>> PatchFeatureState(
string apiKey, int id, string apiKey, int id,
Features.PatchFeatureStateRequest request, PatchFeatureStateRequest request,
[FromServices] Features.FeatureStateService featureStateService, [FromServices] FeatureStateService featureStateService,
CancellationToken ct) CancellationToken ct)
{ {
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct); var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
@@ -346,15 +349,15 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho
if (state is null) return NotFound(); if (state is null) return NotFound();
var updated = await featureStateService.PatchAsync(id, request.Enabled, request.Value, ct); var updated = await featureStateService.PatchAsync(id, request.Enabled, request.Value, ct);
return Ok(Features.FeatureStateResponse.From(updated)); return Ok(FeatureStateResponse.From(updated));
} }
// ─── Feature Segments ──────────────────────────────────────────────────── // ─── Feature Segments ────────────────────────────────────────────────────
[HttpGet("api/v1/environment/{apiKey}/feature/{featureId}/segments")] [HttpGet("api/v1/environment/{apiKey}/feature/{featureId}/segments")]
public async Task<ActionResult<IReadOnlyList<Features.FeatureSegmentResponse>>> ListFeatureSegments( public async Task<ActionResult<IReadOnlyList<FeatureSegmentResponse>>> ListFeatureSegments(
string apiKey, int featureId, string apiKey, int featureId,
[FromServices] Features.FeatureSegmentService featureSegmentService, [FromServices] FeatureSegmentService featureSegmentService,
CancellationToken ct) CancellationToken ct)
{ {
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct); var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
@@ -365,10 +368,10 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho
} }
[HttpPost("api/v1/environment/{apiKey}/feature/{featureId}/segments")] [HttpPost("api/v1/environment/{apiKey}/feature/{featureId}/segments")]
public async Task<ActionResult<Features.FeatureSegmentResponse>> CreateFeatureSegment( public async Task<ActionResult<FeatureSegmentResponse>> CreateFeatureSegment(
string apiKey, int featureId, string apiKey, int featureId,
Features.CreateFeatureSegmentRequest request, CreateFeatureSegmentRequest request,
[FromServices] Features.FeatureSegmentService featureSegmentService, [FromServices] FeatureSegmentService featureSegmentService,
CancellationToken ct) CancellationToken ct)
{ {
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct); var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
@@ -388,10 +391,10 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho
} }
[HttpPut("api/v1/environment/{apiKey}/feature/{featureId}/segment/{id}")] [HttpPut("api/v1/environment/{apiKey}/feature/{featureId}/segment/{id}")]
public async Task<ActionResult<Features.FeatureSegmentResponse>> UpdateFeatureSegment( public async Task<ActionResult<FeatureSegmentResponse>> UpdateFeatureSegment(
string apiKey, int featureId, int id, string apiKey, int featureId, int id,
Features.UpdateFeatureSegmentRequest request, UpdateFeatureSegmentRequest request,
[FromServices] Features.FeatureSegmentService featureSegmentService, [FromServices] FeatureSegmentService featureSegmentService,
CancellationToken ct) CancellationToken ct)
{ {
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct); var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
@@ -405,7 +408,7 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho
[HttpDelete("api/v1/environment/{apiKey}/feature/{featureId}/segment/{id}")] [HttpDelete("api/v1/environment/{apiKey}/feature/{featureId}/segment/{id}")]
public async Task<IActionResult> DeleteFeatureSegment( public async Task<IActionResult> DeleteFeatureSegment(
string apiKey, int featureId, int id, string apiKey, int featureId, int id,
[FromServices] Features.FeatureSegmentService featureSegmentService, [FromServices] FeatureSegmentService featureSegmentService,
CancellationToken ct) CancellationToken ct)
{ {
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct); var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
@@ -418,11 +421,11 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho
// ─── Identity Segments ─────────────────────────────────────────────────── // ─── Identity Segments ───────────────────────────────────────────────────
[HttpGet("api/v1/environment/{apiKey}/identity/{id}/segments")] [HttpGet("api/v1/environment/{apiKey}/identity/{id}/segments")]
public async Task<ActionResult<IReadOnlyList<Segments.SegmentSummaryResponse>>> GetIdentitySegments( public async Task<ActionResult<IReadOnlyList<SegmentSummaryResponse>>> GetIdentitySegments(
string apiKey, int id, string apiKey, int id,
[FromServices] Identities.AdminIdentityService adminIdentityService, [FromServices] AdminIdentityService adminIdentityService,
[FromServices] Segments.SegmentService segmentService, [FromServices] SegmentService segmentService,
[FromServices] Segments.SegmentEvaluator segmentEvaluator, [FromServices] SegmentEvaluator segmentEvaluator,
CancellationToken ct) CancellationToken ct)
{ {
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct); var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
@@ -434,7 +437,7 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho
var segments = await segmentService.ListByProjectAsync(environment.ProjectId, ct); var segments = await segmentService.ListByProjectAsync(environment.ProjectId, ct);
var matching = segments var matching = segments
.Where(s => segmentEvaluator.Evaluate(s, identity.Traits.ToList(), identity.Identifier)) .Where(s => segmentEvaluator.Evaluate(s, identity.Traits.ToList(), identity.Identifier))
.Select(s => new Segments.SegmentSummaryResponse(s.Id, s.Name)) .Select(s => new SegmentSummaryResponse(s.Id, s.Name))
.ToList(); .ToList();
return Ok(matching); return Ok(matching);
@@ -0,0 +1,79 @@
using System.Net;
using System.Text;
using Moq;
using Moq.Protected;
using NUnit.Framework;
namespace MicCheck.Client.Tests.Unit;
[TestFixture]
public class FeatureClientTests
{
[Test]
public async Task WhenFeatureIsEnabled_ThenIsEnabledAsyncReturnsTrue()
{
var client = CreateClient(HttpStatusCode.OK, """
[{"id":1,"feature":{"id":1,"name":"my-feature","type":"STANDARD"},"enabled":true,"featureStateValue":null}]
""");
var result = await client.IsEnabledAsync("my-feature");
Assert.That(result, Is.True);
}
[Test]
public async Task WhenFeatureIsDisabled_ThenIsEnabledAsyncReturnsFalse()
{
var client = CreateClient(HttpStatusCode.OK, """
[{"id":1,"feature":{"id":1,"name":"my-feature","type":"STANDARD"},"enabled":false,"featureStateValue":null}]
""");
var result = await client.IsEnabledAsync("my-feature");
Assert.That(result, Is.False);
}
[Test]
public async Task WhenFeatureNameDiffersOnlyByCase_ThenIsEnabledAsyncStillMatches()
{
var client = CreateClient(HttpStatusCode.OK, """
[{"id":1,"feature":{"id":1,"name":"My-Feature","type":"STANDARD"},"enabled":true,"featureStateValue":null}]
""");
var result = await client.IsEnabledAsync("my-feature");
Assert.That(result, Is.True);
}
[Test]
public async Task WhenFeatureIsNotFound_ThenIsEnabledAsyncReturnsProvidedDefaultValue()
{
var client = CreateClient(HttpStatusCode.OK, "[]");
var result = await client.IsEnabledAsync("missing-feature", defaultValue: true);
Assert.That(result, Is.True);
}
[Test]
public void WhenFeatureNameIsEmpty_ThenIsEnabledAsyncThrowsArgumentException()
{
var client = CreateClient(HttpStatusCode.OK, "[]");
Assert.That(async () => await client.IsEnabledAsync(" "), Throws.ArgumentException);
}
private static FeatureClient CreateClient(HttpStatusCode responseStatus, string responseBody)
{
var handler = new Mock<HttpMessageHandler>();
handler.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync", ItExpr.IsAny<HttpRequestMessage>(), ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(new HttpResponseMessage(responseStatus)
{
Content = new StringContent(responseBody, Encoding.UTF8, "application/json")
});
var httpClient = new HttpClient(handler.Object) { BaseAddress = new Uri("https://example.com") };
return new FeatureClient(httpClient);
}
}
@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion>
<IsPackable>false</IsPackable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.3.0" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="NUnit" Version="4.5.1" />
<PackageReference Include="NUnit3TestAdapter" Version="6.2.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../../src/MicCheck.Client/MicCheck.Client.csproj" />
</ItemGroup>
</Project>